Compare commits

...

34 Commits

Author SHA1 Message Date
Yoga Pangestu
485ae8e5ce feat: add getNames method to ProductService for retrieving product names 2026-08-09 16:31:03 +07:00
Yoga Pangestu
0cce4ee73d feat: enhance StatCard component with description prop and update chart tooltips for better localization
- Added optional description prop to StatCard for additional context.
- Updated ChartTooltipContent to format numbers according to Indonesian locale.
- Refactored revenue and expense charts in the Analysis page to use new chart configurations and improved tooltip content.
- Introduced DashboardPieChart component for displaying order statistics by channel, payment type, marketing, and status.
- Adjusted revenue summary calculations in the Dashboard page to reflect changes in data structure.
2026-08-09 16:30:16 +07:00
Yoga Pangestu
3fab97e7e5 fix: correct array conversion in getForCutting method of RawMaterialVariantService 2026-08-09 15:08:00 +07:00
Yoga Pangestu
1918f35ab2 fix: update dashboard link activation logic in AppSidebar component 2026-08-09 13:16:33 +07:00
Yoga Pangestu
2bb814702a feat: add ChartContainer and related components for enhanced charting capabilities in the dashboard
- Introduced ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, and ChartLegendContent components to streamline chart rendering and tooltip management.
- Replaced existing donut chart implementation with a new pie chart component utilizing the new charting system.
- Updated dashboard to utilize the new chart components, improving maintainability and visual consistency.
- Refactored chart data handling and configuration for better integration with the new charting components.
2026-08-09 12:52:12 +07:00
Yoga Pangestu
a1ba9564c7 fix: correct total calculation by adjusting discount and negotiation price handling in transaction create and edit components 2026-08-09 12:31:01 +07:00
Yoga Pangestu
eba21d3d3a refactor: clean up code by removing unused imports and improving formatting across multiple files 2026-08-09 11:50:38 +07:00
Yoga Pangestu
0023309a8f Refactor services to improve role checks and streamline data retrieval
- Updated CustomerService to simplify getAll method.
- Refactored ProductService to utilize HasRoleChecks trait and improved role verification logic.
- Enhanced ProductVariantService with new methods for fetching data for restocking and transactions.
- Cleaned up RawMaterialService by removing unused methods and improving data retrieval.
- Adjusted SupplierService to streamline getAll method.
- Refactored RoleService to use Spatie's Role model and improved role filtering logic.
- Updated NotificationService to handle role labels more effectively.
- Improved StockMutationService by removing redundant paginated method.
- Cleaned up various frontend components to directly accept necessary props instead of nested data objects.
- Updated tests to reflect changes in service method names and ensure proper notification handling.
2026-08-09 11:33:25 +07:00
Yoga Pangestu
46af27e537 refactor: rename create/delete methods to store/destroy across multiple services; add transaction handling for cash and stock adjustments 2026-08-08 14:31:58 +07:00
Yoga Pangestu
1c19e6c690 refactor: update service method names from create/delete to store/destroy across multiple controllers 2026-08-08 14:31:44 +07:00
Yoga Pangestu
48c031a86f feat: update various controllers to return back instead of redirecting to index routes; add payroll adjustment and payroll controllers 2026-08-08 14:18:04 +07:00
Yoga Pangestu
6627422655 feat: enhance analysis page with additional revenue metrics and improve data handling 2026-08-08 13:40:18 +07:00
Yoga Pangestu
f7c13826eb feat: update dashboard titles to reflect today's data for attendance, revenue, and expenses 2026-08-08 12:41:52 +07:00
Yoga Pangestu
515b2e9848 refactor: reorganize admin routes for better structure and readability 2026-08-08 12:25:59 +07:00
Yoga Pangestu
f7ce2f1620 feat: enhance validation rules for various requests and update attribute names 2026-08-08 01:52:16 +07:00
Yoga Pangestu
cd0995f04e feat: add formatted accessors for various models and enhance date formatting 2026-08-07 23:04:28 +07:00
Yoga Pangestu
4733df5827 Add homepage types and update routing to use HomepageController
- Created a new TypeScript file for homepage-related types including Product, ProductVariant, ProductCategory, and HomepageData.
- Updated the index.ts to export the new homepage types.
- Refactored the web routes to replace the inline closure for the home route with the HomepageController.
2026-08-07 17:29:59 +07:00
Yoga Pangestu
3c945a1372 fix: update error messages for form submissions to improve clarity 2026-08-07 15:43:52 +07:00
Yoga Pangestu
4b70da6715 feat: enhance leave request validation and fix timezone issues 2026-08-07 15:36:44 +07:00
Yoga Pangestu
14140c39cc feat: enhance attendance index with leave tracking and employee details 2026-08-07 15:27:48 +07:00
Yoga Pangestu
e4d956843c feat: update TodayAttendanceAlert component and enhance card styling in attendance and dashboard pages 2026-08-07 14:32:45 +07:00
Yoga Pangestu
166419134f Refactor component imports for consistency and organization
- Updated import paths for various components to align with new directory structure.
- Changed imports from 'row-actions', 'confirm-dialog', 'image-preview-button', and 'file-upload' to their respective new locations in 'data-display', 'dialogs', and 'inputs'.
- Adjusted imports in multiple pages including purchase, restock, transaction, category, customer, product, raw-material, supplier, roles, and settings.
- Ensured all relevant components are imported from their new locations to maintain functionality.
2026-08-07 13:48:46 +07:00
Yoga Pangestu
d648d48632 feat: implement TodayAttendanceAlert component for improved attendance display and interaction 2026-08-07 13:25:41 +07:00
Yoga Pangestu
28f7ea0006 feat: add RETAIL case to ProductStockQuality enum and update label method 2026-08-07 13:02:07 +07:00
Yoga Pangestu
d10f731839 feat: enhance documentation with workflow tracing and session notes guidelines 2026-08-07 11:40:09 +07:00
Yoga Pangestu
85556f8779 feat: enhance product management with approval and rejection workflows
- Added approval and rejection functionality for products, including new routes and methods in the ProductController.
- Implemented UI changes to display product status (pending, rejected) with appropriate badges and actions.
- Introduced a RejectDialog component for providing rejection reasons.
- Updated product columns to handle new actions for approving and rejecting products.
- Enhanced variant management to restrict actions based on product status.
- Refactored various components to improve code organization and readability.
2026-08-07 11:23:41 +07:00
Yoga Pangestu
f7a91739f9 feat: integrate thermal printer functionality with receipt encoding
- Added @point-of-sale/receipt-printer-encoder dependency to package.json.
- Implemented useThermalPrinter hook for managing Bluetooth and USB printer connections.
- Created encodeOrderReceipt function to format receipt data for printing.
- Enhanced TransactionIndex component to include printer connection options and receipt printing functionality.
- Added dropdown menu for selecting printer connection type (Bluetooth/USB) and printing receipt options (58mm/80mm).
- Updated TransactionCardRow to include print receipt actions.
- Defined TypeScript types for receipt printer encoder and web Bluetooth/USB APIs.
- Configured Vite to optimize dependencies for receipt printer encoder.
2026-08-07 09:34:11 +07:00
Yoga Pangestu
add157183b feat: replace useMemo with useState for selectedProductId and update product selection UI 2026-08-07 09:11:47 +07:00
Yoga Pangestu
9e60a1cf39 feat: add tiktok and shopee order IDs to transaction model and UI components 2026-08-07 09:04:16 +07:00
Yoga Pangestu
74c374e126 feat: update search placeholders to Indonesian language across various components 2026-08-07 08:55:59 +07:00
Yoga Pangestu
50b2be68ac Add error handling with toast notifications for form submissions
- Integrated toast notifications to display error messages when form submissions fail across various components, enhancing user feedback and experience.
- Updated components including DeleteUser, FormDialog, ManageTwoFactor, TwoFactorRecoveryCodes, TwoFactorSetupModal, PayrollPeriodShow, EmployeeCreate, EmployeeEdit, CuttingCreate, CuttingEdit, PurchaseCreate, PurchaseEdit, RestockCreate, RestockEdit, TransactionCreate, TransactionEdit, Category management, Product management, Raw Material management, Role management, Settings, and Authentication pages.
2026-08-07 08:50:11 +07:00
Yoga Pangestu
de45ae3bb7 feat: add search parameter cleanup in useServerTable hook 2026-08-07 08:44:30 +07:00
Yoga Pangestu
c6a87a64c9 feat: enhance dashboard with detailed statistics and charts
- Refactored the dashboard component to include attendance, revenue, and expense summaries.
- Added donut charts for order statistics by channel, payment type, marketing, and status.
- Implemented a greeting message based on the current time and user information.
- Updated routing to use DashboardController for the dashboard view.
- Introduced a new AnalysisController for future analysis features.
2026-08-07 08:35:01 +07:00
Yoga Pangestu
7a1b107ce5 Refactor AGENTS.md to create a comprehensive module/feature development checklist, consolidating session notes, model relationships, casting, scopes, reorganized model structures, and conventions for backend and frontend development. Added mandatory reading section for project context and conventions. 2026-08-07 01:37:36 +07:00
273 changed files with 14092 additions and 4142 deletions

88
.ai/CONTEXT.md Normal file
View File

@ -0,0 +1,88 @@
# DST Collection — Project Context
## Tentang Project
**DST Collection** adalah aplikasi ERP untuk brand pakaian wanita (daster, dress, dll). Aplikasi ini mengelola dua area bisnis utama:
1. **Toko** — penjualan, produk, keuangan, HR
2. **Konveksi** — bahan baku, cutting, pembelian bahan
## Tech Stack
| Layer | Technology |
|-------|------------|
| Backend | Laravel 13, PHP 8.3 |
| Frontend | React 19, Inertia.js 3, TypeScript |
| UI | shadcn/ui, Radix UI, Tailwind CSS 4 |
| Auth | Laravel Fortify, Spatie Permission |
| DB | MySQL (SoftDeletes di semua model utama) |
## Roles & Akses
### role: `developer`, `owner`
- **Akses**: Semua module, semua aksi
- **Catatan**: Selalu muncul di semua sidebar
### role: `admin-toko`
- **Akses**: Full CRUD semua module toko
- **Modules**: Kategori, Produk, Customer, Transaksi, Restock, Kas Toko, Pengeluaran, Kasbon, Gaji, Pegawai, Presensi, Cuti, Settings
- **Catatan**: TIDAK akses Supplier, Bahan Baku, Belanja, Cutting
### role: `direktur`
- **Akses**: Readonly semua data toko + bisa ajukan kasbon
- **Modules**: Semua module toko (readonly), kecuali Supplier, Bahan Baku, Belanja, Cutting
- **Catatan**: Bisa lihat semua data pegawai toko, tapi bukan admin bahan baku
### role: `admin-bahan-baku`
- **Akses**: Full CRUD untuk konveksi
- **Modules**: Bahan Baku, Supplier, Belanja, Cutting, Pegawai (hanya role admin-bahan-baku), Kasbon (lihat semua + ACC/bayar)
- **Catatan**: TIDAK akses module toko lainnya
### role: `cashier`
- **Akses**: Transaksi (full CRUD), Customer (full CRUD), Produk (readonly), Kasbon (CRUD own data)
- **Modules**: Transaksi, Customer, Produk (view), Kasbon, Gaji (view), Presensi, Cuti
### role: `marketing-offline`, `marketing-online`
- **Akses**: Transaksi (readonly yang punya dia), Produk (readonly), Customer (full CRUD)
- **Modules**: Transaksi (view only own), Produk (view), Customer, Kasbon (CRUD own data), Presensi, Cuti
### role: `stok-opname`
- **Akses**: Stok Opname (full CRUD + submit)
- **Modules**: Stok Opname, Produk (view), Presensi, Cuti
### role: `non-operator`
- **Akses**: Basic (dashboard, presensi, cuti, kasbon)
- **Modules**: Presensi, Cuti, Kasbon (CRUD own data)
## Module Overview
| # | Module | Route Group | Controller Path | Service Path |
|---|--------|-------------|-----------------|--------------|
| 1 | Kategori | `admin/master/categories` | `Admin/Master/CategoryController` | `Admin/Master/CategoryService` |
| 2 | Produk | `admin/master/products` | `Admin/Master/Product/ProductController` | `Admin/Master/Product/ProductService` |
| 3 | Bahan Baku | `admin/master/raw-materials` | `Admin/Master/RawMaterial/RawMaterialController` | `Admin/Master/RawMaterial/RawMaterialService` |
| 4 | Supplier | `admin/master/suppliers` | `Admin/Master/SupplierController` | `Admin/Master/SupplierService` |
| 5 | Customer | `admin/master/customers` | `Admin/Master/CustomerController` | `Admin/Master/CustomerService` |
| 6 | Belanja | `admin/manage/purchases` | `Admin/Manage/PurchaseController` | `Admin/Manage/PurchaseService` |
| 7 | Cutting | `admin/manage/cuttings` | `Admin/Manage/CuttingController` | `Admin/Manage/CuttingService` |
| 8 | Transaksi | `admin/manage/transactions` | `Admin/Manage/TransactionController` | `Admin/Manage/TransactionService` |
| 9 | Restock | `admin/manage/restocks` | `Admin/Manage/RestockController` | `Admin/Manage/RestockService` |
| 10 | Stok Opname | `admin/manage/stok-opnames` | (via StockMutationController) | — |
| 11 | Kas Toko | `admin/finance/cash-accounts` | `Admin/Finance/CashAccountController` | `Admin/Finance/Cash/CashAccountService`, `Admin/Finance/Cash/CashTransactionService` |
| 12 | Pengeluaran | `admin/finance/expenses` | `Admin/Finance/ExpenseController` | `Admin/Finance/ExpenseService` |
| 13 | Kasbon | `admin/finance/employee-advances` | `Admin/Finance/EmployeeAdvanceController` | `Admin/Finance/EmployeeAdvanceService` |
| 14 | Gaji | `admin/finance/payroll-periods` | `Admin/Finance/PayrollController` | `Admin/Finance/Payroll/PayrollPeriodService`, `Admin/Finance/Payroll/PayrollAdjustmentService` |
| 15 | Pegawai | `admin/hr/employees` | `Admin/HR/EmployeeController` | `Admin/HR/EmployeeService` |
| 16 | Presensi | `admin/hr/attendances` | `Admin/HR/AttendanceController` | `Admin/HR/AttendanceService` |
| 17 | Cuti | `admin/hr/leave-requests` | `Admin/HR/LeaveRequestController` | `Admin/HR/LeaveRequestService` |
| 18 | Role & Permission | `admin/settings/roles` | `Admin/RoleController` | `Admin/Settings/RoleService` |
| 19 | Pengaturan | `admin/settings` | `Admin/AdminSettingsController` | `Admin/AdminSettingsService` |
## Statistics
| Metric | Count |
|--------|-------|
| Tables | 48 |
| Models | 43 |
| Enums | 21 |
| Services | 30 |
| Controllers | 32 |
| Roles | 9 |
| Permissions | ~120 |

1712
.ai/CONVENTIONS.md Normal file

File diff suppressed because it is too large Load Diff

400
.ai/REFERENCE.md Normal file
View File

@ -0,0 +1,400 @@
# Database Reference
> Format: `table_name``model_name` | columns | relationships
---
## Auth & User
### `users` → User
`id` `email`(unique) `username`(unique) `password` `is_active`(bool) `last_login_at`(datetime) `created_at` `updated_at` `deleted_at`
- Casts: email_verified_at(datetime), is_active(bool), last_login_at(datetime), password(hashed), two_factor_confirmed_at(datetime)
- Scopes: active()
- Relations: userProfile(HasOne→UserProfile), employee(HasOne→Employee), attendances(HasMany→Attendance), cashAccounts(HasMany→CashAccount,created_by_id), cashTransactions(HasMany→CashTransaction,created_by_id), createdCuttings(HasMany→Cutting,created_by_id), submittedCuttings(HasMany→Cutting,submitted_by_id), createdExpenses(HasMany→Expense,created_by_id), createdOrders(HasMany→Order,created_by_id), marketingOrders(HasMany→Order,marketing_id), orderItems(HasMany→OrderItem), createdPurchases(HasMany→Purchase,created_by_id), createdRestocks(HasMany→Restock,created_by_id), stokOpnamesCreated(HasMany→StokOpname,created_by_id), stokOpnamesVerified(HasMany→StokOpname,verified_by_id), employeeAdvancesPaid(HasMany→EmployeeAdvance,paid_by_id), employeeAdvancesVerified(HasMany→EmployeeAdvance,verified_by_id), paidPayrolls(HasMany→Payroll,paid_by_id), payrollPeriodsClosed(HasMany→PayrollPeriod,closed_by_id), notifications(HasMany→AppNotification), rejections(HasMany→Rejection,rejected_by_id), pushSubscriptions(HasMany→PushSubscription,morph)
### `user_profiles` → UserProfile
`id` `user_id`(FK→users,unique) `full_name`(200) `phone_number`(20,null) `gender`(enum,null) `birth_date`(date,null) `address`(text,null) `created_at` `updated_at` `deleted_at`
- Casts: gender(Gender), birth_date(date:Y-m-d)
- Scopes: female(), male(), hasPhoneNumber()
- Relations: user(BelongsTo→User)
### `push_subscriptions` → PushSubscription
`id` `subscribable_type` `subscribable_id` `endpoint`(500,unique) `public_key`(null) `auth_token`(null) `content_encoding`(null) `created_at` `updated_at`
- Relations: user(MorphTo)
### `notifications` → AppNotification
`id` `user_id`(FK→users) `title` `body`(text,null) `url`(null) `is_read`(bool,default:false) `read_at`(datetime,null) `created_at` `updated_at`
- Casts: is_read(bool), read_at(datetime)
- Accessor: formatted_read_at → 'l, d F Y H:i'
- Relations: user(BelongsTo→User)
---
## Master Data
### `categories` → Category
`id` `name`(50) `slug`(50,unique) `created_at` `updated_at` `deleted_at`
- Relations: products(BelongsToMany→Product via product_categories)
- Accessor: formattedName → ucfirst(name)
### `product_categories` → Category (Pivot)
`product_id`(FK→products) `category_id`(FK→categories)
- No id, no timestamps
- Relations: category(BelongsTo→Category), product(BelongsTo→Product)
### `products` → Product
`id` `name`(200) `slug`(200,unique) `description`(text,null) `status`(enum,default:active) `rejection_reason`(text,null) `created_at` `updated_at` `deleted_at`
- Casts: status(ProductStatus)
- Scopes: active(), draft(), inactive(), pending(), rejected()
- Relations: categories(BelongsToMany→Category), productVariants(HasMany→ProductVariant)
### `product_variants` → ProductVariant
`id` `product_id`(FK→products) `name`(200) `stock`(uint,default:0) `reject_stock`(uint,default:0) `retail_stock`(uint,default:0) `created_at` `updated_at` `deleted_at`
- Casts: stock(int), reject_stock(int), retail_stock(int)
- GlobalScope: orderBy(name)
- Relations: product(BelongsTo→Product), productPrices(HasMany→ProductPrice,variant_id), orderItems(HasMany→OrderItem), restockItems(HasMany→RestockItem), retailStockHistories(HasMany→RetailStockHistory), stokOpnameItems(HasMany→StokOpnameItem), stockMutations(HasMany→StockMutation,morph)
### `product_prices` → ProductPrice
`id` `variant_id`(FK→product_variants) `type`(enum) `price`(uint) `created_at` `updated_at`
- UNIQUE(variant_id, type)
- Casts: type(PriceType), price(int)
- Scopes: retail(), wholesale()
- Relations: variant(BelongsTo→ProductVariant)
### `customers` → Customer
`id` `name`(200) `phone_number`(20,null) `address`(text,null) `created_at` `updated_at` `deleted_at`
- Relations: orders(HasMany→Order)
- Accessor: formattedPhoneNumber (get: "0821 2121 2121", set: strip whitespace)
### `suppliers` → Supplier
`id` `name`(200) `phone_number`(20,null) `address`(text,null) `created_at` `updated_at` `deleted_at`
- Relations: purchases(HasMany→Purchase)
- Accessor: formattedPhoneNumber (get: "0821 2121 2121", set: strip whitespace)
### `raw_materials` → RawMaterial
`id` `name`(200) `unit`(enum) `is_active`(bool,default:true) `created_at` `updated_at` `deleted_at`
- Casts: unit(RawMaterialUnit), is_active(bool)
- Scopes: active(), nonactive(), kg(), meter(), yard()
- Relations: rawMaterialPrices(HasMany→RawMaterialPrice)
### `raw_material_prices` → RawMaterialPrice
`id` `raw_material_id`(FK→raw_materials) `variant`(200) `price`(uint) `stock`(uint,default:0) `created_at` `updated_at` `deleted_at`
- Casts: price(int), stock(int)
- GlobalScope: orderBy(variant)
- Relations: rawMaterial(BelongsTo→RawMaterial,withTrashed), cuttingMaterials(HasMany→CuttingMaterial), purchaseItems(HasMany→PurchaseItem)
- Accessor: photo_url → first media presigned S3 URL
---
## Finance
### `cash_accounts` → CashAccount
`id` `created_by_id`(FK→users) `name`(200) `balance`(ubig,default:0) `created_at` `updated_at` `deleted_at`
- Casts: balance(int)
- Relations: cashTransactions(HasMany→CashTransaction), createdBy(BelongsTo→User)
### `cash_transactions` → CashTransaction
`id` `cash_account_id`(FK→cash_accounts) `created_by_id`(FK→users) `reference_id`(ubig,null,morph) `reference_type`(string,null,morph) `amount`(ubig) `balance_after`(ubig) `type`(enum,default:deposit) `description`(100) `created_at` `updated_at` `deleted_at`
- Casts: type(CashTransactionType), amount(int), balance_after(int)
- Scopes: deposit(), expenseType(), transfer(), withdrawal()
- Relations: cashAccount(BelongsTo→CashAccount), createdBy(BelongsTo→User), reference(MorphTo), expense(HasOne→Expense), order(HasOne→Order), employeeAdvances(HasMany→EmployeeAdvance), payrolls(HasMany→Payroll)
### `expenses` → Expense
`id` `cash_transaction_id`(FK→cash_transactions,unique,null) `created_by_id`(FK→users) `amount`(ubig) `description`(100) `created_at` `updated_at` `deleted_at`
- Casts: amount(int), date(date:Y-m-d)
- Relations: cashTransaction(BelongsTo→CashTransaction), createdBy(BelongsTo→User)
### `employee_advances` → EmployeeAdvance
`id` `paid_by_id`(FK→users,null) `employee_id`(FK→employees) `verified_by_id`(FK→users,null) `repayment_cash_transaction_id`(FK→cash_transactions,unique,null) `cash_transaction_id`(FK→cash_transactions,unique,null) `amount`(ubig) `paid_amount`(ubig,default:0) `description`(100) `due_date`(date) `status`(enum,default:pending) `verified_at`(datetime,null) `paid_at`(datetime,null) `created_at` `updated_at` `deleted_at`
- Casts: status(EmployeeAdvanceStatus), amount(int), paid_amount(int), due_date(date:Y-m-d), verified_at(datetime), paid_at(datetime)
- Scopes: approved(), cancelled(), paid(), pending(), rejected()
- Relations: employee(BelongsTo→Employee), cashTransaction(BelongsTo→CashTransaction), repaymentCashTransaction(BelongsTo→CashTransaction), paidBy(BelongsTo→User), verifiedBy(BelongsTo→User), payments(HasMany→EmployeeAdvancePayment)
### `employee_advance_payments` → EmployeeAdvancePayment
`id` `employee_advance_id`(FK→employee_advances) `paid_by_id`(FK→users,null) `cash_transaction_id`(FK→cash_transactions,unique,null) `amount`(ubig) `description`(100,null) `paid_at`(datetime) `created_at` `updated_at`
- No soft deletes
- Casts: amount(int), paid_at(datetime)
- Accessor: formatted_amount → 'Rp X.XXX', formatted_paid_at → 'l, d F Y H:i'
- Relations: employeeAdvance(BelongsTo→EmployeeAdvance), paidBy(BelongsTo→User), cashTransaction(BelongsTo→CashTransaction)
---
## HR
### `employees` → Employee
`id` `user_id`(FK→users,unique) `join_date`(date) `resign_date`(date,null) `employment_status`(enum,default:full_time) `base_salary`(uint) `created_at` `updated_at` `deleted_at`
- Casts: employment_status(EmploymentStatus), base_salary(int), join_date(date:Y-m-d), resign_date(date:Y-m-d)
- Scopes: contract(), fullTime(), internship(), partTime(), resigned()
- Relations: user(BelongsTo→User), attendances(HasMany→Attendance), employeeAdvances(HasMany→EmployeeAdvance), leaveRequests(HasMany→LeaveRequest), payrolls(HasMany→Payroll)
### `attendances` → Attendance
`id` `employee_id`(FK→employees) `attendance_date`(date) `check_in_at`(datetime) `check_out_at`(datetime,null) `check_in_latitude`(decimal(10,7),null) `check_in_longitude`(decimal(10,7),null) `check_out_latitude`(decimal(10,7),null) `check_out_longitude`(decimal(10,7),null) `work_duration_minutes`(uint,null) `created_at` `updated_at`
- No soft deletes
- Casts: attendance_date(date:Y-m-d), check_in_at(datetime), check_out_at(datetime), check_in/out_latitude/longitude(decimal:7), work_duration_minutes(int)
- Accessor: formatted_attendance_date → 'l, d F Y', formatted_check_in_at → 'l, d F Y H:i', formatted_check_out_at → 'l, d F Y H:i'
- Relations: employee(BelongsTo→Employee), payrollAdjustments(HasMany→PayrollAdjustment)
### `payroll_periods` → PayrollPeriod
`id` `closed_by_id`(FK→users,null) `year`(usmall) `month`(utiny) `status`(enum,default:open) `closed_at`(datetime,null) `created_at` `updated_at` `deleted_at`
- Casts: status(PayrollPeriodStatus), year(int), month(int), closed_at(datetime)
- Accessor: formatted_closed_at → 'l, d F Y H:i', month_name → 'Januari', status_label
- Scopes: closed(), open()
- Relations: closedBy(BelongsTo→User), payrolls(HasMany→Payroll)
### `payrolls` → Payroll
`id` `payroll_period_id`(FK→payroll_periods) `employee_id`(FK→employees) `cash_transaction_id`(FK→cash_transactions,unique,null) `paid_by_id`(FK→users,null) `base_salary`(uint) `bonus_amount`(ubig,default:0) `deduction_amount`(ubig,default:0) `total_amount`(ubig) `status`(enum,default:unpaid) `paid_at`(datetime,null) `created_at` `updated_at` `deleted_at`
- Casts: status(PayrollStatus), base_salary(int), bonus_amount(int), deduction_amount(int), total_amount(int), paid_at(datetime)
- Scopes: cancelled(), paid(), unpaid()
- Relations: payrollPeriod(BelongsTo→PayrollPeriod), employee(BelongsTo→Employee), cashTransaction(BelongsTo→CashTransaction), paidBy(BelongsTo→User), payrollAdjustments(HasMany→PayrollAdjustment)
### `payroll_adjustments` → PayrollAdjustment
`id` `payroll_id`(FK→payrolls) `attendance_id`(FK→attendances,null) `created_by_id`(FK→users) `type`(enum) `amount`(ubig) `description`(100) `created_at` `updated_at` `deleted_at`
- Casts: type(PayrollAdjustmentType), amount(int)
- Scopes: bonus(), deduction()
- Relations: payroll(BelongsTo→Payroll), attendance(BelongsTo→Attendance), createdBy(BelongsTo→User)
### `leave_requests` → LeaveRequest
`id` `employee_id`(FK→employees) `verified_by_id`(FK→users,null) `start_date`(date) `end_date`(date) `total_days`(uint) `status`(enum,default:pending) `verified_at`(datetime,null) `created_at` `updated_at` `deleted_at`
- Casts: status(LeaveRequestStatus), start_date(date:Y-m-d), end_date(date:Y-m-d), total_days(int), verified_at(datetime)
- Scopes: approved(), cancelled(), pending(), rejected()
- Relations: employee(BelongsTo→Employee), verifiedBy(BelongsTo→User)
---
## Sales
### `orders` → Order
`id` `customer_id`(FK→customers,null) `marketing_id`(FK→users,null) `cash_transaction_id`(FK→cash_transactions,unique,null) `created_by_id`(FK→users) `order_number`(30,unique) `channel`(enum) `price_type`(enum) `status`(enum,default:pending) `payment_type`(enum,default:cash) `is_affiliate`(bool,default:false) `tiktok_order_id`(100,null) `shopee_order_id`(100,null) `subtotal`(ubig) `discount`(ubig,default:0) `nego_price`(ubig,null) `marketplace_settings_snapshot`(json,null) `total_amount`(ubig) `cogs`(ubig,default:0) `notes`(text,null) `created_at` `updated_at` `deleted_at`
- Casts: channel(OrderChannel), price_type(PriceType), status(OrderStatus), payment_type(PaymentType), is_affiliate(bool), subtotal(int), discount(int), nego_price(int), total_amount(int), cogs(int), marketplace_settings_snapshot(array)
- Scopes: cancelled(), cash(), completed(), pending(), processing(), qris(), refunded(), retail(), shopee(), store(), tiktok(), transfer(), wholesale()
- Relations: cashTransaction(BelongsTo→CashTransaction), createdBy(BelongsTo→User), customer(BelongsTo→Customer), marketing(BelongsTo→User), orderItems(HasMany→OrderItem)
### `order_items` → OrderItem
`id` `order_id`(FK→orders,null) `user_id`(FK→users,null) `product_variant_id`(FK→product_variants) `stock_quality`(enum,default:good) `quantity`(uint) `unit_price`(ubig) `subtotal`(ubig) `created_at` `updated_at` `deleted_at`
- Casts: stock_quality(ProductStockQuality), quantity(int), unit_price(int), subtotal(int)
- Scopes: good(), reject()
- Relations: order(BelongsTo→Order), productVariant(BelongsTo→ProductVariant), user(BelongsTo→User)
### `rejections` → Rejection
`id` `rejectable_type`(string,null,morph) `rejectable_id`(ubig,null,morph) `rejected_by_id`(FK→users) `reason`(500) `created_at` `updated_at` `deleted_at`
- Relations: rejectable(MorphTo), rejectedBy(BelongsTo→User)
---
## Production
### `cuttings` → Cutting
`id` `created_by_id`(FK→users) `submitted_by_id`(FK→users,null) `status`(enum,default:in_progress) `description`(100,null) `total_material_cost`(ubig,null) `sewing_cost`(ubig,default:0) `other_cost`(ubig,default:0) `cost_per_unit`(ubig,null) `created_at` `updated_at` `deleted_at`
- Casts: status(CuttingStatus), total_material_cost(int), cost_per_unit(int), sewing_cost(int), other_cost(int)
- Scopes: cancelled(), completed(), inProgress()
- Relations: createdBy(BelongsTo→User), submittedBy(BelongsTo→User), cuttingMaterialCombinations(HasMany→CuttingMaterialCombination), cuttingMaterials(HasMany→CuttingMaterial), cuttingResults(HasMany→CuttingResult)
### `cutting_material_combinations` → CuttingMaterialCombination
`id` `user_id`(FK→users,null) `cutting_id`(FK→cuttings,null) `material_result`(int,null) `created_at` `updated_at` `deleted_at`
- Casts: material_result(int)
- Accessor: formatted_material_result → 'X.XXX'
- Relations: cutting(BelongsTo→Cutting), cuttingMaterials(HasMany→CuttingMaterial,combination_id), user(BelongsTo→User)
### `cutting_materials` → CuttingMaterial
`id` `user_id`(FK→users,null) `cutting_id`(FK→cuttings,null) `raw_material_price_id`(FK→raw_material_prices) `combination_id`(FK→cutting_material_combinations,null) `material_usage`(int) `material_result`(int,null) `created_at` `updated_at` `deleted_at`
- Casts: material_usage(int), material_result(int)
- Accessor: formatted_material_result → 'X.XXX', formatted_material_usage → 'X.XXX'
- Relations: cutting(BelongsTo→Cutting), rawMaterialPrice(BelongsTo→RawMaterialPrice), combination(BelongsTo→CuttingMaterialCombination), user(BelongsTo→User)
### `cutting_results` → CuttingResult
`id` `user_id`(FK→users,null) `cutting_id`(FK→cuttings,null) `product_name`(255,null) `cutting_result`(uint,null) `sample`(uint,null) `original_outside_sample`(uint,null) `created_at` `updated_at` `deleted_at`
- Casts: cutting_result(int), sample(int), original_outside_sample(int)
- Accessor: formatted_cutting_result → 'X.XXX', formatted_original_outside_sample → 'X.XXX', formatted_sample → 'X.XXX'
- Relations: cutting(BelongsTo→Cutting), user(BelongsTo→User)
---
## Inventory
### `purchases` → Purchase
`id` `supplier_id`(FK→suppliers) `created_by_id`(FK→users) `subtotal`(ubig) `discount`(ubig,default:0) `shipping_cost`(ubig,default:0) `total`(ubig) `notes`(100,null) `created_at` `updated_at` `deleted_at`
- Casts: subtotal(int), discount(int), shipping_cost(int), total(int)
- Relations: createdBy(BelongsTo→User), supplier(BelongsTo→Supplier), purchaseItems(HasMany→PurchaseItem)
### `purchase_items` → PurchaseItem
`id` `purchase_id`(FK→purchases,null) `user_id`(FK→users,null) `raw_material_price_id`(FK→raw_material_prices) `quantity`(uint) `unit_price`(ubig) `subtotal`(ubig) `created_at` `updated_at` `deleted_at`
- Casts: quantity(int), unit_price(int), subtotal(int)
- Relations: purchase(BelongsTo→Purchase), rawMaterialPrice(BelongsTo→RawMaterialPrice,withTrashed), user(BelongsTo→User)
### `restocks` → Restock
`id` `created_by_id`(FK→users) `subtotal`(ubig) `total`(ubig) `notes`(100,null) `stock_type`(enum,default:good) `created_at` `updated_at` `deleted_at`
- Casts: stock_type(ProductStockQuality), subtotal(int), total(int)
- Scopes: good(), reject()
- Relations: createdBy(BelongsTo→User), restockItems(HasMany→RestockItem)
### `restock_items` → RestockItem
`id` `restock_id`(FK→restocks,null) `user_id`(FK→users,null) `product_variant_id`(FK→product_variants) `quantity`(int) `unit_price`(ubig) `subtotal`(ubig) `created_at` `updated_at` `deleted_at`
- Casts: quantity(int), unit_price(int), subtotal(int)
- Relations: restock(BelongsTo→Restock), productVariant(BelongsTo→ProductVariant), user(BelongsTo→User)
### `stok_opnames` → StokOpname
`id` `created_by_id`(FK→users) `verified_by_id`(FK→users,null) `opname_date`(date) `status`(enum,default:draft) `notes`(text,null) `verification_notes`(text,null) `created_at` `updated_at` `deleted_at`
- Casts: status(StokOpnameStatus), opname_date(date:Y-m-d)
- Scopes: cancelled(), completed(), draft(), inProgress(), verified()
- Relations: createdBy(BelongsTo→User), verifiedBy(BelongsTo→User), stokOpnameItems(HasMany→StokOpnameItem)
### `stok_opname_items` → StokOpnameItem
`id` `stok_opname_id`(FK→stok_opnames) `product_variant_id`(FK→product_variants) `stock_quality`(enum,default:good) `system_stock`(uint,default:0) `physical_stock`(uint,default:0) `difference`(int,default:0) `notes`(text,null) `created_at` `updated_at`
- No soft deletes
- Casts: stock_quality(ProductStockQuality), system_stock(int), physical_stock(int), difference(int)
- Scopes: good(), reject()
- Relations: stokOpname(BelongsTo→StokOpname), productVariant(BelongsTo→ProductVariant)
### `retail_stock_histories` → RetailStockHistory
`id` `product_variant_id`(FK→product_variants) `user_id`(FK→users) `quantity`(uint) `stock_before`(uint) `retail_stock_before`(uint) `stock_after`(uint) `retail_stock_after`(uint) `notes`(255,null) `created_at`
- No soft deletes, no updated_at
- $timestamps = false
- Casts: quantity(int), stock_before(int), retail_stock_before(int), stock_after(int), retail_stock_after(int)
- Accessor: formatted_quantity → 'X.XXX', formatted_retail_stock_before → 'X.XXX', formatted_retail_stock_after → 'X.XXX', formatted_stock_before → 'X.XXX', formatted_stock_after → 'X.XXX'
- Relations: productVariant(BelongsTo→ProductVariant), user(BelongsTo→User)
### `stock_mutations` → StockMutation
`id` `user_id`(FK→users) `stockable_type`(string) `stockable_id`(ubig) `type`(string) `source_type`(string,null) `source_id`(ubig,null) `quantity`(int) `stock_before`(int) `stock_after`(int) `stock_quality`(string,null) `description`(string,null) `created_at` `updated_at`
- No soft deletes
- Casts: quantity(int), stock_before(int), stock_after(int)
- Accessor: formatted_quantity → 'X.XXX', formatted_stock_after → 'X.XXX', formatted_stock_before → 'X.XXX'
- Relations: stockable(MorphTo), source(MorphTo), user(BelongsTo→User)
---
## Settings
### `system_configurations` → SystemConfiguration
`id` `created_at` `updated_at`
- Singleton table
### `homepage_configurations` → HomepageConfiguration
`id` `created_at` `updated_at`
- Singleton table
### `settings` (laravel-settings)
`id` `group`(string) `name`(string) `locked`(bool,default:false) `payload`(json) `created_at` `updated_at`
- UNIQUE(group, name)
---
## Enums
| Enum | Values | Used In |
|------|--------|---------|
| `CashTransactionType` | deposit, expense, transfer, withdrawal | cash_transactions.type |
| `CuttingStatus` | in_progress, completed, cancelled | cuttings.status |
| `EmployeeAdvanceStatus` | pending, approved, rejected, paid, cancelled | employee_advances.status |
| `EmploymentStatus` | full_time, part_time, contract, internship, resigned | employees.employment_status |
| `Gender` | male, female | user_profiles.gender |
| `LeaveRequestStatus` | pending, approved, rejected, cancelled | leave_requests.status |
| `Modules` | — | Permission module names |
| `OrderChannel` | store, shopee, tiktok | orders.channel |
| `OrderStatus` | pending, processing, completed, cancelled, refunded | orders.status |
| `PaymentType` | cash, transfer, qris | orders.payment_type |
| `PayrollAdjustmentType` | bonus, deduction | payroll_adjustments.type |
| `PayrollPeriodStatus` | open, closed | payroll_periods.status |
| `PayrollStatus` | unpaid, paid, cancelled | payrolls.status |
| `Permission` | — | Permission action names |
| `PriceType` | retail, wholesale, capital | product_prices.type, orders.price_type |
| `ProductStatus` | active, draft, inactive, pending, rejected | products.status |
| `ProductStockQuality` | good, reject | order_items.stock_quality, restocks.stock_type, stok_opname_items.stock_quality |
| `RawMaterialUnit` | kg, meter, yard | raw_materials.unit |
| `Role` | — | Role names |
| `StokOpnameStatus` | draft, in_progress, completed, verified, cancelled | stok_opnames.status |
## Service Concerns
| Trait | Methods | Used By |
|-------|---------|---------|
| `HandlesCashTransactions` | getCashAccount(), creditCash(), debitCash() | CashAccountService, ExpenseService, EmployeeAdvanceService, PayrollPeriodService |
| `HasStockAdjustment` | adjustStock(), adjustVariantStock(), applyStock(), reverseStock() | TransactionService, RestockService |
| `RegistersMedia` | registerMedia(), syncPhoto() | CashAccountService, CuttingService, ExpenseService, PurchaseService, RestockService, TransactionService |
---
## Form Request Validation Rules
> Validasi harus SESUAI dengan DB schema (type + max length).
### Master
| Request | Field | Rules | DB Match |
|---------|-------|-------|----------|
| `CategoryRequest` | `name` | `required, string, max:50, unique:categories,name` | ✅ varchar(50) |
| `CustomerRequest` | `name` | `required, string, max:200, unique:customers,name` | ✅ varchar(200) |
| `CustomerRequest` | `phone_number` | `nullable, string, max:20` | ✅ varchar(20) |
| `CustomerRequest` | `address` | `nullable, string` | ✅ text |
| `SupplierRequest` | `name` | `required, string, max:200, unique:suppliers,name` | ✅ varchar(200) |
| `SupplierRequest` | `phone_number` | `nullable, string, max:20` | ✅ varchar(20) |
| `RawMaterialRequest` | `name` | `required, string, max:200` | ✅ varchar(200) |
| `RawMaterialRequest` | `unit` | `required, in:kg,meter,yard` | ✅ enum |
| `RawMaterialVariantRequest` | `variant` | `required, string, max:200` | ✅ varchar(200) |
| `RawMaterialVariantRequest` | `price` | `required, integer, min:0` | ✅ uint |
| `ProductRequest` | `name` | `required, string, max:200` | ✅ varchar(200) |
| `ProductVariantRequest` | `name` | `required, string, max:200` | ✅ varchar(200) |
### Finance
| Request | Field | Rules | DB Match |
|---------|-------|-------|----------|
| `CashAccountRequest` | `name` | `required, string, max:200, unique:cash_accounts,name` | ✅ varchar(200) |
| `CashTransactionRequest` | `amount` | `required, integer, min:1` | ✅ ubig |
| `CashTransactionRequest` | `description` | `required, string, max:100` | ✅ varchar(100) |
| `ExpenseRequest` | `amount` | `required, integer, min:1` | ✅ ubig |
| `ExpenseRequest` | `description` | `required, string, max:100` | ✅ varchar(100) |
| `EmployeeAdvanceRequest` | `amount` | `required, integer, min:1` | ✅ ubig |
| `EmployeeAdvanceRequest` | `description` | `required, string, max:100` | ✅ varchar(100) |
| `EmployeeAdvanceRequest` | `due_date` | `required, date, after_or_equal:today` | ✅ date |
| `EmployeeAdvancePaymentRequest` | `amount` | `required, integer, min:1` | ✅ ubig |
| `EmployeeAdvancePaymentRequest` | `description` | `nullable, string, max:100` | ✅ varchar(100) nullable |
| `PayrollAdjustmentRequest` | `type` | `required, in:bonus,deduction` | ✅ enum |
| `PayrollAdjustmentRequest` | `amount` | `required, integer, min:1` | ✅ ubig |
| `PayrollAdjustmentRequest` | `description` | `required, string, max:100` | ✅ varchar(100) |
### HR
| Request | Field | Rules | DB Match |
|---------|-------|-------|----------|
| `EmployeeRequest` | `email` | `required, email, max:100, unique:users,email` | ✅ varchar(100) |
| `EmployeeRequest` | `username` | `required, string, max:20, alpha_dash, unique:users,username` | ✅ varchar(20) |
| `EmployeeRequest` | `full_name` | `required, string, max:200` | ✅ varchar(200) |
| `EmployeeRequest` | `phone_number` | `nullable, string, max:20` | ✅ varchar(20) |
| `EmployeeRequest` | `gender` | `nullable, in:male,female` | ✅ enum |
| `EmployeeRequest` | `base_salary` | `required, integer, min:0` | ✅ uint |
| `AttendanceRequest` | `latitude` | `required, numeric, min:-90, max:90` | ✅ decimal(10,7) |
| `AttendanceRequest` | `longitude` | `required, numeric, min:-180, max:180` | ✅ decimal(10,7) |
| `LeaveRequestRequest` | `start_date` | `required, date, after:today` | ✅ date |
| `LeaveRequestRequest` | `end_date` | `required, date, after_or_equal:start_date` | ✅ date |
### Sales
| Request | Field | Rules | DB Match |
|---------|-------|-------|----------|
| `TransactionRequest` | `channel` | `required, in:store,shopee,tiktok` | ✅ enum |
| `TransactionRequest` | `price_type` | `required, in:retail,wholesale` | ✅ enum |
| `TransactionRequest` | `payment_type` | `required, in:cash,transfer,qris` | ✅ enum |
| `TransactionRequest` | `tiktok_order_id` | `nullable, string, max:100` | ✅ varchar(100) |
| `TransactionRequest` | `shopee_order_id` | `nullable, string, max:100` | ✅ varchar(100) |
| `TransactionRequest` | `notes` | `nullable, string, max:100` | ✅ text (UI limit) |
### Production
| Request | Field | Rules | DB Match |
|---------|-------|-------|----------|
| `CuttingRequest` | `description` | `nullable, string, max:100` | ✅ varchar(100) |
| `CuttingRequest` | `product_name` | `required, string, max:255` | ✅ varchar(255) |
| `CuttingRequest` | `cutting_result` | `required, integer, min:1` | ✅ uint |
| `CuttingRequest` | `sample` | `required, integer` | ✅ uint |
| `CuttingRequest` | `original_outside_sample` | `required, integer` | ✅ uint |
### Inventory
| Request | Field | Rules | DB Match |
|---------|-------|-------|----------|
| `PurchaseRequest` | `supplier_id` | `required, integer, exists:suppliers,id` | ✅ FK |
| `PurchaseRequest` | `notes` | `nullable, string, max:100` | ✅ varchar(100) |
| `RestockRequest` | `stock_type` | `required, in:good,reject` | ✅ enum |
| `RestockRequest` | `notes` | `nullable, string, max:100` | ✅ varchar(100) |

1165
AGENTS.md

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
<?php
namespace App\Concerns;
use App\Enums\Role;
trait HasRoleChecks
{
public static function hasAnyRole(array $roles): bool
{
return auth()->user()->hasAnyRole(
array_map(fn (Role $role) => $role->value, $roles)
);
}
}

View File

@ -121,11 +121,6 @@ enum Permission: string
case PAYROLL_CANCEL = 'payroll.cancel'; case PAYROLL_CANCEL = 'payroll.cancel';
case PAYROLL_ADJUST = 'payroll.adjust'; case PAYROLL_ADJUST = 'payroll.adjust';
// Owner Verifications
case OWNER_VERIFICATIONS_VIEW = 'owner_verifications.view';
case OWNER_VERIFICATIONS_VERIFY = 'owner_verifications.verify';
case OWNER_VERIFICATIONS_REJECT = 'owner_verifications.reject';
// Restocks // Restocks
case RESTOCKS_VIEW = 'restocks.view'; case RESTOCKS_VIEW = 'restocks.view';
case RESTOCKS_CREATE = 'restocks.create'; case RESTOCKS_CREATE = 'restocks.create';

View File

@ -11,6 +11,8 @@ enum ProductStatus: string
case ACTIVE = 'active'; case ACTIVE = 'active';
case INACTIVE = 'inactive'; case INACTIVE = 'inactive';
case DRAFT = 'draft'; case DRAFT = 'draft';
case PENDING = 'pending';
case REJECTED = 'rejected';
public function label(): string public function label(): string
{ {
@ -18,6 +20,8 @@ public function label(): string
self::ACTIVE => 'Aktif', self::ACTIVE => 'Aktif',
self::INACTIVE => 'Non Aktif', self::INACTIVE => 'Non Aktif',
self::DRAFT => 'Draft', self::DRAFT => 'Draft',
self::PENDING => 'Menunggu Verifikasi',
self::REJECTED => 'Ditolak',
}; };
} }
} }

View File

@ -10,12 +10,14 @@ enum ProductStockQuality: string
case GOOD = 'good'; case GOOD = 'good';
case REJECT = 'reject'; case REJECT = 'reject';
case RETAIL = 'retail';
public function label(): string public function label(): string
{ {
return match ($this) { return match ($this) {
self::GOOD => 'Bagus', self::GOOD => 'Bagus',
self::REJECT => 'Reject', self::REJECT => 'Reject',
self::RETAIL => 'Ecer',
}; };
} }
} }

View File

@ -2,12 +2,8 @@
namespace App\Enums; namespace App\Enums;
use App\Traits\ProvidesEnumOptions;
enum Role: string enum Role: string
{ {
use ProvidesEnumOptions;
case DEVELOPER = 'developer'; case DEVELOPER = 'developer';
case OWNER = 'owner'; case OWNER = 'owner';
case ADMIN_TOKO = 'admin-toko'; case ADMIN_TOKO = 'admin-toko';
@ -95,7 +91,6 @@ public function permissions(): array
Permission::CUSTOMERS_VIEW, Permission::CUSTOMERS_VIEW,
Permission::PRODUCTS_VIEW, Permission::PRODUCTS_VIEW,
Permission::STOCKS_VIEW,
Permission::ORDERS_VIEW, Permission::ORDERS_VIEW,
@ -139,8 +134,6 @@ public function permissions(): array
Permission::ANALYSIS_TOP_PRODUCTS, Permission::ANALYSIS_TOP_PRODUCTS,
Permission::ANALYSIS_MARKETING_SALES, Permission::ANALYSIS_MARKETING_SALES,
Permission::STOCKS_VIEW,
Permission::STOK_OPNAMES_VIEW, Permission::STOK_OPNAMES_VIEW,
Permission::ATTENDANCES_VIEW, Permission::ATTENDANCES_VIEW,
@ -167,9 +160,6 @@ public function permissions(): array
Permission::PRODUCTS_UPDATE, Permission::PRODUCTS_UPDATE,
Permission::PRODUCTS_DELETE, Permission::PRODUCTS_DELETE,
Permission::PRODUCTS_TOGGLE_STATUS, Permission::PRODUCTS_TOGGLE_STATUS,
Permission::STOCKS_VIEW,
Permission::OWNER_VERIFICATIONS_VIEW,
Permission::ORDERS_VIEW, Permission::ORDERS_VIEW,
Permission::ORDERS_CREATE, Permission::ORDERS_CREATE,
@ -295,9 +285,6 @@ public function permissions(): array
Permission::RAW_MATERIALS_CREATE, Permission::RAW_MATERIALS_CREATE,
Permission::RAW_MATERIALS_UPDATE, Permission::RAW_MATERIALS_UPDATE,
Permission::RAW_MATERIALS_TOGGLE_STATUS, Permission::RAW_MATERIALS_TOGGLE_STATUS,
Permission::STOCKS_VIEW,
Permission::OWNER_VERIFICATIONS_VIEW,
Permission::PRODUCTS_VIEW, Permission::PRODUCTS_VIEW,
Permission::PRODUCTS_CREATE, Permission::PRODUCTS_CREATE,
@ -397,7 +384,6 @@ public function permissions(): array
Permission::LEAVE_REQUESTS_DELETE, Permission::LEAVE_REQUESTS_DELETE,
Permission::PRODUCTS_VIEW, Permission::PRODUCTS_VIEW,
Permission::STOCKS_VIEW,
Permission::STOK_OPNAMES_VIEW, Permission::STOK_OPNAMES_VIEW,
Permission::STOK_OPNAMES_CREATE, Permission::STOK_OPNAMES_CREATE,

View File

@ -36,7 +36,7 @@ public function updateSystem(UpdateSystemRequest $request): RedirectResponse
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan sistem berhasil diperbarui.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan sistem berhasil diperbarui.']);
return to_route('admin.settings.index'); return back();
} }
public function updateHomepage(UpdateHomepageRequest $request): RedirectResponse public function updateHomepage(UpdateHomepageRequest $request): RedirectResponse
@ -45,7 +45,7 @@ public function updateHomepage(UpdateHomepageRequest $request): RedirectResponse
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan homepage berhasil diperbarui.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan homepage berhasil diperbarui.']);
return to_route('admin.settings.index'); return back();
} }
public function updateSocialMedia(UpdateSocialMediaRequest $request): RedirectResponse public function updateSocialMedia(UpdateSocialMediaRequest $request): RedirectResponse
@ -54,7 +54,7 @@ public function updateSocialMedia(UpdateSocialMediaRequest $request): RedirectRe
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan media sosial berhasil diperbarui.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan media sosial berhasil diperbarui.']);
return to_route('admin.settings.index'); return back();
} }
public function updateMarketplace(UpdateMarketplaceRequest $request): RedirectResponse public function updateMarketplace(UpdateMarketplaceRequest $request): RedirectResponse
@ -63,7 +63,7 @@ public function updateMarketplace(UpdateMarketplaceRequest $request): RedirectRe
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan marketplace berhasil diperbarui.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan marketplace berhasil diperbarui.']);
return to_route('admin.settings.index'); return back();
} }
public function updateHR(UpdateHRRequest $request): RedirectResponse public function updateHR(UpdateHRRequest $request): RedirectResponse
@ -72,6 +72,6 @@ public function updateHR(UpdateHRRequest $request): RedirectResponse
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan HR berhasil diperbarui.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan HR berhasil diperbarui.']);
return to_route('admin.settings.index'); return back();
} }
} }

View File

@ -7,7 +7,8 @@
use App\Http\Requests\Admin\Finance\CashTransactionRequest; use App\Http\Requests\Admin\Finance\CashTransactionRequest;
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\CashTransaction; use App\Models\CashTransaction;
use App\Services\Admin\Finance\CashAccountService; use App\Services\Admin\Finance\Cash\CashAccountService;
use App\Services\Admin\Finance\Cash\CashTransactionService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -15,16 +16,15 @@
class CashAccountController extends Controller class CashAccountController extends Controller
{ {
public function __construct( public function __construct(
private CashAccountService $service private CashAccountService $cashAccountService,
private CashTransactionService $cashTransactionService,
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
{ {
$cashAccount = $this->service->get();
return Inertia::render('admin/finance/cash-account/index', [ return Inertia::render('admin/finance/cash-account/index', [
'cashAccount' => $cashAccount, 'cashAccount' => $this->cashAccountService->get(),
'transactions' => $this->service->paginatedTransactions( 'transactions' => $this->cashTransactionService->paginated(
...$request->validatedWithDefaults(), ...$request->validatedWithDefaults(),
filters: $request->only(['type']), filters: $request->only(['type']),
), ),
@ -38,7 +38,7 @@ public function index(PaginatedRequest $request): Response
public function deposit(CashTransactionRequest $request): RedirectResponse public function deposit(CashTransactionRequest $request): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->deposit($request->validated()), fn () => $this->cashTransactionService->deposit($request->validated()),
'Deposit berhasil ditambahkan.', 'Deposit berhasil ditambahkan.',
'admin.finance.cash-accounts.index' 'admin.finance.cash-accounts.index'
); );
@ -47,7 +47,7 @@ public function deposit(CashTransactionRequest $request): RedirectResponse
public function withdrawal(CashTransactionRequest $request): RedirectResponse public function withdrawal(CashTransactionRequest $request): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->withdrawal($request->validated()), fn () => $this->cashTransactionService->withdrawal($request->validated()),
'Withdrawal berhasil ditambahkan.', 'Withdrawal berhasil ditambahkan.',
'admin.finance.cash-accounts.index' 'admin.finance.cash-accounts.index'
); );
@ -56,7 +56,7 @@ public function withdrawal(CashTransactionRequest $request): RedirectResponse
public function update(CashTransactionRequest $request, CashTransaction $transaction): RedirectResponse public function update(CashTransactionRequest $request, CashTransaction $transaction): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->updateTransaction($transaction, $request->validated()), fn () => $this->cashTransactionService->update($transaction, $request->validated()),
'Transaksi berhasil diperbarui.', 'Transaksi berhasil diperbarui.',
'admin.finance.cash-accounts.index' 'admin.finance.cash-accounts.index'
); );
@ -65,7 +65,7 @@ public function update(CashTransactionRequest $request, CashTransaction $transac
public function destroy(CashTransaction $transaction): RedirectResponse public function destroy(CashTransaction $transaction): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->deleteTransaction($transaction), fn () => $this->cashTransactionService->destroy($transaction),
'Transaksi berhasil dihapus.', 'Transaksi berhasil dihapus.',
'admin.finance.cash-accounts.index' 'admin.finance.cash-accounts.index'
); );

View File

@ -36,7 +36,7 @@ public function index(PaginatedRequest $request): Response
public function store(EmployeeAdvanceRequest $request): RedirectResponse public function store(EmployeeAdvanceRequest $request): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->create($request->validated()), fn () => $this->service->store($request->validated()),
'Kasbon berhasil ditambahkan.', 'Kasbon berhasil ditambahkan.',
'admin.finance.employee-advances.index' 'admin.finance.employee-advances.index'
); );
@ -54,7 +54,7 @@ public function update(EmployeeAdvanceRequest $request, EmployeeAdvance $employe
public function destroy(EmployeeAdvance $employeeAdvance): RedirectResponse public function destroy(EmployeeAdvance $employeeAdvance): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->delete($employeeAdvance), fn () => $this->service->destroy($employeeAdvance),
'Kasbon berhasil dihapus.', 'Kasbon berhasil dihapus.',
'admin.finance.employee-advances.index' 'admin.finance.employee-advances.index'
); );

View File

@ -27,7 +27,7 @@ public function index(PaginatedRequest $request): Response
public function store(ExpenseRequest $request): RedirectResponse public function store(ExpenseRequest $request): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->create($request->validated()), fn () => $this->service->store($request->validated()),
'Pengeluaran berhasil ditambahkan.', 'Pengeluaran berhasil ditambahkan.',
'admin.finance.expenses.index' 'admin.finance.expenses.index'
); );
@ -45,7 +45,7 @@ public function update(ExpenseRequest $request, Expense $expense): RedirectRespo
public function destroy(Expense $expense): RedirectResponse public function destroy(Expense $expense): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->delete($expense), fn () => $this->service->destroy($expense),
'Pengeluaran berhasil dihapus.', 'Pengeluaran berhasil dihapus.',
'admin.finance.expenses.index' 'admin.finance.expenses.index'
); );

View File

@ -1,12 +1,12 @@
<?php <?php
namespace App\Http\Controllers\Admin\Finance; namespace App\Http\Controllers\Admin\Finance\Payroll;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finance\PayrollAdjustmentRequest; use App\Http\Requests\Admin\Finance\PayrollAdjustmentRequest;
use App\Models\Payroll; use App\Models\Payroll;
use App\Models\PayrollAdjustment; use App\Models\PayrollAdjustment;
use App\Services\Admin\Finance\PayrollAdjustmentService; use App\Services\Admin\Finance\Payroll\PayrollAdjustmentService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
class PayrollAdjustmentController extends Controller class PayrollAdjustmentController extends Controller
@ -18,7 +18,7 @@ public function __construct(
public function store(PayrollAdjustmentRequest $request, Payroll $payroll): RedirectResponse public function store(PayrollAdjustmentRequest $request, Payroll $payroll): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->create($payroll, $request->validated()), fn () => $this->service->store($payroll, $request->validated()),
'Adjustment gaji berhasil ditambahkan.', 'Adjustment gaji berhasil ditambahkan.',
'admin.finance.payroll-periods.show', 'admin.finance.payroll-periods.show',
parameters: ['payroll_period' => $payroll->payroll_period_id] parameters: ['payroll_period' => $payroll->payroll_period_id]
@ -28,7 +28,7 @@ public function store(PayrollAdjustmentRequest $request, Payroll $payroll): Redi
public function destroy(PayrollAdjustment $payrollAdjustment): RedirectResponse public function destroy(PayrollAdjustment $payrollAdjustment): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->delete($payrollAdjustment), fn () => $this->service->destroy($payrollAdjustment),
'Adjustment gaji berhasil dihapus.', 'Adjustment gaji berhasil dihapus.',
'admin.finance.payroll-periods.show', 'admin.finance.payroll-periods.show',
parameters: ['payroll_period' => $payrollAdjustment->payroll->payroll_period_id] parameters: ['payroll_period' => $payrollAdjustment->payroll->payroll_period_id]

View File

@ -1,10 +1,10 @@
<?php <?php
namespace App\Http\Controllers\Admin\Finance; namespace App\Http\Controllers\Admin\Finance\Payroll;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Payroll; use App\Models\Payroll;
use App\Services\Admin\Finance\PayrollPeriodService; use App\Services\Admin\Finance\Payroll\PayrollPeriodService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
class PayrollController extends Controller class PayrollController extends Controller

View File

@ -1,18 +1,21 @@
<?php <?php
namespace App\Http\Controllers\Admin\Finance; namespace App\Http\Controllers\Admin\Finance\Payroll;
use App\Enums\PayrollPeriodStatus; use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\PayrollPeriod; use App\Models\PayrollPeriod;
use App\Services\Admin\Finance\PayrollPeriodService; use App\Services\Admin\Finance\Payroll\PayrollPeriodService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
class PayrollPeriodController extends Controller class PayrollPeriodController extends Controller
{ {
use HasRoleChecks;
public function __construct( public function __construct(
private PayrollPeriodService $service private PayrollPeriodService $service
) {} ) {}
@ -26,22 +29,23 @@ public function index(PaginatedRequest $request): Response
public function current(): RedirectResponse public function current(): RedirectResponse
{ {
$now = now(); $period = $this->service->getCurrentOrCreate();
$period = PayrollPeriod::firstOrCreate(
['year' => $now->year, 'month' => $now->month],
['status' => PayrollPeriodStatus::OPEN]
);
return to_route('admin.finance.payroll-periods.show', ['payroll_period' => $period->id]); return to_route('admin.finance.payroll-periods.show', ['payroll_period' => $period->id]);
} }
public function show(PayrollPeriod $payrollPeriod): Response public function show(PayrollPeriod $payrollPeriod): Response
{ {
$period = $this->service->getDetail($payrollPeriod); $payrollPeriod->load([
'payrolls' => function ($query) {
$query->with(['employee.user.userProfile', 'payrollAdjustments'])
->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->orderBy('id');
},
]);
return Inertia::render('admin/finance/payroll-period/show', [ return Inertia::render('admin/finance/payroll-period/show', [
'payrollPeriod' => $period, 'payrollPeriod' => $payrollPeriod,
]); ]);
} }

View File

@ -6,7 +6,6 @@
use App\Http\Requests\Admin\HR\AttendanceRequest; use App\Http\Requests\Admin\HR\AttendanceRequest;
use App\Models\Attendance; use App\Models\Attendance;
use App\Services\Admin\HR\AttendanceService; use App\Services\Admin\HR\AttendanceService;
use App\Settings\HRSettings;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
@ -22,38 +21,9 @@ public function index(Request $request): Response
{ {
$year = $request->integer('year', now()->year); $year = $request->integer('year', now()->year);
$month = $request->integer('month', now()->month); $month = $request->integer('month', now()->month);
$hrSettings = app(HRSettings::class);
$user = auth()->user();
$isAdmin = $user->hasAnyRole(['developer', 'owner', 'direktur']);
if ($isAdmin) {
return Inertia::render('admin/hr/attendance/index', [
'attendances' => $this->service->getByMonth($year, $month),
'todayAttendance' => null,
'currentYear' => $year,
'currentMonth' => $month,
'monthStats' => $this->service->getMonthStats($year, $month),
'hrSettings' => [
'scheduled_check_in_time' => $hrSettings->scheduled_check_in_time,
'scheduled_check_out_time' => $hrSettings->scheduled_check_out_time,
],
'isAdmin' => true,
]);
}
$employeeId = $user->employee?->id;
return Inertia::render('admin/hr/attendance/index', [ return Inertia::render('admin/hr/attendance/index', [
'attendances' => $this->service->getByMonth($year, $month, $employeeId), ...$this->service->getIndexData($year, $month),
'todayAttendance' => $this->service->getToday(),
'currentYear' => $year,
'currentMonth' => $month,
'monthStats' => $this->service->getMonthStats($year, $month, $employeeId),
'hrSettings' => [
'scheduled_check_in_time' => $hrSettings->scheduled_check_in_time,
'scheduled_check_out_time' => $hrSettings->scheduled_check_out_time,
],
'isAdmin' => false,
]); ]);
} }

View File

@ -2,20 +2,25 @@
namespace App\Http\Controllers\Admin\HR; namespace App\Http\Controllers\Admin\HR;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\HR\EmployeeRequest; use App\Http\Requests\Admin\HR\EmployeeRequest;
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\User; use App\Models\User;
use App\Services\Admin\HR\EmployeeService; use App\Services\Admin\HR\EmployeeService;
use App\Services\Admin\Settings\RoleService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Spatie\Permission\Models\Role;
class EmployeeController extends Controller class EmployeeController extends Controller
{ {
use HasRoleChecks;
public function __construct( public function __construct(
private EmployeeService $service private EmployeeService $service,
private RoleService $roleService,
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
@ -26,28 +31,22 @@ public function index(PaginatedRequest $request): Response
filters: $request->only(['employment_status', 'is_active', 'gender']), filters: $request->only(['employment_status', 'is_active', 'gender']),
), ),
'filters' => $request->only(['employment_status', 'is_active', 'gender']), 'filters' => $request->only(['employment_status', 'is_active', 'gender']),
'canViewAll' => $this->service->canViewAll(), 'canViewAll' => self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]),
]); ]);
} }
public function create(): Response public function create(): Response
{ {
$user = auth()->user();
return Inertia::render('admin/hr/employee/create', [ return Inertia::render('admin/hr/employee/create', [
'roles' => $this->service->canViewAll() 'roles' => $this->roleService->getForEmployee(),
? Role::where('name', '!=', 'Developer') 'canViewAll' => self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]),
->when($this->service->shouldHideAdminBahanBaku(), fn ($q) => $q->where('name', '!=', 'admin-bahan-baku'))
->get(['id', 'name'])
: Role::where('name', '=', $user->roles->first()?->name)->get(['id', 'name']),
'canViewAll' => $this->service->canViewAll(),
]); ]);
} }
public function store(EmployeeRequest $request): RedirectResponse public function store(EmployeeRequest $request): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->create($request->validated()), fn () => $this->service->store($request->validated()),
'Pegawai berhasil ditambahkan.', 'Pegawai berhasil ditambahkan.',
'admin.hr.employees.index' 'admin.hr.employees.index'
); );
@ -59,10 +58,8 @@ public function edit(User $user): Response
return Inertia::render('admin/hr/employee/edit', [ return Inertia::render('admin/hr/employee/edit', [
'employee' => $user, 'employee' => $user,
'roles' => Role::where('name', '!=', 'Developer') 'roles' => $this->roleService->getForEmployee(),
->when($this->service->shouldHideAdminBahanBaku(), fn ($q) => $q->where('name', '!=', 'admin-bahan-baku')) 'canViewAll' => self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]),
->get(['id', 'name']),
'canViewAll' => $this->service->canViewAll(),
]); ]);
} }
@ -78,7 +75,7 @@ public function update(EmployeeRequest $request, User $user): RedirectResponse
public function destroy(User $user): RedirectResponse public function destroy(User $user): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->delete($user), fn () => $this->service->destroy($user),
'Pegawai berhasil dihapus.', 'Pegawai berhasil dihapus.',
'admin.hr.employees.index' 'admin.hr.employees.index'
); );
@ -91,7 +88,7 @@ public function toggleActive(User $user): RedirectResponse
Inertia::flash('toast', ['type' => 'success', 'message' => "Pegawai berhasil {$status}."]); Inertia::flash('toast', ['type' => 'success', 'message' => "Pegawai berhasil {$status}."]);
return to_route('admin.hr.employees.index'); return back();
} }
public function resetPassword(User $user): RedirectResponse public function resetPassword(User $user): RedirectResponse

View File

@ -35,7 +35,7 @@ public function index(PaginatedRequest $request): Response
public function store(LeaveRequestRequest $request): RedirectResponse public function store(LeaveRequestRequest $request): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->create($request->validated()), fn () => $this->service->store($request->validated()),
'Permohonan cuti berhasil ditambahkan.', 'Permohonan cuti berhasil ditambahkan.',
'admin.hr.leave-requests.index' 'admin.hr.leave-requests.index'
); );
@ -53,7 +53,7 @@ public function update(LeaveRequestRequest $request, LeaveRequest $leaveRequest)
public function destroy(LeaveRequest $leaveRequest): RedirectResponse public function destroy(LeaveRequest $leaveRequest): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->delete($leaveRequest), fn () => $this->service->destroy($leaveRequest),
'Permohonan cuti berhasil dihapus.', 'Permohonan cuti berhasil dihapus.',
'admin.hr.leave-requests.index' 'admin.hr.leave-requests.index'
); );

View File

@ -7,6 +7,7 @@
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\Cutting; use App\Models\Cutting;
use App\Services\Admin\Manage\CuttingService; use App\Services\Admin\Manage\CuttingService;
use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -15,6 +16,7 @@ class CuttingController extends Controller
{ {
public function __construct( public function __construct(
private CuttingService $service, private CuttingService $service,
private RawMaterialVariantService $rawMaterialVariantService,
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
@ -29,14 +31,14 @@ public function index(PaginatedRequest $request): Response
public function create(): Response public function create(): Response
{ {
return Inertia::render('admin/manage/cutting/create', [ return Inertia::render('admin/manage/cutting/create', [
'data' => $this->service->getForCreate(), 'rawMaterials' => $this->rawMaterialVariantService->getForCutting(),
]); ]);
} }
public function store(CuttingRequest $request): RedirectResponse public function store(CuttingRequest $request): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->create($request->validated()), fn () => $this->service->store($request->validated()),
'Cutting berhasil ditambahkan.', 'Cutting berhasil ditambahkan.',
'admin.manage.cuttings.index', 'admin.manage.cuttings.index',
'admin.manage.cuttings.create' 'admin.manage.cuttings.create'
@ -47,7 +49,7 @@ public function edit(Cutting $cutting): Response
{ {
return Inertia::render('admin/manage/cutting/edit', [ return Inertia::render('admin/manage/cutting/edit', [
'cutting' => $this->service->getForEdit($cutting), 'cutting' => $this->service->getForEdit($cutting),
'data' => $this->service->getForCreate(), 'rawMaterials' => $this->rawMaterialVariantService->getForCutting(),
]); ]);
} }
@ -65,7 +67,7 @@ public function update(CuttingRequest $request, Cutting $cutting): RedirectRespo
public function destroy(Cutting $cutting): RedirectResponse public function destroy(Cutting $cutting): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->delete($cutting), fn () => $this->service->destroy($cutting),
'Cutting berhasil dihapus.', 'Cutting berhasil dihapus.',
'admin.manage.cuttings.index' 'admin.manage.cuttings.index'
); );

View File

@ -7,6 +7,7 @@
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\Purchase; use App\Models\Purchase;
use App\Services\Admin\Manage\PurchaseService; use App\Services\Admin\Manage\PurchaseService;
use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService;
use App\Services\Admin\Master\SupplierService; use App\Services\Admin\Master\SupplierService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
@ -17,6 +18,7 @@ class PurchaseController extends Controller
public function __construct( public function __construct(
private readonly PurchaseService $service, private readonly PurchaseService $service,
private readonly SupplierService $supplierService, private readonly SupplierService $supplierService,
private readonly RawMaterialVariantService $rawMaterialVariantService,
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
@ -34,14 +36,15 @@ public function index(PaginatedRequest $request): Response
public function create(): Response public function create(): Response
{ {
return Inertia::render('admin/manage/purchase/create', [ return Inertia::render('admin/manage/purchase/create', [
'data' => $this->service->getForCreate(), 'suppliers' => $this->supplierService->getAll(),
'rawMaterials' => $this->rawMaterialVariantService->getForCutting(),
]); ]);
} }
public function store(PurchaseRequest $request): RedirectResponse public function store(PurchaseRequest $request): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->create($request->validated()), fn () => $this->service->store($request->validated()),
'Belanja berhasil ditambahkan.', 'Belanja berhasil ditambahkan.',
'admin.manage.purchases.index', 'admin.manage.purchases.index',
'admin.manage.purchases.create' 'admin.manage.purchases.create'
@ -52,7 +55,8 @@ public function edit(Purchase $purchase): Response
{ {
return Inertia::render('admin/manage/purchase/edit', [ return Inertia::render('admin/manage/purchase/edit', [
'purchase' => $this->service->getForEdit($purchase), 'purchase' => $this->service->getForEdit($purchase),
'data' => $this->service->getForCreate(), 'suppliers' => $this->supplierService->getAll(),
'rawMaterials' => $this->rawMaterialVariantService->getForCutting(),
]); ]);
} }
@ -70,7 +74,7 @@ public function update(PurchaseRequest $request, Purchase $purchase): RedirectRe
public function destroy(Purchase $purchase): RedirectResponse public function destroy(Purchase $purchase): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->delete($purchase), fn () => $this->service->destroy($purchase),
'Belanja berhasil dihapus.', 'Belanja berhasil dihapus.',
'admin.manage.purchases.index' 'admin.manage.purchases.index'
); );

View File

@ -7,6 +7,7 @@
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\Restock; use App\Models\Restock;
use App\Services\Admin\Manage\RestockService; use App\Services\Admin\Manage\RestockService;
use App\Services\Admin\Master\Product\ProductVariantService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -15,6 +16,7 @@ class RestockController extends Controller
{ {
public function __construct( public function __construct(
private RestockService $service, private RestockService $service,
private ProductVariantService $productVariantService,
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
@ -29,14 +31,14 @@ public function index(PaginatedRequest $request): Response
public function create(): Response public function create(): Response
{ {
return Inertia::render('admin/manage/restock/create', [ return Inertia::render('admin/manage/restock/create', [
'data' => $this->service->getForCreate(), 'products' => $this->productVariantService->getForRestock(),
]); ]);
} }
public function store(RestockRequest $request): RedirectResponse public function store(RestockRequest $request): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->create($request->validated()), fn () => $this->service->store($request->validated()),
'Restock berhasil ditambahkan.', 'Restock berhasil ditambahkan.',
'admin.manage.restocks.index', 'admin.manage.restocks.index',
'admin.manage.restocks.create' 'admin.manage.restocks.create'
@ -47,7 +49,7 @@ public function edit(Restock $restock): Response
{ {
return Inertia::render('admin/manage/restock/edit', [ return Inertia::render('admin/manage/restock/edit', [
'restock' => $this->service->getForEdit($restock), 'restock' => $this->service->getForEdit($restock),
'data' => $this->service->getForCreate(), 'products' => $this->productVariantService->getForRestock(),
]); ]);
} }
@ -65,7 +67,7 @@ public function update(RestockRequest $request, Restock $restock): RedirectRespo
public function destroy(Restock $restock): RedirectResponse public function destroy(Restock $restock): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->delete($restock), fn () => $this->service->destroy($restock),
'Restock berhasil dihapus.', 'Restock berhasil dihapus.',
'admin.manage.restocks.index' 'admin.manage.restocks.index'
); );

View File

@ -2,11 +2,17 @@
namespace App\Http\Controllers\Admin\Manage; namespace App\Http\Controllers\Admin\Manage;
use App\Enums\OrderChannel;
use App\Enums\PaymentType;
use App\Enums\PriceType;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\TransactionRequest; use App\Http\Requests\Admin\Manage\TransactionRequest;
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\Order; use App\Models\Order;
use App\Models\User;
use App\Services\Admin\Manage\TransactionService; use App\Services\Admin\Manage\TransactionService;
use App\Services\Admin\Master\CustomerService;
use App\Services\Admin\Master\Product\ProductVariantService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -15,6 +21,8 @@ class TransactionController extends Controller
{ {
public function __construct( public function __construct(
private TransactionService $service, private TransactionService $service,
private ProductVariantService $productVariantService,
private CustomerService $customerService,
) {} ) {}
public function index(PaginatedRequest $request): Response public function index(PaginatedRequest $request): Response
@ -35,14 +43,19 @@ public function index(PaginatedRequest $request): Response
public function create(): Response public function create(): Response
{ {
return Inertia::render('admin/manage/transaction/create', [ return Inertia::render('admin/manage/transaction/create', [
'data' => $this->service->getForCreate(), 'products' => $this->productVariantService->getForTransaction(),
'customers' => $this->customerService->getAll(),
'employees' => $this->getEmployees(),
'channelOptions' => OrderChannel::toSelect(),
'paymentTypeOptions' => PaymentType::toSelect(),
'priceTypeOptions' => PriceType::toSelect()->filter(fn ($p) => $p['value'] !== PriceType::CAPITAL->value)->values(),
]); ]);
} }
public function store(TransactionRequest $request): RedirectResponse public function store(TransactionRequest $request): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->create($request->validated()), fn () => $this->service->store($request->validated()),
'Transaksi berhasil ditambahkan.', 'Transaksi berhasil ditambahkan.',
'admin.manage.transactions.index', 'admin.manage.transactions.index',
'admin.manage.transactions.create' 'admin.manage.transactions.create'
@ -53,7 +66,12 @@ public function edit(Order $transaction): Response
{ {
return Inertia::render('admin/manage/transaction/edit', [ return Inertia::render('admin/manage/transaction/edit', [
'transaction' => $this->service->getForEdit($transaction), 'transaction' => $this->service->getForEdit($transaction),
'data' => $this->service->getForCreate(), 'products' => $this->productVariantService->getForTransaction(),
'customers' => $this->customerService->getAll(),
'employees' => $this->getEmployees(),
'channelOptions' => OrderChannel::toSelect(),
'paymentTypeOptions' => PaymentType::toSelect(),
'priceTypeOptions' => PriceType::toSelect()->filter(fn ($p) => $p['value'] !== PriceType::CAPITAL->value)->values(),
]); ]);
} }
@ -71,7 +89,7 @@ public function update(TransactionRequest $request, Order $transaction): Redirec
public function destroy(Order $transaction): RedirectResponse public function destroy(Order $transaction): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->delete($transaction), fn () => $this->service->destroy($transaction),
'Transaksi berhasil dihapus.', 'Transaksi berhasil dihapus.',
'admin.manage.transactions.index' 'admin.manage.transactions.index'
); );
@ -85,4 +103,16 @@ public function updateStatus(Order $transaction): RedirectResponse
'admin.manage.transactions.index' 'admin.manage.transactions.index'
); );
} }
private function getEmployees()
{
return User::query()
->select('id')
->active()
->with('userProfile:id,user_id,full_name')
->orderBy('id')
->get()
->filter(fn (User $user) => $user->userProfile?->full_name)
->values();
}
} }

View File

@ -26,11 +26,11 @@ public function index(PaginatedRequest $request): Response
public function store(CategoryRequest $request): RedirectResponse public function store(CategoryRequest $request): RedirectResponse
{ {
$this->service->create($request->validated()); $this->service->store($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kategori berhasil ditambahkan.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Kategori berhasil ditambahkan.']);
return to_route('admin.master.categories.index'); return back();
} }
public function update(CategoryRequest $request, Category $category): RedirectResponse public function update(CategoryRequest $request, Category $category): RedirectResponse
@ -39,15 +39,15 @@ public function update(CategoryRequest $request, Category $category): RedirectRe
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kategori berhasil diperbarui.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Kategori berhasil diperbarui.']);
return to_route('admin.master.categories.index'); return back();
} }
public function destroy(Category $category): RedirectResponse public function destroy(Category $category): RedirectResponse
{ {
$this->service->delete($category); $this->service->destroy($category);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kategori berhasil dihapus.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Kategori berhasil dihapus.']);
return to_route('admin.master.categories.index'); return back();
} }
} }

View File

@ -26,11 +26,11 @@ public function index(PaginatedRequest $request): Response
public function store(CustomerRequest $request): RedirectResponse public function store(CustomerRequest $request): RedirectResponse
{ {
$this->service->create($request->validated()); $this->service->store($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Customer berhasil ditambahkan.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Customer berhasil ditambahkan.']);
return to_route('admin.master.customers.index'); return back();
} }
public function update(CustomerRequest $request, Customer $customer): RedirectResponse public function update(CustomerRequest $request, Customer $customer): RedirectResponse
@ -39,15 +39,15 @@ public function update(CustomerRequest $request, Customer $customer): RedirectRe
Inertia::flash('toast', ['type' => 'success', 'message' => 'Customer berhasil diperbarui.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Customer berhasil diperbarui.']);
return to_route('admin.master.customers.index'); return back();
} }
public function destroy(Customer $customer): RedirectResponse public function destroy(Customer $customer): RedirectResponse
{ {
$this->service->delete($customer); $this->service->destroy($customer);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Customer berhasil dihapus.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Customer berhasil dihapus.']);
return to_route('admin.master.customers.index'); return back();
} }
} }

View File

@ -84,6 +84,34 @@ public function toggleStatus(Product $product): RedirectResponse
Inertia::flash('toast', ['type' => 'success', 'message' => "Status produk berhasil diubah menjadi {$status->label()}."]); Inertia::flash('toast', ['type' => 'success', 'message' => "Status produk berhasil diubah menjadi {$status->label()}."]);
return to_route('admin.master.products.index'); return back();
}
public function approve(Product $product): RedirectResponse
{
$this->service->approve($product);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil disetujui.']);
return back();
}
public function reject(Product $product): RedirectResponse
{
$reason = request()->input('rejection_reason', '');
$this->service->reject($product, $reason);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil ditolak.']);
return back();
}
public function resubmit(Product $product): RedirectResponse
{
$this->service->resubmit($product);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil diajukan ulang.']);
return back();
} }
} }

View File

@ -18,8 +18,6 @@ public function __construct(
public function index(StockMutationRequest $request, Product $product, ProductVariant $variant): Response public function index(StockMutationRequest $request, Product $product, ProductVariant $variant): Response
{ {
$perPage = $request->validatedWithDefaults()['perPage'];
return Inertia::render('admin/master/product/variant/stock-mutations', [ return Inertia::render('admin/master/product/variant/stock-mutations', [
'product' => [ 'product' => [
'id' => $product->id, 'id' => $product->id,
@ -29,7 +27,7 @@ public function index(StockMutationRequest $request, Product $product, ProductVa
'id' => $variant->id, 'id' => $variant->id,
'name' => $variant->name, 'name' => $variant->name,
], ],
'mutations' => $this->service->paginated($variant, $perPage), 'mutations' => $this->service->paginated($variant, ...$request->validatedWithDefaults()),
]); ]);
} }
} }

View File

@ -36,7 +36,7 @@ public function create(): Response
public function store(RawMaterialRequest $request): RedirectResponse public function store(RawMaterialRequest $request): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->create($request->validated()), fn () => $this->service->store($request->validated()),
'Bahan baku berhasil ditambahkan.', 'Bahan baku berhasil ditambahkan.',
'admin.master.raw-materials.index', 'admin.master.raw-materials.index',
'admin.master.raw-materials.create' 'admin.master.raw-materials.create'
@ -64,7 +64,7 @@ public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): R
public function destroy(RawMaterial $rawMaterial): RedirectResponse public function destroy(RawMaterial $rawMaterial): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->delete($rawMaterial), fn () => $this->service->destroy($rawMaterial),
'Bahan baku berhasil dihapus.', 'Bahan baku berhasil dihapus.',
'admin.master.raw-materials.index' 'admin.master.raw-materials.index'
); );
@ -77,6 +77,6 @@ public function toggleStatus(RawMaterial $rawMaterial): RedirectResponse
Inertia::flash('toast', ['type' => 'success', 'message' => "Status bahan baku berhasil diubah menjadi {$status}."]); Inertia::flash('toast', ['type' => 'success', 'message' => "Status bahan baku berhasil diubah menjadi {$status}."]);
return to_route('admin.master.raw-materials.index'); return back();
} }
} }

View File

@ -26,11 +26,11 @@ public function index(PaginatedRequest $request): Response
public function store(SupplierRequest $request): RedirectResponse public function store(SupplierRequest $request): RedirectResponse
{ {
$this->service->create($request->validated()); $this->service->store($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Supplier berhasil ditambahkan.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Supplier berhasil ditambahkan.']);
return to_route('admin.master.suppliers.index'); return back();
} }
public function update(SupplierRequest $request, Supplier $supplier): RedirectResponse public function update(SupplierRequest $request, Supplier $supplier): RedirectResponse
@ -39,15 +39,15 @@ public function update(SupplierRequest $request, Supplier $supplier): RedirectRe
Inertia::flash('toast', ['type' => 'success', 'message' => 'Supplier berhasil diperbarui.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Supplier berhasil diperbarui.']);
return to_route('admin.master.suppliers.index'); return back();
} }
public function destroy(Supplier $supplier): RedirectResponse public function destroy(Supplier $supplier): RedirectResponse
{ {
$this->service->delete($supplier); $this->service->destroy($supplier);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Supplier berhasil dihapus.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Supplier berhasil dihapus.']);
return to_route('admin.master.suppliers.index'); return back();
} }
} }

View File

@ -34,7 +34,7 @@ public function create(): Response
public function store(RoleRequest $request): RedirectResponse public function store(RoleRequest $request): RedirectResponse
{ {
return $this->handleAction( return $this->handleAction(
fn () => $this->service->create($request->validated()), fn () => $this->service->store($request->validated()),
'Role berhasil ditambahkan.', 'Role berhasil ditambahkan.',
'admin.settings.roles.index' 'admin.settings.roles.index'
); );
@ -43,7 +43,7 @@ public function store(RoleRequest $request): RedirectResponse
public function edit(Role $role): Response public function edit(Role $role): Response
{ {
return Inertia::render('admin/roles/edit', [ return Inertia::render('admin/roles/edit', [
'role' => $this->service->getById($role->id), 'role' => $role->load('permissions'),
'permissions' => $this->service->getPermissionsByModule(), 'permissions' => $this->service->getPermissionsByModule(),
]); ]);
} }
@ -59,10 +59,10 @@ public function update(RoleRequest $request, Role $role): RedirectResponse
public function destroy(Role $role): RedirectResponse public function destroy(Role $role): RedirectResponse
{ {
$this->service->delete($role); $this->service->destroy($role);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Role berhasil dihapus.']); Inertia::flash('toast', ['type' => 'success', 'message' => 'Role berhasil dihapus.']);
return to_route('admin.settings.roles.index'); return back();
} }
} }

View File

@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Services\AnalysisService;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class AnalysisController extends Controller
{
use HasRoleChecks;
public function __construct(
private AnalysisService $service
) {}
public function index(Request $request): Response
{
$user = $request->user();
$isManager = self::hasAnyRole([Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO, Role::DIREKTUR]);
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
$attendance = $this->service->getAttendanceStats($startDate, $endDate);
$myAttendance = $this->service->getMyAttendance($user, $startDate, $endDate);
$cashOverview = $this->service->getCashOverview($startDate, $endDate);
$rawMaterialStock = $this->service->getRawMaterialStock();
$productStock = $this->service->getProductStock();
$revenueSummary = $this->service->getRevenueSummary($startDate, $endDate);
$monthlyRevenue = $this->service->getMonthlyRevenue($startDate, $endDate);
$monthlyRevenueByChannel = $this->service->getMonthlyRevenueByChannel($startDate, $endDate);
$revenueByPaymentType = $this->service->getRevenueByPaymentType($startDate, $endDate);
$expenseSummary = $this->service->getExpenseSummary($startDate, $endDate);
$monthlyExpense = $this->service->getMonthlyExpense($startDate, $endDate);
$busyHours = $this->service->getBusyHours($startDate, $endDate);
$profitMetrics = $this->service->getProfitMetrics($startDate, $endDate);
$topSuppliers = $this->service->getTopSuppliers($startDate, $endDate);
$topCustomers = $this->service->getTopCustomers($startDate, $endDate);
$topProducts = $this->service->getTopProducts($startDate, $endDate);
$marketingSales = $this->service->getMarketingSales($startDate, $endDate);
$orderStats = $this->service->getOrderStats($startDate, $endDate);
return Inertia::render('admin/analysis/index', [
'filters' => [
'start_date' => $startDate,
'end_date' => $endDate,
],
'attendance' => $attendance,
'myAttendance' => $myAttendance,
'isManager' => $isManager,
'cashOverview' => $cashOverview,
'rawMaterialStock' => $rawMaterialStock,
'productStock' => $productStock,
'revenueSummary' => $revenueSummary,
'monthlyRevenue' => $monthlyRevenue,
'monthlyRevenueByChannel' => $monthlyRevenueByChannel,
'revenueByPaymentType' => $revenueByPaymentType,
'expenseSummary' => $expenseSummary,
'monthlyExpense' => $monthlyExpense,
'busyHours' => $busyHours,
'profitMetrics' => $profitMetrics,
'topSuppliers' => $topSuppliers,
'topCustomers' => $topCustomers,
'topProducts' => $topProducts,
'marketingSales' => $marketingSales,
'orderStats' => $orderStats,
]);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers;
use App\Services\DashboardService;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __construct(
private DashboardService $service
) {}
public function __invoke(Request $request): Response
{
$user = $request->user();
return Inertia::render('dashboard', [
'attendance' => $this->service->getAttendanceStats(),
'revenueSummary' => $this->service->getRevenueSummary(),
'expenseSummary' => $this->service->getExpenseSummary(),
'orderStats' => $this->service->getOrderStats(),
'todayAttendance' => $this->service->getTodayAttendance($user),
'isOnLeave' => $this->service->isOnLeave($user),
'canCheckIn' => $user->employee !== null,
]);
}
}

View File

@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Product;
use App\Services\S3PresignedService;
use App\Settings\HomepageSettings;
use App\Settings\SocialMediaSettings;
use App\Settings\SystemSettings;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class HomepageController extends Controller
{
public function __construct(
private S3PresignedService $s3Service,
) {}
public function __invoke(Request $request): Response
{
$system = app(SystemSettings::class);
$homepage = app(HomepageSettings::class);
$socialMedia = app(SocialMediaSettings::class);
$categories = Category::select(['id', 'name', 'slug'])
->orderBy('name')
->get();
$search = $request->input('search', '');
$category = $request->input('category', '');
$productsQuery = Product::select(['id', 'name', 'slug', 'description', 'status'])
->active()
->with([
'categories:id,name,slug',
'productVariants:id,product_id,name,stock',
'productVariants.productPrices:id,variant_id,type,price',
]);
if ($search !== '') {
$productsQuery->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
if ($category !== '') {
$productsQuery->whereHas('categories', function ($q) use ($category) {
$q->where('slug', $category);
});
}
$products = Inertia::scroll(
fn () => $productsQuery->orderBy('created_at', 'desc')->paginate(12)
);
$galleryImages = array_map(
fn ($key) => str_starts_with($key, 'http') ? $key : $this->s3Service->getTemporaryUrl($key, 60),
$homepage->gallery_images ?? [],
);
return Inertia::render('welcome', [
'appName' => $system->app_name,
'aboutApp' => $system->about_app,
'contactEmail' => $system->email,
'contactPhone' => $system->phone,
'contactAddress' => $system->address,
'instagramUrl' => $socialMedia->instagram_url,
'facebookUrl' => $socialMedia->facebook_url,
'tiktokUrl' => $socialMedia->tiktok_url,
'homepage' => [
'hero_badge' => $homepage->hero_badge,
'hero_title_line1' => $homepage->hero_title_line1,
'hero_title_line2' => $homepage->hero_title_line2,
'hero_title_highlight' => $homepage->hero_title_highlight,
'hero_description' => $homepage->hero_description,
'hero_cta_primary_text' => $homepage->hero_cta_primary_text,
'hero_cta_secondary_text' => $homepage->hero_cta_secondary_text,
'hero_image_url' => $homepage->hero_image_url
? (str_starts_with($homepage->hero_image_url, 'http') ? $homepage->hero_image_url : $this->s3Service->getTemporaryUrl($homepage->hero_image_url, 60))
: null,
'hero_bg_text_left' => $homepage->hero_bg_text_left,
'hero_bg_text_right' => $homepage->hero_bg_text_right,
'scroll_hashtag' => $homepage->scroll_hashtag,
'scroll_tagline' => $homepage->scroll_tagline,
'catalog_badge' => $homepage->catalog_badge,
'catalog_title' => $homepage->catalog_title,
'catalog_description' => $homepage->catalog_description,
'catalog_search_placeholder' => $homepage->catalog_search_placeholder,
'gallery_badge' => $homepage->gallery_badge,
'gallery_title' => $homepage->gallery_title,
'gallery_description' => $homepage->gallery_description,
'gallery_images' => $galleryImages,
'order_guide_badge' => $homepage->order_guide_badge,
'order_guide_title' => $homepage->order_guide_title,
'order_guide_description' => $homepage->order_guide_description,
'order_steps' => $homepage->order_steps,
'about_badge' => $homepage->about_badge,
'about_title' => $homepage->about_title,
'about_image_url' => $homepage->about_image_url
? (str_starts_with($homepage->about_image_url, 'http') ? $homepage->about_image_url : $this->s3Service->getTemporaryUrl($homepage->about_image_url, 60))
: null,
'about_features' => $homepage->about_features,
'contact_badge' => $homepage->contact_badge,
'contact_title' => $homepage->contact_title,
'contact_description' => $homepage->contact_description,
'contact_form_title' => $homepage->contact_form_title,
'footer_description' => $homepage->footer_description,
'footer_copyright' => $homepage->footer_copyright,
],
'categories' => $categories,
'products' => $products,
'filters' => [
'search' => $search,
'category' => $category,
],
'seo' => [
'title' => $system->app_name.' - '.$homepage->hero_badge,
'description' => $homepage->hero_description,
'image' => url('/assets/logo.png'),
'url' => url('/'),
],
]);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Settings\SystemSettings;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Middleware; use Inertia\Middleware;
@ -38,6 +39,7 @@ public function share(Request $request): array
return [ return [
...parent::share($request), ...parent::share($request),
'name' => config('app.name'), 'name' => config('app.name'),
'address' => app(SystemSettings::class)->address ?? '',
'auth' => [ 'auth' => [
'user' => $request->user() 'user' => $request->user()
? tap($request->user()->load('userProfile', 'roles'), function ($user) { ? tap($request->user()->load('userProfile', 'roles'), function ($user) {

View File

@ -9,7 +9,7 @@ class CashAccountRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('cash.update');
} }
public function rules(): array public function rules(): array

View File

@ -12,7 +12,8 @@ class CashTransactionRequest extends FormRequest
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('cash.deposit')
|| $this->user()->can('cash.withdraw');
} }
#[Override] #[Override]

View File

@ -12,7 +12,7 @@ class EmployeeAdvancePaymentRequest extends FormRequest
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('employee_advances.pay');
} }
#[Override] #[Override]

View File

@ -12,7 +12,8 @@ class EmployeeAdvanceRequest extends FormRequest
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('employee_advances.create')
|| $this->user()->can('employee_advances.update');
} }
#[Override] #[Override]

View File

@ -12,7 +12,8 @@ class ExpenseRequest extends FormRequest
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('expenses.create')
|| $this->user()->can('expenses.update');
} }
#[Override] #[Override]

View File

@ -14,7 +14,7 @@ class PayrollAdjustmentRequest extends FormRequest
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('payroll.adjust');
} }
#[Override] #[Override]
@ -29,7 +29,6 @@ public function rules(): array
'type' => ['required', Rule::in(PayrollAdjustmentType::values())], 'type' => ['required', Rule::in(PayrollAdjustmentType::values())],
'amount' => ['required', 'integer', 'min:1'], 'amount' => ['required', 'integer', 'min:1'],
'description' => ['required', 'string', 'max:100'], 'description' => ['required', 'string', 'max:100'],
'attendance_id' => ['nullable', 'integer', 'exists:attendances,id'],
]; ];
} }
@ -39,7 +38,6 @@ public function attributes(): array
'type' => 'jenis', 'type' => 'jenis',
'amount' => 'jumlah', 'amount' => 'jumlah',
'description' => 'keterangan', 'description' => 'keterangan',
'attendance_id' => 'presensi',
]; ];
} }
} }

View File

@ -8,15 +8,15 @@ class AttendanceRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('attendances.create');
} }
public function rules(): array public function rules(): array
{ {
return [ return [
'photo' => ['required'], 'photo' => ['required'],
'latitude' => ['required', 'numeric'], 'latitude' => ['required', 'numeric', 'min:-90', 'max:90'],
'longitude' => ['required', 'numeric'], 'longitude' => ['required', 'numeric', 'min:-180', 'max:180'],
]; ];
} }

View File

@ -2,6 +2,8 @@
namespace App\Http\Requests\Admin\HR; namespace App\Http\Requests\Admin\HR;
use App\Enums\EmploymentStatus;
use App\Enums\Gender;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -9,7 +11,8 @@ class EmployeeRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('employees.create')
|| $this->user()->can('employees.update');
} }
public function rules(): array public function rules(): array
@ -34,7 +37,7 @@ public function rules(): array
'role' => ['required', 'string', Rule::exists('roles', 'name')], 'role' => ['required', 'string', Rule::exists('roles', 'name')],
'full_name' => ['required', 'string', 'max:200'], 'full_name' => ['required', 'string', 'max:200'],
'phone_number' => ['nullable', 'string', 'max:20'], 'phone_number' => ['nullable', 'string', 'max:20'],
'gender' => ['nullable', 'in:male,female'], 'gender' => ['nullable', Rule::in(Gender::values())],
'birth_date' => ['nullable', 'date'], 'birth_date' => ['nullable', 'date'],
'address' => ['nullable', 'string'], 'address' => ['nullable', 'string'],
'join_date' => [ 'join_date' => [
@ -48,7 +51,7 @@ public function rules(): array
], ],
'employment_status' => [ 'employment_status' => [
Rule::requiredIf(! $isOwner), Rule::requiredIf(! $isOwner),
Rule::in(['full_time', 'part_time', 'contract', 'internship', 'resigned']), Rule::in(EmploymentStatus::values()),
], ],
'base_salary' => [ 'base_salary' => [
Rule::requiredIf(! $isOwner), Rule::requiredIf(! $isOwner),

View File

@ -2,19 +2,46 @@
namespace App\Http\Requests\Admin\HR; namespace App\Http\Requests\Admin\HR;
use App\Models\LeaveRequest;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
class LeaveRequestRequest extends FormRequest class LeaveRequestRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('leave_requests.create')
|| $this->user()->can('leave_requests.update');
} }
public function rules(): array public function rules(): array
{ {
$leaveRequest = $this->route('leaveRequest');
return [ return [
'start_date' => ['required', 'date', 'after_or_equal:today'], 'start_date' => [
'required',
'date',
'after:today',
function ($attribute, $value, $fail) use ($leaveRequest) {
$employee = $this->user()->employee;
if (! $employee) {
return;
}
$query = LeaveRequest::where('employee_id', $employee->id)
->where('start_date', $value)
->where('status', '!=', LeaveRequest::cancelled());
if ($leaveRequest) {
$query->where('id', '!=', $leaveRequest->id);
}
if ($query->exists()) {
$fail('Anda sudah mengajukan cuti pada tanggal ini.');
}
},
],
'end_date' => ['required', 'date', 'after_or_equal:start_date'], 'end_date' => ['required', 'date', 'after_or_equal:start_date'],
]; ];
} }

View File

@ -3,12 +3,14 @@
namespace App\Http\Requests\Admin\Manage; namespace App\Http\Requests\Admin\Manage;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CuttingRequest extends FormRequest class CuttingRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('cuttings.create')
|| $this->user()->can('cuttings.update');
} }
public function rules(): array public function rules(): array
@ -20,7 +22,7 @@ public function rules(): array
'original_outside_sample' => ['required', 'integer'], 'original_outside_sample' => ['required', 'integer'],
'cutting_result' => ['required', 'integer', 'min:1'], 'cutting_result' => ['required', 'integer', 'min:1'],
'materials' => ['required', 'array', 'min:1'], 'materials' => ['required', 'array', 'min:1'],
'materials.*.raw_material_price_id' => ['required', 'integer', 'exists:raw_material_prices,id'], 'materials.*.raw_material_price_id' => ['required', 'integer', Rule::exists('raw_material_prices', 'id')],
'materials.*.material_usage' => ['required', 'integer', 'min:1'], 'materials.*.material_usage' => ['required', 'integer', 'min:1'],
'materials.*.material_result' => ['required', 'integer'], 'materials.*.material_result' => ['required', 'integer'],
'materials.*.combination_index' => ['nullable', 'integer'], 'materials.*.combination_index' => ['nullable', 'integer'],

View File

@ -13,7 +13,8 @@ class PurchaseRequest extends FormRequest
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('purchases.create')
|| $this->user()->can('purchases.update');
} }
public function prepareForValidation(): void public function prepareForValidation(): void
@ -25,18 +26,18 @@ public function rules(): array
{ {
return [ return [
'mode' => ['sometimes', 'required', 'in:new,existing'], 'mode' => ['sometimes', 'required', 'in:new,existing'],
'name' => ['required_unless:mode,existing', 'string', 'max:200'], 'name' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'string', 'max:200'],
'unit' => [$this->isMethod('post') ? 'required_unless:mode,existing' : 'nullable', Rule::in(RawMaterialUnit::values())], 'unit' => [$this->isMethod('post') ? Rule::requiredUnless(fn () => $this->input('mode') === 'existing') : 'nullable', Rule::in(RawMaterialUnit::values())],
'variants' => ['required_unless:mode,existing', 'array', 'min:1'], 'variants' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'array', 'min:1'],
'variants.*.variant' => ['required_unless:mode,existing', 'string', 'max:200'], 'variants.*.variant' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'string', 'max:200'],
'variants.*.price' => ['required_unless:mode,existing', 'integer', 'min:0'], 'variants.*.price' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'integer', 'min:0'],
'variants.*.stock' => ['required_unless:mode,existing', 'integer', 'min:0'], 'variants.*.stock' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'integer', 'min:0'],
'variants.*.photo_key' => ['required_unless:mode,existing', 'string', 'max:500'], 'variants.*.photo_key' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'string', 'max:500'],
'existing_items' => ['required_if:mode,existing', 'array', 'min:1'], 'existing_items' => [Rule::requiredIf(fn () => $this->input('mode') === 'existing'), 'array', 'min:1'],
'existing_items.*.raw_material_price_id' => ['required', 'integer', 'exists:raw_material_prices,id'], 'existing_items.*.raw_material_price_id' => ['required', 'integer', Rule::exists('raw_material_prices', 'id')],
'existing_items.*.quantity' => ['required', 'integer', 'min:1'], 'existing_items.*.quantity' => ['required', 'integer', 'min:1'],
'existing_items.*.unit_price' => ['required', 'integer', 'min:0'], 'existing_items.*.unit_price' => ['required', 'integer', 'min:0'],
'supplier_id' => ['required', 'integer', 'exists:suppliers,id'], 'supplier_id' => ['required', 'integer', Rule::exists('suppliers', 'id')],
'discount' => ['nullable', 'integer', 'min:0'], 'discount' => ['nullable', 'integer', 'min:0'],
'shipping_cost' => ['nullable', 'integer', 'min:0'], 'shipping_cost' => ['nullable', 'integer', 'min:0'],
'notes' => ['nullable', 'string', 'max:100'], 'notes' => ['nullable', 'string', 'max:100'],

View File

@ -10,7 +10,8 @@ class RestockRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('restocks.create')
|| $this->user()->can('restocks.update');
} }
public function rules(): array public function rules(): array
@ -18,7 +19,7 @@ public function rules(): array
return [ return [
'stock_type' => ['sometimes', 'required', Rule::in(ProductStockQuality::values())], 'stock_type' => ['sometimes', 'required', Rule::in(ProductStockQuality::values())],
'items' => ['required', 'array', 'min:1'], 'items' => ['required', 'array', 'min:1'],
'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'], 'items.*.product_variant_id' => ['required', 'integer', Rule::exists('product_variants', 'id')],
'items.*.quantity' => ['required', 'integer', 'min:1'], 'items.*.quantity' => ['required', 'integer', 'min:1'],
'notes' => ['nullable', 'string', 'max:100'], 'notes' => ['nullable', 'string', 'max:100'],
'photo_key' => ['nullable', 'string', 'max:500'], 'photo_key' => ['nullable', 'string', 'max:500'],

View File

@ -16,7 +16,8 @@ class TransactionRequest extends FormRequest
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('orders.create')
|| $this->user()->can('orders.update');
} }
public function prepareForValidation(): void public function prepareForValidation(): void
@ -26,15 +27,13 @@ public function prepareForValidation(): void
public function rules(): array public function rules(): array
{ {
$sellingPriceTypes = array_diff(PriceType::values(), [PriceType::CAPITAL->value]);
return [ return [
'stock_type' => ['sometimes', 'required', Rule::in(ProductStockQuality::values())], 'stock_type' => ['sometimes', 'required', Rule::in(ProductStockQuality::values())],
'channel' => ['sometimes', 'required', Rule::in(OrderChannel::values())], 'channel' => ['sometimes', 'required', Rule::in(OrderChannel::values())],
'price_type' => ['sometimes', 'required', Rule::in($sellingPriceTypes)], 'price_type' => ['sometimes', 'required', Rule::in(array_diff(PriceType::values(), [PriceType::CAPITAL->value]))],
'payment_type' => ['sometimes', 'required', Rule::in(PaymentType::values())], 'payment_type' => ['sometimes', 'required', Rule::in(PaymentType::values())],
'customer_id' => ['nullable', 'integer', 'exists:customers,id'], 'customer_id' => ['nullable', 'integer', Rule::exists('customers', 'id')],
'marketing_id' => ['nullable', 'integer', 'exists:users,id'], 'marketing_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
'discount' => ['nullable', 'integer', 'min:0'], 'discount' => ['nullable', 'integer', 'min:0'],
'nego_price' => ['nullable', 'integer'], 'nego_price' => ['nullable', 'integer'],
'is_completed' => ['sometimes', 'boolean'], 'is_completed' => ['sometimes', 'boolean'],
@ -42,7 +41,7 @@ public function rules(): array
'tiktok_order_id' => ['nullable', 'string', 'max:100'], 'tiktok_order_id' => ['nullable', 'string', 'max:100'],
'shopee_order_id' => ['nullable', 'string', 'max:100'], 'shopee_order_id' => ['nullable', 'string', 'max:100'],
'items' => ['required', 'array', 'min:1'], 'items' => ['required', 'array', 'min:1'],
'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'], 'items.*.product_variant_id' => ['required', 'integer', Rule::exists('product_variants', 'id')],
'items.*.quantity' => ['required', 'integer', 'min:1'], 'items.*.quantity' => ['required', 'integer', 'min:1'],
'notes' => ['nullable', 'string', 'max:100'], 'notes' => ['nullable', 'string', 'max:100'],
'photo_key' => [ 'photo_key' => [

View File

@ -9,7 +9,8 @@ class CategoryRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('categories.create')
|| $this->user()->can('categories.update');
} }
public function rules(): array public function rules(): array
@ -17,7 +18,7 @@ public function rules(): array
$category = $this->route('category'); $category = $this->route('category');
return [ return [
'name' => ['required', 'string', 'max:100', Rule::unique('categories', 'name')->ignore($category)], 'name' => ['required', 'string', 'max:50', Rule::unique('categories', 'name')->ignore($category)],
]; ];
} }

View File

@ -9,7 +9,8 @@ class CustomerRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('customers.create')
|| $this->user()->can('customers.update');
} }
public function rules(): array public function rules(): array

View File

@ -4,6 +4,7 @@
use App\Concerns\CurrencyStripping; use App\Concerns\CurrencyStripping;
use App\Enums\PriceType; use App\Enums\PriceType;
use App\Enums\ProductStatus;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Override; use Override;
@ -14,7 +15,8 @@ class ProductRequest extends FormRequest
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('products.create')
|| $this->user()->can('products.update');
} }
#[Override] #[Override]
@ -34,13 +36,13 @@ public function rules(): array
'max:200', 'max:200',
], ],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'status' => ['nullable', Rule::in(['active', 'inactive', 'draft'])], 'status' => ['nullable', Rule::in(ProductStatus::values())],
'category_ids' => ['required', 'array', 'min:1'], 'category_ids' => ['required', 'array', 'min:1'],
'category_ids.*' => ['exists:categories,id'], 'category_ids.*' => [Rule::exists('categories', 'id')],
'use_same_price' => ['nullable', 'boolean'], 'use_same_price' => ['nullable', 'boolean'],
'shared_prices' => ['required_if:use_same_price,true', 'nullable', 'array', ...($useSamePrice ? ['size:9'] : [])], 'shared_prices' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', 'array', ...($useSamePrice ? ['size:9'] : [])],
'shared_prices.*.type' => ['required_if:use_same_price,true', 'nullable', Rule::in(PriceType::values())], 'shared_prices.*.type' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', Rule::in(PriceType::values())],
'shared_prices.*.price' => ['required_if:use_same_price,true', 'nullable', 'integer', 'min:0'], 'shared_prices.*.price' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', 'integer', 'min:0'],
'variants' => ['required', 'array', 'min:1'], 'variants' => ['required', 'array', 'min:1'],
'variants.*.id' => ['nullable', 'integer'], 'variants.*.id' => ['nullable', 'integer'],
'variants.*.name' => ['required', 'string', 'max:200'], 'variants.*.name' => ['required', 'string', 'max:200'],
@ -49,10 +51,10 @@ public function rules(): array
'variants.*.retail_stock' => ['required', 'integer', 'min:0'], 'variants.*.retail_stock' => ['required', 'integer', 'min:0'],
'variants.*.photo_keys' => ['required', 'array', 'min:1', 'max:5'], 'variants.*.photo_keys' => ['required', 'array', 'min:1', 'max:5'],
'variants.*.photo_keys.*' => ['required', 'string', 'max:500'], 'variants.*.photo_keys.*' => ['required', 'string', 'max:500'],
'variants.*.prices' => ['required_if:use_same_price,false', 'nullable', 'array', ...(! $useSamePrice ? ['size:9'] : [])], 'variants.*.prices' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', 'array', ...(! $useSamePrice ? ['size:9'] : [])],
'variants.*.prices.*.id' => ['nullable', 'integer'], 'variants.*.prices.*.id' => ['nullable', 'integer'],
'variants.*.prices.*.type' => ['required_if:use_same_price,false', 'nullable', Rule::in(PriceType::values())], 'variants.*.prices.*.type' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', Rule::in(PriceType::values())],
'variants.*.prices.*.price' => ['required_if:use_same_price,false', 'nullable', 'integer', 'min:0'], 'variants.*.prices.*.price' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', 'integer', 'min:0'],
]; ];
} }

View File

@ -13,7 +13,8 @@ class ProductVariantRequest extends FormRequest
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('products.create')
|| $this->user()->can('products.update');
} }
public function prepareForValidation(): void public function prepareForValidation(): void

View File

@ -8,7 +8,7 @@ class TransferStockRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('products.transfer_stock');
} }
public function rules(): array public function rules(): array

View File

@ -13,7 +13,8 @@ class RawMaterialRequest extends FormRequest
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('raw_materials.create')
|| $this->user()->can('raw_materials.update');
} }
public function prepareForValidation(): void public function prepareForValidation(): void

View File

@ -11,7 +11,8 @@ class RawMaterialVariantRequest extends FormRequest
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('raw_materials.create')
|| $this->user()->can('raw_materials.update');
} }
public function prepareForValidation(): void public function prepareForValidation(): void

View File

@ -9,7 +9,8 @@ class SupplierRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('suppliers.create')
|| $this->user()->can('suppliers.update');
} }
public function rules(): array public function rules(): array

View File

@ -9,7 +9,8 @@ class RoleRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('roles.create')
|| $this->user()->can('roles.update');
} }
public function rules(): array public function rules(): array
@ -24,7 +25,7 @@ public function rules(): array
Rule::unique('roles', 'name')->ignore($roleId, 'id'), Rule::unique('roles', 'name')->ignore($roleId, 'id'),
], ],
'permissions' => ['present', 'array'], 'permissions' => ['present', 'array'],
'permissions.*' => ['string', 'exists:permissions,name'], 'permissions.*' => ['string', Rule::exists('permissions', 'name')],
]; ];
} }

View File

@ -12,7 +12,7 @@ class UpdateHRRequest extends FormRequest
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('settings.update_hr');
} }
#[Override] #[Override]

View File

@ -8,7 +8,7 @@ class UpdateHomepageRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('settings.update_homepage');
} }
public function rules(): array public function rules(): array

View File

@ -8,7 +8,7 @@ class UpdateMarketplaceRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('settings.update_marketplace');
} }
private function feeRuleRules(): array private function feeRuleRules(): array

View File

@ -8,7 +8,7 @@ class UpdateSocialMediaRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('settings.update_social_media');
} }
public function rules(): array public function rules(): array

View File

@ -8,7 +8,7 @@ class UpdateSystemRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()->can('settings.update_system');
} }
public function rules(): array public function rules(): array

View File

@ -27,7 +27,7 @@ public function rules(): array
'required', 'required',
'string', 'string',
'email', 'email',
'max:255', 'max:100',
Rule::unique('users', 'email')->ignore($userId), Rule::unique('users', 'email')->ignore($userId),
], ],
'username' => [ 'username' => [

View File

@ -2,11 +2,14 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Appends(['formatted_read_at'])]
#[Guarded(['id'])] #[Guarded(['id'])]
class AppNotification extends Model class AppNotification extends Model
{ {
@ -22,6 +25,13 @@ protected function casts(): array
]; ];
} }
protected function formattedReadAt(): Attribute
{
return Attribute::make(
get: fn () => $this->read_at?->translatedFormat('l, d F Y H:i'),
);
}
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -11,6 +12,7 @@
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\InteractsWithMedia;
#[Appends(['formatted_attendance_date', 'formatted_check_in_at', 'formatted_check_out_at'])]
#[Guarded(['id'])] #[Guarded(['id'])]
class Attendance extends Model implements HasMedia class Attendance extends Model implements HasMedia
{ {
@ -33,7 +35,21 @@ protected function casts(): array
protected function formattedAttendanceDate(): Attribute protected function formattedAttendanceDate(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn ($value) => $value ? \Carbon\Carbon::parse($value)->translatedFormat('l, d F Y') : null, get: fn () => $this->attendance_date?->translatedFormat('l, d F Y'),
);
}
protected function formattedCheckInAt(): Attribute
{
return Attribute::make(
get: fn () => $this->check_in_at?->translatedFormat('l, d F Y H:i'),
);
}
protected function formattedCheckOutAt(): Attribute
{
return Attribute::make(
get: fn () => $this->check_out_at?->translatedFormat('l, d F Y H:i'),
); );
} }

View File

@ -2,12 +2,15 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
#[Appends(['formatted_material_result', 'formatted_material_usage'])]
#[Guarded(['id'])] #[Guarded(['id'])]
class CuttingMaterial extends Model class CuttingMaterial extends Model
{ {
@ -21,6 +24,20 @@ protected function casts(): array
]; ];
} }
protected function formattedMaterialResult(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->material_result, 0, ',', '.'),
);
}
protected function formattedMaterialUsage(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->material_usage, 0, ',', '.'),
);
}
public function combination(): BelongsTo public function combination(): BelongsTo
{ {
return $this->belongsTo(CuttingMaterialCombination::class, 'combination_id'); return $this->belongsTo(CuttingMaterialCombination::class, 'combination_id');

View File

@ -2,13 +2,16 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
#[Appends(['formatted_material_result'])]
#[Guarded(['id'])] #[Guarded(['id'])]
class CuttingMaterialCombination extends Model class CuttingMaterialCombination extends Model
{ {
@ -21,6 +24,13 @@ protected function casts(): array
]; ];
} }
protected function formattedMaterialResult(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->material_result, 0, ',', '.'),
);
}
public function cutting(): BelongsTo public function cutting(): BelongsTo
{ {
return $this->belongsTo(Cutting::class); return $this->belongsTo(Cutting::class);

View File

@ -2,12 +2,15 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
#[Appends(['formatted_cutting_result', 'formatted_original_outside_sample', 'formatted_sample'])]
#[Guarded(['id'])] #[Guarded(['id'])]
class CuttingResult extends Model class CuttingResult extends Model
{ {
@ -22,6 +25,27 @@ protected function casts(): array
]; ];
} }
protected function formattedCuttingResult(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->cutting_result, 0, ',', '.'),
);
}
protected function formattedOriginalOutsideSample(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->original_outside_sample, 0, ',', '.'),
);
}
protected function formattedSample(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->sample, 0, ',', '.'),
);
}
public function cutting(): BelongsTo public function cutting(): BelongsTo
{ {
return $this->belongsTo(Cutting::class); return $this->belongsTo(Cutting::class);

View File

@ -70,7 +70,7 @@ protected function formattedRemainingAmount(): Attribute
protected function statusLabel(): Attribute protected function statusLabel(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn () => $this->status->label(), get: fn () => $this->status?->label(),
); );
} }

View File

@ -33,7 +33,7 @@ protected function formattedAmount(): Attribute
protected function formattedPaidAt(): Attribute protected function formattedPaidAt(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn () => $this->paid_at?->translatedFormat('l, d F Y'), get: fn () => $this->paid_at?->translatedFormat('l, d F Y H:i'),
); );
} }

View File

@ -45,7 +45,7 @@ protected function casts(): array
protected function channelLabel(): Attribute protected function channelLabel(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn () => $this->channel->label(), get: fn () => $this->channel?->label(),
); );
} }
@ -73,14 +73,14 @@ protected function formattedNegoPrice(): Attribute
protected function paymentTypeLabel(): Attribute protected function paymentTypeLabel(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn () => $this->payment_type->label(), get: fn () => $this->payment_type?->label(),
); );
} }
protected function statusLabel(): Attribute protected function statusLabel(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn () => $this->status->label(), get: fn () => $this->status?->label(),
); );
} }

View File

@ -1,38 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Guarded(['id'])]
class OwnerVerificationRequest extends Model
{
use HasFactory;
protected function casts(): array
{
return [
'payload' => 'array',
'verified_at' => 'datetime',
];
}
public function subject(): MorphTo
{
return $this->morphTo();
}
public function submittedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'submitted_by_id');
}
public function verifiedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'verified_by_id');
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Enums\PayrollPeriodStatus; use App\Enums\PayrollPeriodStatus;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Attributes\Scope;
@ -14,7 +15,7 @@
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
#[Appends(['status_label'])] #[Appends(['formatted_closed_at', 'month_name', 'status_label'])]
#[Guarded(['id'])] #[Guarded(['id'])]
class PayrollPeriod extends Model class PayrollPeriod extends Model
{ {
@ -30,6 +31,20 @@ protected function casts(): array
]; ];
} }
protected function formattedClosedAt(): Attribute
{
return Attribute::make(
get: fn () => $this->closed_at?->translatedFormat('l, d F Y H:i'),
);
}
protected function monthName(): Attribute
{
return Attribute::make(
get: fn () => $this->month ? Carbon::create()->month($this->month)->translatedFormat('F') : null,
);
}
protected function statusLabel(): Attribute protected function statusLabel(): Attribute
{ {
return Attribute::make( return Attribute::make(

View File

@ -61,6 +61,18 @@ protected function inactive(Builder $query): void
$query->where('status', ProductStatus::INACTIVE); $query->where('status', ProductStatus::INACTIVE);
} }
#[Scope]
protected function pending(Builder $query): void
{
$query->where('status', ProductStatus::PENDING);
}
#[Scope]
protected function rejected(Builder $query): void
{
$query->where('status', ProductStatus::REJECTED);
}
public function categories(): BelongsToMany public function categories(): BelongsToMany
{ {
return $this->belongsToMany(Category::class, 'product_categories') return $this->belongsToMany(Category::class, 'product_categories')

View File

@ -2,11 +2,14 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Appends(['formatted_quantity', 'formatted_retail_stock_before', 'formatted_retail_stock_after', 'formatted_stock_before', 'formatted_stock_after'])]
#[Guarded(['id'])] #[Guarded(['id'])]
class RetailStockHistory extends Model class RetailStockHistory extends Model
{ {
@ -25,6 +28,41 @@ protected function casts(): array
]; ];
} }
protected function formattedQuantity(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->quantity, 0, ',', '.'),
);
}
protected function formattedRetailStockBefore(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->retail_stock_before, 0, ',', '.'),
);
}
protected function formattedRetailStockAfter(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->retail_stock_after, 0, ',', '.'),
);
}
protected function formattedStockBefore(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->stock_before, 0, ',', '.'),
);
}
protected function formattedStockAfter(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->stock_after, 0, ',', '.'),
);
}
public function productVariant(): BelongsTo public function productVariant(): BelongsTo
{ {
return $this->belongsTo(ProductVariant::class); return $this->belongsTo(ProductVariant::class);

View File

@ -2,12 +2,15 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Appends(['formatted_quantity', 'formatted_stock_after', 'formatted_stock_before'])]
#[Guarded(['id'])] #[Guarded(['id'])]
class StockMutation extends Model class StockMutation extends Model
{ {
@ -22,6 +25,27 @@ protected function casts(): array
]; ];
} }
protected function formattedQuantity(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->quantity, 0, ',', '.'),
);
}
protected function formattedStockAfter(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->stock_after, 0, ',', '.'),
);
}
protected function formattedStockBefore(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->stock_before, 0, ',', '.'),
);
}
public function source(): MorphTo public function source(): MorphTo
{ {
return $this->morphTo(); return $this->morphTo();

View File

@ -156,16 +156,6 @@ public function orderItems(): HasMany
return $this->hasMany(OrderItem::class); return $this->hasMany(OrderItem::class);
} }
public function ownerVerificationRequestsSubmitted(): HasMany
{
return $this->hasMany(OwnerVerificationRequest::class, 'submitted_by_id');
}
public function ownerVerificationRequestsVerified(): HasMany
{
return $this->hasMany(OwnerVerificationRequest::class, 'verified_by_id');
}
public function paidPayrolls(): HasMany public function paidPayrolls(): HasMany
{ {
return $this->hasMany(Payroll::class, 'paid_by_id'); return $this->hasMany(Payroll::class, 'paid_by_id');

View File

@ -24,7 +24,6 @@ public function migrate(): array
return $row; return $row;
}); });
$results['homepage_configurations'] = $this->migrateTable('homepage_configurations'); $results['homepage_configurations'] = $this->migrateTable('homepage_configurations');
$results['owner_verification_requests'] = $this->migrateTable('owner_verification_requests');
$results['notifications'] = $this->migrateTable('notifications'); $results['notifications'] = $this->migrateTable('notifications');
return $results; return $results;

View File

@ -46,15 +46,6 @@ public function getHomepageData(): array
]; ];
} }
private function getTemporaryUrl(string $key): string
{
if (str_starts_with($key, 'http')) {
return $key;
}
return $this->s3Service->getTemporaryUrl($key, 60);
}
public function getSocialMediaData(): array public function getSocialMediaData(): array
{ {
$settings = app(SocialMediaSettings::class); $settings = app(SocialMediaSettings::class);
@ -167,4 +158,13 @@ public function updateHR(array $data): void
$settings->fill($data); $settings->fill($data);
$settings->save(); $settings->save();
} }
private function getTemporaryUrl(string $key): string
{
if (str_starts_with($key, 'http')) {
return $key;
}
return $this->s3Service->getTemporaryUrl($key, 60);
}
} }

View File

@ -0,0 +1,13 @@
<?php
namespace App\Services\Admin\Finance\Cash;
use App\Models\CashAccount;
class CashAccountService
{
public function get(): ?CashAccount
{
return CashAccount::select(['id', 'name', 'balance'])->first();
}
}

View File

@ -1,8 +1,9 @@
<?php <?php
namespace App\Services\Admin\Finance; namespace App\Services\Admin\Finance\Cash;
use App\Enums\CashTransactionType; use App\Enums\CashTransactionType;
use App\Enums\Role;
use App\Models\CashAccount; use App\Models\CashAccount;
use App\Models\CashTransaction; use App\Models\CashTransaction;
use App\Services\Concerns\HandlesCashTransactions; use App\Services\Concerns\HandlesCashTransactions;
@ -11,45 +12,22 @@
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class CashAccountService class CashTransactionService
{ {
use HandlesCashTransactions, RegistersMedia; use HandlesCashTransactions, RegistersMedia;
public function __construct( public function __construct(
private S3PresignedService $s3Service, private S3PresignedService $s3Service,
private CashAccountService $cashAccountService
) {} ) {}
public function get(): ?CashAccount public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
return CashAccount::select(['id', 'name', 'balance'])->first(); $cashAccount = $this->cashAccountService->get();
}
public function getAllTransactions(array $filters = []): Collection
{
$cashAccount = $this->get();
if (! $cashAccount) {
return collect();
}
return $cashAccount->cashTransactions()
->select(['id', 'created_by_id', 'reference_type', 'reference_id', 'amount', 'balance_after', 'type', 'description', 'created_at'])
->with('createdBy.userProfile', 'media')
->when($filters['type'] ?? null, function ($query, $type) {
$query->where('type', $type);
})
->latest()
->get()
->map(fn (CashTransaction $transaction) => $this->formatTransaction($transaction));
}
public function paginatedTransactions(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
$cashAccount = $this->get();
if (! $cashAccount) { if (! $cashAccount) {
return new PaginationLengthAwarePaginator(collect(), 0, $perPage); return new PaginationLengthAwarePaginator(collect(), 0, $perPage);
@ -70,29 +48,6 @@ public function paginatedTransactions(int $perPage = 25, string $search = '', st
return $paginator; return $paginator;
} }
private function formatTransaction(CashTransaction $transaction): array
{
$media = $transaction->getFirstMedia('receipts');
if (! $media) {
return $transaction->toArray() + [
'receipt_key' => null,
'receipt_url' => null,
];
}
$s3Key = $media->file_name;
if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) {
$s3Key = $media->getPath();
}
return $transaction->toArray() + [
'receipt_key' => $s3Key,
'receipt_url' => $this->s3Service->getTemporaryUrl($s3Key),
];
}
public function deposit(array $data): CashTransaction public function deposit(array $data): CashTransaction
{ {
$transaction = DB::transaction(fn () => $this->creditCash( $transaction = DB::transaction(fn () => $this->creditCash(
@ -100,12 +55,12 @@ public function deposit(array $data): CashTransaction
description: $data['description'], description: $data['description'],
)); ));
if (! empty($data['receipt_key'])) { if (array_key_exists('receipt_key', $data)) {
$this->registerMedia($transaction, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null); $this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
} }
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Setoran Kas Toko', title: 'Setoran Kas Toko',
body: 'Setoran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.', body: 'Setoran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'), url: route('admin.finance.cash-accounts.index'),
@ -122,12 +77,12 @@ public function withdrawal(array $data): CashTransaction
type: CashTransactionType::WITHDRAWAL, type: CashTransactionType::WITHDRAWAL,
)); ));
if (! empty($data['receipt_key'])) { if (array_key_exists('receipt_key', $data)) {
$this->registerMedia($transaction, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null); $this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
} }
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Penarikan Kas Toko', title: 'Penarikan Kas Toko',
body: 'Penarikan sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.', body: 'Penarikan sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'), url: route('admin.finance.cash-accounts.index'),
@ -136,7 +91,7 @@ public function withdrawal(array $data): CashTransaction
return $transaction; return $transaction;
} }
public function updateTransaction(CashTransaction $transaction, array $data): CashTransaction public function update(CashTransaction $transaction, array $data): CashTransaction
{ {
$transaction = DB::transaction(function () use ($transaction, $data) { $transaction = DB::transaction(function () use ($transaction, $data) {
if (! in_array($transaction->type, [CashTransactionType::DEPOSIT, CashTransactionType::WITHDRAWAL])) { if (! in_array($transaction->type, [CashTransactionType::DEPOSIT, CashTransactionType::WITHDRAWAL])) {
@ -168,27 +123,14 @@ public function updateTransaction(CashTransaction $transaction, array $data): Ca
]); ]);
if (array_key_exists('receipt_key', $data)) { if (array_key_exists('receipt_key', $data)) {
$currentMedia = $transaction->getFirstMedia('receipts'); $this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
$currentKey = $currentMedia?->file_name;
if ($currentMedia && ! str_contains($currentKey, '/')) {
$currentKey = $currentMedia->getPath();
}
if ($data['receipt_key'] !== $currentKey) {
$transaction->clearMediaCollection('receipts');
if (! empty($data['receipt_key'])) {
$this->registerMedia($transaction, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
}
}
} }
return $transaction; return $transaction;
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Transaksi Kas Diperbarui', title: 'Transaksi Kas Diperbarui',
body: 'Transaksi kas sebesar Rp '.number_format($transaction->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.', body: 'Transaksi kas sebesar Rp '.number_format($transaction->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'), url: route('admin.finance.cash-accounts.index'),
@ -197,7 +139,7 @@ public function updateTransaction(CashTransaction $transaction, array $data): Ca
return $transaction; return $transaction;
} }
public function deleteTransaction(CashTransaction $transaction): bool public function destroy(CashTransaction $transaction): bool
{ {
return DB::transaction(function () use ($transaction) { return DB::transaction(function () use ($transaction) {
if (! in_array($transaction->type, [CashTransactionType::DEPOSIT, CashTransactionType::WITHDRAWAL])) { if (! in_array($transaction->type, [CashTransactionType::DEPOSIT, CashTransactionType::WITHDRAWAL])) {
@ -221,13 +163,18 @@ public function deleteTransaction(CashTransaction $transaction): bool
$cashAccount->update(['balance' => $newBalance]); $cashAccount->update(['balance' => $newBalance]);
$media = $transaction->getFirstMedia('receipts');
if ($media) {
Cache::forget("cash_transaction_receipt_{$media->id}");
}
$transaction->clearMediaCollection('receipts'); $transaction->clearMediaCollection('receipts');
$deleted = $transaction->delete(); $deleted = $transaction->delete();
if ($deleted) { if ($deleted) {
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Transaksi Kas Dihapus', title: 'Transaksi Kas Dihapus',
body: 'Transaksi kas sebesar Rp '.number_format($transaction->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.', body: 'Transaksi kas sebesar Rp '.number_format($transaction->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'), url: route('admin.finance.cash-accounts.index'),
@ -237,4 +184,27 @@ public function deleteTransaction(CashTransaction $transaction): bool
return $deleted; return $deleted;
}); });
} }
private function formatTransaction(CashTransaction $transaction): array
{
$media = $transaction->getFirstMedia('receipts');
if (! $media) {
return $transaction->toArray() + [
'receipt_key' => null,
'receipt_url' => null,
];
}
$s3Key = $media->file_name;
if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) {
$s3Key = $media->getPath();
}
return $transaction->toArray() + [
'receipt_key' => $s3Key,
'receipt_url' => $this->s3Service->getTemporaryUrl($s3Key),
];
}
} }

View File

@ -2,48 +2,35 @@
namespace App\Services\Admin\Finance; namespace App\Services\Admin\Finance;
use App\Concerns\HasRoleChecks;
use App\Enums\CashTransactionType; use App\Enums\CashTransactionType;
use App\Enums\EmployeeAdvanceStatus; use App\Enums\EmployeeAdvanceStatus;
use App\Enums\Role;
use App\Models\EmployeeAdvance; use App\Models\EmployeeAdvance;
use App\Models\EmployeeAdvancePayment; use App\Models\EmployeeAdvancePayment;
use App\Services\Concerns\HandlesCashTransactions; use App\Services\Concerns\HandlesCashTransactions;
use App\Services\NotificationService; use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class EmployeeAdvanceService class EmployeeAdvanceService
{ {
use HandlesCashTransactions; use HandlesCashTransactions, HasRoleChecks;
private function canViewAll(): bool
{
return auth()->user()->hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']);
}
public function getAll(array $filters = []): Collection
{
return EmployeeAdvance::select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at'])
->with(['employee.user.userProfile', 'payments.paidBy.userProfile'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->latest()
->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
return EmployeeAdvance::query() return EmployeeAdvance::query()
->select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at']) ->select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at'])
->with(['employee.user.userProfile', 'payments.paidBy.userProfile']) ->with(['employee.user.userProfile', 'payments.paidBy.userProfile'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))) ->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->when($filters['status'] ?? null, fn ($q) => $q->where('status', $filters['status'])) ->when($filters['status'] ?? null, fn ($q) => $q->where('status', $filters['status']))
->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%"))
->orderBy($sort, $direction) ->orderBy($sort, $direction)
->paginate($perPage); ->paginate($perPage);
} }
public function create(array $data): EmployeeAdvance public function store(array $data): EmployeeAdvance
{ {
$employee = auth()->user()->employee; $employee = auth()->user()->employee;
@ -64,7 +51,7 @@ public function create(array $data): EmployeeAdvance
$employeeAdvance->load('employee.user'); $employeeAdvance->load('employee.user');
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Kasbon Baru', title: 'Kasbon Baru',
body: 'Kasbon sebesar Rp '.number_format($data['amount'], 0, ',', '.')." dari {$employeeAdvance->employee->name} menunggu persetujuan".' oleh '.auth()->user()->full_name.'.', body: 'Kasbon sebesar Rp '.number_format($data['amount'], 0, ',', '.')." dari {$employeeAdvance->employee->name} menunggu persetujuan".' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.employee-advances.index'), url: route('admin.finance.employee-advances.index'),
@ -123,7 +110,7 @@ public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeA
return $employeeAdvance; return $employeeAdvance;
} }
public function delete(EmployeeAdvance $employeeAdvance): bool public function destroy(EmployeeAdvance $employeeAdvance): bool
{ {
return DB::transaction(function () use ($employeeAdvance) { return DB::transaction(function () use ($employeeAdvance) {
if ($employeeAdvance->status === EmployeeAdvanceStatus::APPROVED && $employeeAdvance->cash_transaction_id) { if ($employeeAdvance->status === EmployeeAdvanceStatus::APPROVED && $employeeAdvance->cash_transaction_id) {
@ -181,7 +168,7 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Kasbon Disetujui', title: 'Kasbon Disetujui',
body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah disetujui'.' oleh '.auth()->user()->full_name.'.', body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah disetujui'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.employee-advances.index'), url: route('admin.finance.employee-advances.index'),
@ -234,7 +221,7 @@ public function pay(EmployeeAdvance $employeeAdvance, int $amount): EmployeeAdva
: 'Pembayaran kasbon sebesar Rp '.number_format($amount, 0, ',', '.').' oleh '.auth()->user()->full_name.'. Sisa: Rp '.number_format($employeeAdvance->amount - $employeeAdvance->paid_amount, 0, ',', '.').'.'; : 'Pembayaran kasbon sebesar Rp '.number_format($amount, 0, ',', '.').' oleh '.auth()->user()->full_name.'. Sisa: Rp '.number_format($employeeAdvance->amount - $employeeAdvance->paid_amount, 0, ',', '.').'.';
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: $employeeAdvance->status === EmployeeAdvanceStatus::PAID ? 'Kasbon Dibayar' : 'Pembayaran Kasbon', title: $employeeAdvance->status === EmployeeAdvanceStatus::PAID ? 'Kasbon Dibayar' : 'Pembayaran Kasbon',
body: $notificationBody, body: $notificationBody,
url: route('admin.finance.employee-advances.index'), url: route('admin.finance.employee-advances.index'),

View File

@ -3,6 +3,7 @@
namespace App\Services\Admin\Finance; namespace App\Services\Admin\Finance;
use App\Enums\CashTransactionType; use App\Enums\CashTransactionType;
use App\Enums\Role;
use App\Models\CashAccount; use App\Models\CashAccount;
use App\Models\Expense; use App\Models\Expense;
use App\Services\Concerns\HandlesCashTransactions; use App\Services\Concerns\HandlesCashTransactions;
@ -10,7 +11,6 @@
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -23,15 +23,6 @@ public function __construct(
private S3PresignedService $s3Service, private S3PresignedService $s3Service,
) {} ) {}
public function getAll(array $filters = []): Collection
{
return Expense::select(['id', 'created_by_id', 'amount', 'description', 'created_at'])
->with('createdBy.userProfile', 'media')
->latest()
->get()
->map(fn (Expense $expense) => $this->formatExpense($expense));
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
$paginator = Expense::query() $paginator = Expense::query()
@ -46,36 +37,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator; return $paginator;
} }
private function formatExpense(Expense $expense): array public function store(array $data): Expense
{
$media = $expense->getFirstMedia('receipts');
if (! $media) {
return $expense->toArray() + [
'receipt_key' => null,
'receipt_url' => null,
];
}
$s3Key = $media->file_name;
// Handle old data where file_name is just the filename, not full path
if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) {
$s3Key = $media->getPath();
}
$cacheKey = "expense_receipt_{$media->id}";
$receiptUrl = Cache::remember($cacheKey, now()->addMinutes(55), function () use ($s3Key) {
return $this->s3Service->getTemporaryUrl($s3Key);
});
return $expense->toArray() + [
'receipt_key' => $s3Key,
'receipt_url' => $receiptUrl,
];
}
public function create(array $data): Expense
{ {
$expense = DB::transaction(function () use ($data) { $expense = DB::transaction(function () use ($data) {
$cashTransaction = $this->debitCash( $cashTransaction = $this->debitCash(
@ -90,15 +52,15 @@ public function create(array $data): Expense
'description' => $data['description'], 'description' => $data['description'],
]); ]);
if (! empty($data['receipt_key'])) { if (array_key_exists('receipt_key', $data)) {
$this->registerMedia($expense, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null); $this->syncReceipt($expense, $data['receipt_key'] ?? null, 'receipts', 'expense_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
} }
return $expense; return $expense;
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Pengeluaran Baru', title: 'Pengeluaran Baru',
body: 'Pengeluaran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.', body: 'Pengeluaran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.expenses.index'), url: route('admin.finance.expenses.index'),
@ -138,33 +100,14 @@ public function update(Expense $expense, array $data): Expense
]); ]);
if (array_key_exists('receipt_key', $data)) { if (array_key_exists('receipt_key', $data)) {
$currentMedia = $expense->getFirstMedia('receipts'); $this->syncReceipt($expense, $data['receipt_key'] ?? null, 'receipts', 'expense_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
$currentKey = $currentMedia?->file_name;
// Normalize: if file_name is just a filename (old data), use getPath()
if ($currentMedia && ! str_contains($currentKey, '/')) {
$currentKey = $currentMedia->getPath();
}
if ($data['receipt_key'] !== $currentKey) {
// Invalidate old receipt cache
if ($currentMedia) {
Cache::forget("expense_receipt_{$currentMedia->id}");
}
$expense->clearMediaCollection('receipts');
if (! empty($data['receipt_key'])) {
$this->registerMedia($expense, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
}
}
} }
return $expense; return $expense;
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Pengeluaran Diperbarui', title: 'Pengeluaran Diperbarui',
body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.', body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.expenses.index'), url: route('admin.finance.expenses.index'),
@ -173,7 +116,7 @@ public function update(Expense $expense, array $data): Expense
return $expense; return $expense;
} }
public function delete(Expense $expense): bool public function destroy(Expense $expense): bool
{ {
return DB::transaction(function () use ($expense) { return DB::transaction(function () use ($expense) {
$this->creditCash( $this->creditCash(
@ -182,7 +125,6 @@ public function delete(Expense $expense): bool
type: CashTransactionType::DEPOSIT, type: CashTransactionType::DEPOSIT,
); );
// Invalidate receipt cache
$media = $expense->getFirstMedia('receipts'); $media = $expense->getFirstMedia('receipts');
if ($media) { if ($media) {
Cache::forget("expense_receipt_{$media->id}"); Cache::forget("expense_receipt_{$media->id}");
@ -194,7 +136,7 @@ public function delete(Expense $expense): bool
if ($deleted) { if ($deleted) {
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Pengeluaran Dihapus', title: 'Pengeluaran Dihapus',
body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.', body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.expenses.index'), url: route('admin.finance.expenses.index'),
@ -204,4 +146,32 @@ public function delete(Expense $expense): bool
return $deleted; return $deleted;
}); });
} }
private function formatExpense(Expense $expense): array
{
$media = $expense->getFirstMedia('receipts');
if (! $media) {
return $expense->toArray() + [
'receipt_key' => null,
'receipt_url' => null,
];
}
$s3Key = $media->file_name;
if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) {
$s3Key = $media->getPath();
}
$cacheKey = "expense_receipt_{$media->id}";
$receiptUrl = Cache::remember($cacheKey, now()->addMinutes(55), function () use ($s3Key) {
return $this->s3Service->getTemporaryUrl($s3Key);
});
return $expense->toArray() + [
'receipt_key' => $s3Key,
'receipt_url' => $receiptUrl,
];
}
} }

View File

@ -1,6 +1,6 @@
<?php <?php
namespace App\Services\Admin\Finance; namespace App\Services\Admin\Finance\Payroll;
use App\Enums\PayrollAdjustmentType; use App\Enums\PayrollAdjustmentType;
use App\Enums\PayrollStatus; use App\Enums\PayrollStatus;
@ -11,7 +11,7 @@
class PayrollAdjustmentService class PayrollAdjustmentService
{ {
public function create(Payroll $payroll, array $data): PayrollAdjustment public function store(Payroll $payroll, array $data): PayrollAdjustment
{ {
if ($payroll->status !== PayrollStatus::UNPAID) { if ($payroll->status !== PayrollStatus::UNPAID) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@ -35,7 +35,7 @@ public function create(Payroll $payroll, array $data): PayrollAdjustment
}); });
} }
public function delete(PayrollAdjustment $adjustment): bool public function destroy(PayrollAdjustment $adjustment): bool
{ {
$payroll = $adjustment->payroll; $payroll = $adjustment->payroll;

View File

@ -1,57 +1,29 @@
<?php <?php
namespace App\Services\Admin\Finance; namespace App\Services\Admin\Finance\Payroll;
use App\Concerns\HasRoleChecks;
use App\Enums\CashTransactionType; use App\Enums\CashTransactionType;
use App\Enums\PayrollPeriodStatus; use App\Enums\PayrollPeriodStatus;
use App\Enums\PayrollStatus; use App\Enums\PayrollStatus;
use App\Enums\Role;
use App\Models\Payroll; use App\Models\Payroll;
use App\Models\PayrollPeriod; use App\Models\PayrollPeriod;
use App\Services\Concerns\HandlesCashTransactions; use App\Services\Concerns\HandlesCashTransactions;
use App\Services\NotificationService; use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class PayrollPeriodService class PayrollPeriodService
{ {
use HandlesCashTransactions; use HandlesCashTransactions, HasRoleChecks;
private function canViewAll(): bool
{
return auth()->user()->hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']);
}
public function getAll(array $filters = []): Collection
{
return PayrollPeriod::select(['id', 'year', 'month', 'status', 'closed_at', 'created_at'])
->when(! $this->canViewAll(), function ($query) {
$query->withCount(['payrolls as payrolls_count' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))])
->withSum(['payrolls as payrolls_sum_total_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'total_amount')
->withSum(['payrolls as payrolls_sum_bonus_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'bonus_amount')
->withSum(['payrolls as payrolls_sum_deduction_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'deduction_amount')
->withCount(['payrolls as paid_count' => fn ($q) => $q->paid()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))])
->withCount(['payrolls as cancelled_count' => fn ($q) => $q->cancelled()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))]);
})
->when($this->canViewAll(), function ($query) {
$query->withCount('payrolls')
->withSum('payrolls', 'total_amount')
->withSum('payrolls', 'bonus_amount')
->withSum('payrolls', 'deduction_amount')
->withCount(['payrolls as paid_count' => fn ($q) => $q->paid()])
->withCount(['payrolls as cancelled_count' => fn ($q) => $q->cancelled()]);
})
->latest('year')
->latest('month')
->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
return PayrollPeriod::query() return PayrollPeriod::query()
->select(['id', 'year', 'month', 'status', 'closed_at', 'created_at']) ->select(['id', 'year', 'month', 'status', 'closed_at', 'created_at'])
->when(! $this->canViewAll(), function ($query) { ->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), function ($query) {
$query->withCount(['payrolls as payrolls_count' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))]) $query->withCount(['payrolls as payrolls_count' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))])
->withSum(['payrolls as payrolls_sum_total_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'total_amount') ->withSum(['payrolls as payrolls_sum_total_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'total_amount')
->withSum(['payrolls as payrolls_sum_bonus_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'bonus_amount') ->withSum(['payrolls as payrolls_sum_bonus_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'bonus_amount')
@ -59,7 +31,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->withCount(['payrolls as paid_count' => fn ($q) => $q->paid()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))]) ->withCount(['payrolls as paid_count' => fn ($q) => $q->paid()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))])
->withCount(['payrolls as cancelled_count' => fn ($q) => $q->cancelled()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))]); ->withCount(['payrolls as cancelled_count' => fn ($q) => $q->cancelled()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))]);
}) })
->when($this->canViewAll(), function ($query) { ->when(self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), function ($query) {
$query->withCount('payrolls') $query->withCount('payrolls')
->withSum('payrolls', 'total_amount') ->withSum('payrolls', 'total_amount')
->withSum('payrolls', 'bonus_amount') ->withSum('payrolls', 'bonus_amount')
@ -72,15 +44,14 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage); ->paginate($perPage);
} }
public function getDetail(PayrollPeriod $period): PayrollPeriod public function getCurrentOrCreate(): PayrollPeriod
{ {
return $period->load([ $now = now();
'payrolls' => function ($query) {
$query->with(['employee.user.userProfile', 'payrollAdjustments']) return PayrollPeriod::firstOrCreate(
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))) ['year' => $now->year, 'month' => $now->month],
->orderBy('id'); ['status' => PayrollPeriodStatus::OPEN]
}, );
]);
} }
public function close(PayrollPeriod $period): PayrollPeriod public function close(PayrollPeriod $period): PayrollPeriod
@ -161,7 +132,7 @@ public function pay(Payroll $payroll): Payroll
$employeeUser = $payroll->employee->user ?? null; $employeeUser = $payroll->employee->user ?? null;
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Gaji Dibayar', title: 'Gaji Dibayar',
body: "Gaji karyawan {$payroll->employee->name} sebesar {$payroll->formatted_amount} telah dibayar".' oleh '.auth()->user()->full_name.'.', body: "Gaji karyawan {$payroll->employee->name} sebesar {$payroll->formatted_amount} telah dibayar".' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.payroll-periods.index'), url: route('admin.finance.payroll-periods.index'),
@ -192,7 +163,7 @@ public function cancel(Payroll $payroll): Payroll
$employeeUser = $payroll->employee->user ?? null; $employeeUser = $payroll->employee->user ?? null;
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Gaji Dibatalkan', title: 'Gaji Dibatalkan',
body: "Gaji karyawan {$payroll->employee->name} sebesar {$payroll->formatted_amount} telah dibatalkan".' oleh '.auth()->user()->full_name.'.', body: "Gaji karyawan {$payroll->employee->name} sebesar {$payroll->formatted_amount} telah dibatalkan".' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.payroll-periods.index'), url: route('admin.finance.payroll-periods.index'),

View File

@ -2,30 +2,54 @@
namespace App\Services\Admin\HR; namespace App\Services\Admin\HR;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Models\Attendance; use App\Models\Attendance;
use App\Models\Employee; use App\Models\Employee;
use App\Models\LeaveRequest; use App\Models\LeaveRequest;
use App\Models\User;
use App\Services\Concerns\RegistersMedia; use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use App\Settings\HRSettings;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\ValidationException;
class AttendanceService class AttendanceService
{ {
use RegistersMedia; use HasRoleChecks, RegistersMedia;
public function __construct( public function __construct(
private S3PresignedService $s3Service, private S3PresignedService $s3Service,
private EmployeeService $employeeService,
) {} ) {}
public function getAll(): Collection public function getIndexData(int $year, int $month): array
{ {
return Attendance::with(['employee.user.userProfile', 'media']) $user = auth()->user();
->latest('attendance_date') $isAdmin = self::hasAnyRole([Role::DEVELOPER, Role::OWNER]);
->get() $hrSettings = app(HRSettings::class);
->map(fn (Attendance $attendance) => $this->formatAttendance($attendance));
$employeeId = $isAdmin ? null : $user->employee?->id;
return [
'attendances' => $this->getByMonth($year, $month, $employeeId),
'leaves' => $this->getLeavesByMonth($year, $month, $employeeId),
'employees' => $isAdmin ? $this->employeeService->getAll() : [],
'todayAttendance' => $isAdmin ? null : $this->getToday(),
'currentYear' => $year,
'currentMonth' => $month,
'monthStats' => $this->getMonthStats($year, $month, $employeeId),
'hrSettings' => [
'scheduled_check_in_time' => $hrSettings->scheduled_check_in_time,
'scheduled_check_out_time' => $hrSettings->scheduled_check_out_time,
],
'isOnLeave' => $isAdmin ? false : $this->isOnLeave($user),
'canCheckIn' => $isAdmin ? false : $user->employee !== null,
'isAdmin' => $isAdmin,
];
} }
public function getByMonth(int $year, int $month, ?int $employeeId = null): Collection public function getByMonth(int $year, int $month, ?int $employeeId = null): Collection
@ -53,55 +77,64 @@ public function getToday(): ?array
return $this->getByDate(now()->toDateString()); return $this->getByDate(now()->toDateString());
} }
public function getAllEmployees(): Collection public function getLeavesByMonth(int $year, int $month, ?int $employeeId = null): Collection
{ {
return Employee::with(['user.userProfile', 'user.roles']) $startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth();
->whereHas('user', fn ($q) => $q->where('is_active', true)) $endOfMonth = $startOfMonth->copy()->endOfMonth();
->get();
return LeaveRequest::approved()
->with('employee.user.userProfile')
->where('start_date', '<=', $endOfMonth)
->where('end_date', '>=', $startOfMonth)
->when($employeeId, fn ($q) => $q->where('employee_id', $employeeId))
->get()
->map(fn (LeaveRequest $leave) => [
'id' => $leave->id,
'employee_id' => $leave->employee_id,
'start_date' => $leave->start_date->toDateString(),
'end_date' => $leave->end_date->toDateString(),
'total_days' => $leave->total_days,
'status' => $leave->status->value,
'employee_name' => $leave->employee?->user?->userProfile?->full_name ?? '-',
]);
} }
public function getMonthStats(int $year, int $month, ?int $employeeId = null): array public function getMonthStats(int $year, int $month, ?int $employeeId = null): array
{ {
$startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth(); $startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth();
$endOfMonth = $startOfMonth->copy()->endOfMonth(); $endOfMonth = $startOfMonth->copy()->endOfMonth();
$today = Carbon::today();
$statEnd = $endOfMonth->lte($today) ? $endOfMonth : $today;
$workingDays = 0; $workingDays = 0;
$current = $startOfMonth->copy(); $current = $startOfMonth->copy();
while ($current->lte($endOfMonth)) { while ($current->lte($statEnd)) {
if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) { if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) {
$workingDays++; $workingDays++;
} }
$current->addDay(); $current->addDay();
} }
$attendanceQuery = Attendance::whereYear('attendance_date', $year)
->whereMonth('attendance_date', $month);
if ($employeeId) { if ($employeeId) {
$attendanceQuery->where('employee_id', $employeeId); return $this->getMonthStatsForEmployee($year, $month, $startOfMonth, $statEnd, $workingDays, $employeeId);
} }
$attendanceCount = $attendanceQuery->count();
$leaveQuery = LeaveRequest::approved() return $this->getMonthStatsForAll($year, $month, $startOfMonth, $statEnd, $workingDays);
->where('start_date', '<=', $endOfMonth) }
->where('end_date', '>=', $startOfMonth);
if ($employeeId) { public function isOnLeave(User $user): bool
$leaveQuery->where('employee_id', $employeeId); {
$employee = $user->employee;
if (! $employee) {
return false;
} }
$leaveDays = $leaveQuery->get()
->reduce(function ($carry, $leave) use ($startOfMonth, $endOfMonth) {
$leaveStart = max($leave->start_date->timestamp, $startOfMonth->timestamp);
$leaveEnd = min($leave->end_date->timestamp, $endOfMonth->timestamp);
$days = Carbon::createFromTimestamp($leaveStart)->diffInDays(Carbon::createFromTimestamp($leaveEnd)) + 1;
return $carry + max(0, $days); return LeaveRequest::approved()
}, 0); ->where('employee_id', $employee->id)
->where('start_date', '<=', now()->toDateString())
return [ ->where('end_date', '>=', now()->toDateString())
'working_days' => $workingDays, ->exists();
'present' => $attendanceCount,
'absent' => max(0, $workingDays - $attendanceCount - $leaveDays),
'leave' => $leaveDays,
];
} }
public function checkIn(array $data): Attendance public function checkIn(array $data): Attendance
@ -109,7 +142,9 @@ public function checkIn(array $data): Attendance
$employee = auth()->user()->employee; $employee = auth()->user()->employee;
if (! $employee) { if (! $employee) {
throw new \Exception('Anda tidak terdaftar sebagai karyawan.'); throw ValidationException::withMessages([
'employee' => 'Anda tidak terdaftar sebagai karyawan.',
]);
} }
$today = now()->toDateString(); $today = now()->toDateString();
@ -119,7 +154,9 @@ public function checkIn(array $data): Attendance
->first(); ->first();
if ($existing) { if ($existing) {
throw new \Exception('Anda sudah melakukan presensi hari ini.'); throw ValidationException::withMessages([
'attendance' => 'Anda sudah melakukan presensi hari ini.',
]);
} }
$attendance = Attendance::create([ $attendance = Attendance::create([
@ -135,7 +172,7 @@ public function checkIn(array $data): Attendance
} }
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR],
title: 'Presensi Masuk', title: 'Presensi Masuk',
body: 'Presensi masuk oleh '.auth()->user()->full_name.'.', body: 'Presensi masuk oleh '.auth()->user()->full_name.'.',
url: route('admin.hr.attendances.index'), url: route('admin.hr.attendances.index'),
@ -157,7 +194,7 @@ public function checkOut(Attendance $attendance, array $data): Attendance
} }
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR],
title: 'Presensi Pulang', title: 'Presensi Pulang',
body: 'Presensi pulang oleh '.auth()->user()->full_name.'.', body: 'Presensi pulang oleh '.auth()->user()->full_name.'.',
url: route('admin.hr.attendances.index'), url: route('admin.hr.attendances.index'),
@ -166,6 +203,93 @@ public function checkOut(Attendance $attendance, array $data): Attendance
return $attendance; return $attendance;
} }
private function getMonthStatsForEmployee(int $year, int $month, Carbon $startOfMonth, Carbon $statEnd, int $workingDays, int $employeeId): array
{
$attendanceQuery = Attendance::whereYear('attendance_date', $year)
->whereMonth('attendance_date', $month)
->where('attendance_date', '<=', $statEnd->toDateString())
->where('employee_id', $employeeId);
$attendanceDates = $attendanceQuery->pluck('attendance_date')
->map(fn ($d) => Carbon::parse($d)->toDateString())
->filter(fn ($d) => ! in_array(Carbon::parse($d)->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY]))
->unique()
->values();
$attendanceCount = $attendanceDates->count();
$leaveQuery = LeaveRequest::approved()
->where('start_date', '<=', $statEnd)
->where('end_date', '>=', $startOfMonth)
->where('employee_id', $employeeId);
$leaveRequests = $leaveQuery->get();
$leaveCount = $leaveRequests->count();
$leaveDates = collect();
$leaveRequests->each(function ($leave) use (&$leaveDates, $startOfMonth, $statEnd) {
$leaveStart = Carbon::parse($leave->start_date)->startOfDay()->lte($startOfMonth) ? $startOfMonth->copy() : Carbon::parse($leave->start_date)->startOfDay();
$leaveEnd = Carbon::parse($leave->end_date)->startOfDay()->gte($statEnd) ? $statEnd->copy() : Carbon::parse($leave->end_date)->startOfDay();
$current = $leaveStart->copy();
while ($current->lte($leaveEnd)) {
if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) {
$leaveDates->push($current->toDateString());
}
$current->addDay();
}
});
$leaveDates = $leaveDates->unique()->values();
$coveredDates = $attendanceDates->merge($leaveDates)->unique()->count();
$absent = max(0, $workingDays - $coveredDates);
return [
'working_days' => $workingDays,
'present' => $attendanceCount,
'absent' => $absent,
'leave' => $leaveCount,
];
}
private function getMonthStatsForAll(int $year, int $month, Carbon $startOfMonth, Carbon $statEnd, int $workingDays): array
{
$totalEmployees = Employee::whereHas('user', fn ($q) => $q->where('is_active', true))->count();
$presentCount = Attendance::whereYear('attendance_date', $year)
->whereMonth('attendance_date', $month)
->where('attendance_date', '<=', $statEnd->toDateString())
->whereHas('employee', fn ($q) => $q->whereHas('user', fn ($uq) => $uq->where('is_active', true)))
->count();
$leaveRequests = LeaveRequest::approved()
->where('start_date', '<=', $statEnd)
->where('end_date', '>=', $startOfMonth)
->whereHas('employee', fn ($q) => $q->whereHas('user', fn ($uq) => $uq->where('is_active', true)))
->get();
$leaveDays = 0;
$leaveRequests->each(function ($leave) use (&$leaveDays, $startOfMonth, $statEnd) {
$leaveStart = Carbon::parse($leave->start_date)->startOfDay()->lte($startOfMonth) ? $startOfMonth->copy() : Carbon::parse($leave->start_date)->startOfDay();
$leaveEnd = Carbon::parse($leave->end_date)->startOfDay()->gte($statEnd) ? $statEnd->copy() : Carbon::parse($leave->end_date)->startOfDay();
$current = $leaveStart->copy();
while ($current->lte($leaveEnd)) {
if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) {
$leaveDays++;
}
$current->addDay();
}
});
$absent = max(0, ($totalEmployees * $workingDays) - $presentCount - $leaveDays);
return [
'working_days' => $workingDays,
'present' => $presentCount,
'absent' => $absent,
'leave' => $leaveDays,
'total_employees' => $totalEmployees,
];
}
private function formatAttendance(Attendance $attendance): array private function formatAttendance(Attendance $attendance): array
{ {
$toArray = $attendance->toArray(); $toArray = $attendance->toArray();

View File

@ -2,46 +2,17 @@
namespace App\Services\Admin\HR; namespace App\Services\Admin\HR;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Models\Employee;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class EmployeeService class EmployeeService
{ {
private const ADMIN_ROLES = ['developer', 'owner', 'direktur', 'admin-toko']; use HasRoleChecks;
private const RESTRICTED_ROLES = ['admin-toko', 'direktur'];
public function canViewAll(): bool
{
return auth()->user()->hasAnyRole(self::ADMIN_ROLES);
}
public function shouldHideAdminBahanBaku(): bool
{
return auth()->user()->hasAnyRole(self::RESTRICTED_ROLES);
}
public function getAll(array $filters = []): Collection
{
return User::select(['id', 'email', 'username', 'is_active'])
->where(fn ($q) => $q->whereHas('employee')->orWhereHas('roles', fn ($rq) => $rq->where('name', 'Owner')))
->with([
'userProfile' => fn ($q) => $q->select(['id', 'user_id', 'full_name', 'phone_number', 'gender']),
'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']),
'roles' => fn ($q) => $q->select(['id', 'name']),
])
->when(! $this->canViewAll(), function ($q) {
$userRoles = auth()->user()->roles->pluck('name');
$q->whereHas('roles', fn ($rq) => $rq->whereIn('name', $userRoles));
})
->when($this->shouldHideAdminBahanBaku(), fn ($q) => $q->whereDoesntHave('roles', fn ($rq) => $rq->where('name', 'admin-bahan-baku')))
->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status)))
->when(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN)))
->when($filters['gender'] ?? null, fn ($q, $gender) => $q->whereHas('userProfile', fn ($uq) => $uq->where('gender', $gender)))
->latest()
->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
@ -53,11 +24,11 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']), 'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']),
'roles' => fn ($q) => $q->select(['id', 'name']), 'roles' => fn ($q) => $q->select(['id', 'name']),
]) ])
->when(! $this->canViewAll(), function ($q) { ->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), function ($q) {
$userRoles = auth()->user()->roles->pluck('name'); $userRoles = auth()->user()->roles->pluck('name');
$q->whereHas('roles', fn ($rq) => $rq->whereIn('name', $userRoles)); $q->whereHas('roles', fn ($rq) => $rq->whereIn('name', $userRoles));
}) })
->when($this->shouldHideAdminBahanBaku(), fn ($q) => $q->whereDoesntHave('roles', fn ($rq) => $rq->where('name', 'admin-bahan-baku'))) ->when(self::hasAnyRole([Role::ADMIN_TOKO, Role::DIREKTUR]), fn ($q) => $q->whereDoesntHave('roles', fn ($rq) => $rq->where('name', Role::ADMIN_BAHAN_BAKU->value)))
->when($search, fn ($q) => $q->whereHas('userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%"))) ->when($search, fn ($q) => $q->whereHas('userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%")))
->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status))) ->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status)))
->when(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN))) ->when(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN)))
@ -66,7 +37,18 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage); ->paginate($perPage);
} }
public function create(array $data): User public function getAll(): Collection
{
return Employee::with(['user.userProfile', 'user.roles'])
->whereHas('user', fn ($q) => $q->where('is_active', true))
->get()
->map(fn (Employee $employee) => [
'id' => $employee->id,
'name' => $employee->user?->userProfile?->full_name ?? '-',
]);
}
public function store(array $data): User
{ {
return DB::transaction(function () use ($data) { return DB::transaction(function () use ($data) {
$user = User::create([ $user = User::create([
@ -132,7 +114,7 @@ public function update(User $user, array $data): User
return $user->fresh(['userProfile', 'employee']); return $user->fresh(['userProfile', 'employee']);
} }
public function delete(User $user): bool public function destroy(User $user): bool
{ {
return DB::transaction(function () use ($user) { return DB::transaction(function () use ($user) {
$user->employee()->delete(); $user->employee()->delete();

View File

@ -2,39 +2,26 @@
namespace App\Services\Admin\HR; namespace App\Services\Admin\HR;
use App\Concerns\HasRoleChecks;
use App\Enums\LeaveRequestStatus; use App\Enums\LeaveRequestStatus;
use App\Enums\Role;
use App\Models\LeaveRequest; use App\Models\LeaveRequest;
use App\Services\NotificationService; use App\Services\NotificationService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class LeaveRequestService class LeaveRequestService
{ {
private function canViewAll(): bool use HasRoleChecks;
{
return auth()->user()->hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']);
}
public function getAll(array $filters = []): Collection
{
return LeaveRequest::select(['id', 'employee_id', 'start_date', 'end_date', 'total_days', 'status', 'created_at'])
->with(['employee.user.userProfile'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->when($filters['status'] ?? null, function ($query, $status) {
$query->where('status', $status);
})
->latest()
->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
return LeaveRequest::query() return LeaveRequest::query()
->select(['id', 'employee_id', 'start_date', 'end_date', 'total_days', 'status', 'created_at']) ->select(['id', 'employee_id', 'start_date', 'end_date', 'total_days', 'status', 'created_at'])
->with(['employee.user.userProfile']) ->with(['employee.user.userProfile'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))) ->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->when($search, fn ($q) => $q->whereHas('employee.user.userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%"))) ->when($search, fn ($q) => $q->whereHas('employee.user.userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%")))
->when($filters['status'] ?? null, function ($query, $status) { ->when($filters['status'] ?? null, function ($query, $status) {
$query->where('status', $status); $query->where('status', $status);
@ -43,13 +30,15 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage); ->paginate($perPage);
} }
public function create(array $data): LeaveRequest public function store(array $data): LeaveRequest
{ {
$leaveRequest = DB::transaction(function () use ($data) { $leaveRequest = DB::transaction(function () use ($data) {
$employee = auth()->user()->employee; $employee = auth()->user()->employee;
if (! $employee) { if (! $employee) {
throw new \Exception('Anda tidak terdaftar sebagai karyawan.'); throw ValidationException::withMessages([
'employee' => 'Anda tidak terdaftar sebagai karyawan.',
]);
} }
$startDate = new Carbon($data['start_date']); $startDate = new Carbon($data['start_date']);
@ -68,7 +57,7 @@ public function create(array $data): LeaveRequest
$leaveRequest->load('employee.user'); $leaveRequest->load('employee.user');
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Pengajuan Cuti Baru', title: 'Pengajuan Cuti Baru',
body: "Pengajuan cuti {$leaveRequest->total_days} hari oleh ".auth()->user()->full_name.'.', body: "Pengajuan cuti {$leaveRequest->total_days} hari oleh ".auth()->user()->full_name.'.',
url: route('admin.hr.leave-requests.index'), url: route('admin.hr.leave-requests.index'),
@ -95,7 +84,7 @@ public function update(LeaveRequest $leaveRequest, array $data): LeaveRequest
}); });
} }
public function delete(LeaveRequest $leaveRequest): bool public function destroy(LeaveRequest $leaveRequest): bool
{ {
return $leaveRequest->delete(); return $leaveRequest->delete();
} }
@ -111,7 +100,7 @@ public function approve(LeaveRequest $leaveRequest): LeaveRequest
$employeeUser = $leaveRequest->employee->user ?? null; $employeeUser = $leaveRequest->employee->user ?? null;
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Cuti Disetujui', title: 'Cuti Disetujui',
body: "Cuti {$leaveRequest->employee->name} telah disetujui oleh ".auth()->user()->full_name.'.', body: "Cuti {$leaveRequest->employee->name} telah disetujui oleh ".auth()->user()->full_name.'.',
url: route('admin.hr.leave-requests.index'), url: route('admin.hr.leave-requests.index'),
@ -132,7 +121,7 @@ public function reject(LeaveRequest $leaveRequest): LeaveRequest
$employeeUser = $leaveRequest->employee->user ?? null; $employeeUser = $leaveRequest->employee->user ?? null;
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Cuti Ditolak', title: 'Cuti Ditolak',
body: "Cuti {$leaveRequest->employee->name} telah ditolak oleh ".auth()->user()->full_name.'.', body: "Cuti {$leaveRequest->employee->name} telah ditolak oleh ".auth()->user()->full_name.'.',
url: route('admin.hr.leave-requests.index'), url: route('admin.hr.leave-requests.index'),

View File

@ -6,7 +6,6 @@
use App\Models\CuttingMaterial; use App\Models\CuttingMaterial;
use App\Models\CuttingMaterialCombination; use App\Models\CuttingMaterialCombination;
use App\Models\CuttingResult; use App\Models\CuttingResult;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice; use App\Models\RawMaterialPrice;
use App\Services\Concerns\RegistersMedia; use App\Services\Concerns\RegistersMedia;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
@ -61,28 +60,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator; return $paginator;
} }
public function getForCreate(): array
{
return [
'rawMaterials' => RawMaterial::query()
->select(['id', 'name', 'unit', 'is_active'])
->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock',
])
->active()
->orderBy('name')
->get()
->each(function (RawMaterial $rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$media = $price->getFirstMedia('photos');
$price->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
});
}),
];
}
public function getForEdit(Cutting $cutting): array public function getForEdit(Cutting $cutting): array
{ {
$cutting->load([ $cutting->load([
@ -143,7 +120,7 @@ public function getForEdit(Cutting $cutting): array
]; ];
} }
public function create(array $data): Cutting public function store(array $data): Cutting
{ {
return DB::transaction(function () use ($data) { return DB::transaction(function () use ($data) {
foreach ($data['materials'] as $materialData) { foreach ($data['materials'] as $materialData) {
@ -349,7 +326,7 @@ public function update(Cutting $cutting, array $data): Cutting
}); });
} }
public function delete(Cutting $cutting): bool public function destroy(Cutting $cutting): bool
{ {
return DB::transaction(function () use ($cutting) { return DB::transaction(function () use ($cutting) {
$cutting->load('cuttingMaterials.rawMaterialPrice'); $cutting->load('cuttingMaterials.rawMaterialPrice');

View File

@ -2,6 +2,7 @@
namespace App\Services\Admin\Manage; namespace App\Services\Admin\Manage;
use App\Enums\Role;
use App\Models\Purchase; use App\Models\Purchase;
use App\Models\PurchaseItem; use App\Models\PurchaseItem;
use App\Models\RawMaterial; use App\Models\RawMaterial;
@ -154,16 +155,16 @@ public function getForEdit(Purchase $purchase): array
]; ];
} }
public function create(array $data): Purchase public function store(array $data): Purchase
{ {
if (($data['mode'] ?? 'new') === 'existing') { if (($data['mode'] ?? 'new') === 'existing') {
return $this->createFromExisting($data); return $this->storeFromExisting($data);
} }
return $this->createNew($data); return $this->storeNew($data);
} }
private function createFromExisting(array $data): Purchase private function storeFromExisting(array $data): Purchase
{ {
return DB::transaction(function () use ($data) { return DB::transaction(function () use ($data) {
$now = now(); $now = now();
@ -219,7 +220,7 @@ private function createFromExisting(array $data): Purchase
} }
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Bahan Baku'], roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Belanja Baru', title: 'Belanja Baru',
body: 'Belanja bahan baku sebesar Rp '.number_format($total, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.', body: 'Belanja bahan baku sebesar Rp '.number_format($total, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.purchases.index'), url: route('admin.manage.purchases.index'),
@ -229,7 +230,7 @@ private function createFromExisting(array $data): Purchase
}); });
} }
private function createNew(array $data): Purchase private function storeNew(array $data): Purchase
{ {
return DB::transaction(function () use ($data) { return DB::transaction(function () use ($data) {
$rawMaterial = RawMaterial::create([ $rawMaterial = RawMaterial::create([
@ -309,7 +310,7 @@ private function createNew(array $data): Purchase
} }
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Bahan Baku'], roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Belanja Baru', title: 'Belanja Baru',
body: 'Belanja bahan baku sebesar Rp '.number_format($total, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.', body: 'Belanja bahan baku sebesar Rp '.number_format($total, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.purchases.index'), url: route('admin.manage.purchases.index'),
@ -334,12 +335,6 @@ public function update(Purchase $purchase, array $data): Purchase
} }
}); });
$oldMaterial = $oldItems
->map(fn (PurchaseItem $item) => $item->rawMaterialPrice?->rawMaterial)
->filter()
->unique(fn (RawMaterial $material) => $material->id)
->first();
$oldMaterial = $oldItems $oldMaterial = $oldItems
->map(fn (PurchaseItem $item) => $item->rawMaterialPrice?->rawMaterial) ->map(fn (PurchaseItem $item) => $item->rawMaterialPrice?->rawMaterial)
->filter() ->filter()
@ -466,7 +461,7 @@ public function update(Purchase $purchase, array $data): Purchase
}); });
} }
public function delete(Purchase $purchase): bool public function destroy(Purchase $purchase): bool
{ {
return DB::transaction(function () use ($purchase) { return DB::transaction(function () use ($purchase) {
$purchase->load('purchaseItems.rawMaterialPrice'); $purchase->load('purchaseItems.rawMaterialPrice');

View File

@ -4,7 +4,7 @@
use App\Enums\PriceType; use App\Enums\PriceType;
use App\Enums\ProductStockQuality; use App\Enums\ProductStockQuality;
use App\Models\Product; use App\Enums\Role;
use App\Models\ProductVariant; use App\Models\ProductVariant;
use App\Models\Restock; use App\Models\Restock;
use App\Models\RestockItem; use App\Models\RestockItem;
@ -59,65 +59,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator; return $paginator;
} }
public function getForCreate(): array public function store(array $data): Restock
{
return [
'products' => Product::query()
->select(['id', 'name', 'status'])
->with([
'productVariants:id,product_id,name,stock,reject_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->active()
->orderBy('name')
->get()
->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('photos');
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
$capitalPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::CAPITAL);
$variant->capital_price = $capitalPrice?->price ?? 0;
$rejectPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::REJECT);
$variant->reject_price = $rejectPrice?->price ?? 0;
});
}),
];
}
public function getForEdit(Restock $restock): array
{
$restock->load([
'restockItems' => fn ($q) => $q
->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)'),
'restockItems.productVariant.product',
]);
$media = $restock->getFirstMedia('photos');
return [
'id' => $restock->id,
'stock_type' => $restock->stock_type->value,
'notes' => $restock->notes,
'photo_key' => $media?->file_name,
'photo_url' => $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null,
'items' => $restock->restockItems->map(fn (RestockItem $item) => [
'id' => $item->id,
'product_variant_id' => $item->product_variant_id,
'quantity' => $item->quantity,
'unit_price' => $item->unit_price,
])->values(),
];
}
public function create(array $data): Restock
{ {
return DB::transaction(function () use ($data) { return DB::transaction(function () use ($data) {
$now = now(); $now = now();
@ -143,7 +85,7 @@ public function create(array $data): Restock
$this->syncPhoto($restock, $data); $this->syncPhoto($restock, $data);
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
title: 'Restock Baru', title: 'Restock Baru',
body: 'Restock '.($stockType === ProductStockQuality::GOOD->value ? 'produk' : 'reject').' sebesar Rp '.number_format($subtotal, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.', body: 'Restock '.($stockType === ProductStockQuality::GOOD->value ? 'produk' : 'reject').' sebesar Rp '.number_format($subtotal, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.restocks.index'), url: route('admin.manage.restocks.index'),
@ -189,7 +131,7 @@ public function update(Restock $restock, array $data): Restock
}); });
} }
public function delete(Restock $restock): bool public function destroy(Restock $restock): bool
{ {
return DB::transaction(function () use ($restock) { return DB::transaction(function () use ($restock) {
$restock->load('restockItems'); $restock->load('restockItems');

View File

@ -7,10 +7,10 @@
use App\Enums\PaymentType; use App\Enums\PaymentType;
use App\Enums\PriceType; use App\Enums\PriceType;
use App\Enums\ProductStockQuality; use App\Enums\ProductStockQuality;
use App\Enums\Role;
use App\Models\Customer; use App\Models\Customer;
use App\Models\Order; use App\Models\Order;
use App\Models\OrderItem; use App\Models\OrderItem;
use App\Models\Product;
use App\Models\ProductVariant; use App\Models\ProductVariant;
use App\Models\User; use App\Models\User;
use App\Services\Concerns\HasStockAdjustment; use App\Services\Concerns\HasStockAdjustment;
@ -41,7 +41,7 @@ public function __construct(
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
$paginator = Order::query() $paginator = Order::query()
->select(['id', 'created_by_id', 'customer_id', 'marketing_id', 'order_number', 'channel', 'price_type', 'status', 'payment_type', 'subtotal', 'discount', 'nego_price', 'total_amount', 'cogs', 'notes', 'created_at']) ->select(['id', 'created_by_id', 'customer_id', 'marketing_id', 'order_number', 'channel', 'price_type', 'status', 'payment_type', 'tiktok_order_id', 'shopee_order_id', 'subtotal', 'discount', 'nego_price', 'total_amount', 'cogs', 'notes', 'created_at'])
->with([ ->with([
'createdBy:id', 'createdBy:id',
'createdBy.userProfile:id,user_id,full_name', 'createdBy.userProfile:id,user_id,full_name',
@ -96,7 +96,7 @@ public function getSummary(array $filters = []): array
$query = Order::query() $query = Order::query()
->selectRaw('COUNT(*) as total_orders') ->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(subtotal), 0) as total_subtotal') ->selectRaw('COALESCE(SUM(subtotal), 0) as total_subtotal')
->selectRaw('COALESCE(SUM(subtotal) - SUM(COALESCE(nego_price, subtotal)), 0) as total_discount') ->selectRaw('COALESCE(SUM(discount) + SUM(COALESCE(nego_price, 0)), 0) as total_discount')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_amount') ->selectRaw('COALESCE(SUM(total_amount), 0) as total_amount')
->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs') ->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs')
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status)) ->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
@ -143,83 +143,7 @@ public function getFilterOptions(): array
]; ];
} }
public function getForCreate(): array public function store(array $data): Order
{
return [
'products' => Product::query()
->select(['id', 'name', 'status'])
->with([
'productVariants:id,product_id,name,stock,reject_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->active()
->orderBy('name')
->get()
->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('photos');
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
$prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]);
$variant->prices = $prices;
});
}),
'customers' => Customer::query()
->select(['id', 'name'])
->orderBy('name')
->get(),
'employees' => User::query()
->select('id')
->active()
->with('userProfile:id,user_id,full_name')
->orderBy('id')
->get()
->filter(fn (User $user) => $user->userProfile?->full_name)
->values(),
'channelOptions' => OrderChannel::toSelect(),
'paymentTypeOptions' => PaymentType::toSelect(),
'priceTypeOptions' => PriceType::toSelect()->filter(fn ($p) => $p['value'] !== PriceType::CAPITAL->value)->values(),
];
}
public function getForEdit(Order $order): array
{
$order->load('orderItems.productVariant.product');
$media = $order->getFirstMedia('photos');
return [
'id' => $order->id,
'order_number' => $order->order_number,
'stock_type' => $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value,
'channel' => $order->channel?->value ?? OrderChannel::STORE->value,
'price_type' => $order->price_type?->value ?? PriceType::RETAIL->value,
'payment_type' => $order->payment_type?->value ?? PaymentType::CASH->value,
'customer_id' => $order->customer_id,
'marketing_id' => $order->marketing_id,
'discount' => $order->discount,
'nego_price' => $order->nego_price,
'is_completed' => $order->status === OrderStatus::COMPLETED,
'is_affiliate' => $order->is_affiliate,
'tiktok_order_id' => $order->tiktok_order_id,
'shopee_order_id' => $order->shopee_order_id,
'notes' => $order->notes,
'photo_key' => $media?->file_name,
'photo_url' => $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null,
'items' => $order->orderItems->map(fn (OrderItem $item) => [
'id' => $item->id,
'product_variant_id' => $item->product_variant_id,
'quantity' => $item->quantity,
'unit_price' => $item->unit_price,
])->values(),
];
}
public function create(array $data): Order
{ {
return DB::transaction(function () use ($data) { return DB::transaction(function () use ($data) {
$now = now(); $now = now();
@ -234,7 +158,7 @@ public function create(array $data): Order
$discount = (int) ($data['discount'] ?? 0); $discount = (int) ($data['discount'] ?? 0);
$negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null; $negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null;
$totalAmount = $subtotal - $discount + ($negoPrice ?? 0); $totalAmount = $subtotal - $discount - ($negoPrice ?? 0);
$order = Order::create([ $order = Order::create([
'created_by_id' => auth()->id(), 'created_by_id' => auth()->id(),
@ -268,7 +192,7 @@ public function create(array $data): Order
} }
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
title: 'Transaksi Baru', title: 'Transaksi Baru',
body: 'Transaksi '.$order->order_number.' sebesar Rp '.number_format($totalAmount, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.', body: 'Transaksi '.$order->order_number.' sebesar Rp '.number_format($totalAmount, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.transactions.index'), url: route('admin.manage.transactions.index'),
@ -301,7 +225,7 @@ public function update(Order $order, array $data): Order
$discount = (int) ($data['discount'] ?? 0); $discount = (int) ($data['discount'] ?? 0);
$negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null; $negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null;
$totalAmount = $subtotal - $discount + ($negoPrice ?? 0); $totalAmount = $subtotal - $discount - ($negoPrice ?? 0);
foreach ($itemRows as &$row) { foreach ($itemRows as &$row) {
$row['order_id'] = $order->id; $row['order_id'] = $order->id;
@ -339,7 +263,7 @@ public function update(Order $order, array $data): Order
}); });
} }
public function delete(Order $order): bool public function destroy(Order $order): bool
{ {
return DB::transaction(function () use ($order) { return DB::transaction(function () use ($order) {
$order->load('orderItems'); $order->load('orderItems');

View File

@ -8,11 +8,6 @@
class CategoryService class CategoryService
{ {
public function getAll(array $filters = []): Collection
{
return Category::select(['id', 'name'])->latest()->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
return Category::query() return Category::query()
@ -22,7 +17,12 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage); ->paginate($perPage);
} }
public function create(array $data): Category public function getAll(): Collection
{
return Category::select(['id', 'name'])->latest()->get();
}
public function store(array $data): Category
{ {
return Category::create($data); return Category::create($data);
} }
@ -34,7 +34,7 @@ public function update(Category $category, array $data): Category
return $category; return $category;
} }
public function delete(Category $category): bool public function destroy(Category $category): bool
{ {
return $category->delete(); return $category->delete();
} }

View File

@ -8,11 +8,6 @@
class CustomerService class CustomerService
{ {
public function getAll(array $filters = []): Collection
{
return Customer::select(['id', 'name', 'phone_number', 'address'])->latest()->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
return Customer::query() return Customer::query()
@ -22,7 +17,12 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage); ->paginate($perPage);
} }
public function create(array $data): Customer public function getAll(): Collection
{
return Customer::select(['id', 'name'])->orderBy('name')->get();
}
public function store(array $data): Customer
{ {
return Customer::create($data); return Customer::create($data);
} }
@ -34,7 +34,7 @@ public function update(Customer $customer, array $data): Customer
return $customer; return $customer;
} }
public function delete(Customer $customer): bool public function destroy(Customer $customer): bool
{ {
return $customer->delete(); return $customer->delete();
} }

View File

@ -2,70 +2,48 @@
namespace App\Services\Admin\Master\Product; namespace App\Services\Admin\Master\Product;
use App\Concerns\HasRoleChecks;
use App\Enums\ProductStatus;
use App\Enums\Role;
use App\Models\Product; use App\Models\Product;
use App\Models\ProductVariant; use App\Models\ProductVariant;
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use App\Services\StockMutationService; use App\Services\StockMutationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ProductService class ProductService
{ {
use HasRoleChecks;
public function __construct( public function __construct(
private ProductVariantService $variantService, private ProductVariantService $variantService,
private S3PresignedService $s3Service, private S3PresignedService $s3Service,
private StockMutationService $stockMutationService, private StockMutationService $stockMutationService,
) {} ) {}
public function getNames(): array public function getNames(): Collection
{ {
return Product::where('status', '!=', 'deleted') return Product::select(['id', 'name'])
->orderBy('name')
->pluck('name')
->unique()
->values()
->toArray();
}
public function getAll(array $filters = []): Collection
{
$products = Product::select(['id', 'name', 'slug', 'description', 'status'])
->with([
'categories:id,name',
'productVariants:id,product_id,name,stock,reject_stock,retail_stock',
'productVariants.productPrices:id,variant_id,type,price',
'productVariants.media',
])
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%"))
->latest()
->get(); ->get();
$products->each(function ($product) {
$product->productVariants->each(function ($variant) {
$media = $variant->getMedia('photos');
$variant->photo_urls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray();
});
});
return $products;
} }
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
$paginator = Product::query() $paginator = Product::query()
->select(['id', 'name', 'slug', 'description', 'status']) ->select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason'])
->with([ ->with([
'categories:id,name', 'categories:id,name',
'productVariants:id,product_id,name,stock,reject_stock,retail_stock', 'productVariants:id,product_id,name,stock,reject_stock,retail_stock',
'productVariants.productPrices:id,variant_id,type,price', 'productVariants.productPrices:id,variant_id,type,price',
]) ])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")) ->when($search, fn($q) => $q->where('name', 'like', "%{$search}%"))
->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%")) ->when($filters['name'] ?? null, fn($q, $name) => $q->where('name', 'like', "%{$name}%"))
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status)) ->when($filters['status'] ?? null, fn($q, $status) => $q->where('status', $status))
->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) { ->when($filters['category'] ?? null, fn($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
$cq->where('categories.id', $categoryId); $cq->where('categories.id', $categoryId);
})) }))
->when(($filters['stock'] ?? null) === 'empty', function ($q) { ->when(($filters['stock'] ?? null) === 'empty', function ($q) {
@ -80,20 +58,24 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
$paginator->getCollection()->each(function ($product) { $paginator->getCollection()->each(function ($product) {
$product->productVariants->each(function ($variant) { $product->productVariants->each(function ($variant) {
$media = $variant->getMedia('photos'); $media = $variant->getMedia('photos');
$variant->photo_urls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray(); $variant->photo_urls = $media->map(fn($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray();
}); });
}); });
return $paginator; return $paginator;
} }
public function create(array $data): Product public function store(array $data): Product
{ {
$product = DB::transaction(function () use ($data) { $status = self::hasAnyRole([Role::DEVELOPER, Role::OWNER])
? ($data['status'] ?? ProductStatus::ACTIVE)
: ProductStatus::PENDING;
$product = DB::transaction(function () use ($data, $status) {
$product = Product::create([ $product = Product::create([
'name' => $data['name'], 'name' => $data['name'],
'description' => $data['description'] ?? null, 'description' => $data['description'] ?? null,
'status' => $data['status'] ?? 'active', 'status' => $status,
]); ]);
$product->categories()->sync($data['category_ids']); $product->categories()->sync($data['category_ids']);
@ -102,7 +84,7 @@ public function create(array $data): Product
// Bulk insert variants // Bulk insert variants
$now = now(); $now = now();
$variantRows = collect($data['variants'])->map(fn ($v) => [ $variantRows = collect($data['variants'])->map(fn($v) => [
'product_id' => $product->id, 'product_id' => $product->id,
'name' => $v['name'], 'name' => $v['name'],
'stock' => $v['stock'], 'stock' => $v['stock'],
@ -116,7 +98,7 @@ public function create(array $data): Product
// Map variant name -> variant ID // Map variant name -> variant ID
$insertedVariants = ProductVariant::where('product_id', $product->id)->get(); $insertedVariants = ProductVariant::where('product_id', $product->id)->get();
$variantMap = $insertedVariants->mapWithKeys(fn ($v) => [$v->name => $v->id]); $variantMap = $insertedVariants->mapWithKeys(fn($v) => [$v->name => $v->id]);
// Bulk insert prices // Bulk insert prices
$priceRows = []; $priceRows = [];
@ -160,9 +142,9 @@ public function create(array $data): Product
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Produk Baru', title: 'Produk Baru',
body: "Produk \"{$product->name}\" berhasil ditambahkan".' oleh '.auth()->user()->full_name.'.', body: "Produk \"{$product->name}\" berhasil ditambahkan" . ' oleh ' . auth()->user()->full_name . '.',
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
); );
@ -180,7 +162,7 @@ public function getForEdit(Product $product): array
$variants = $product->productVariants->map(function (ProductVariant $variant) { $variants = $product->productVariants->map(function (ProductVariant $variant) {
$media = $variant->getMedia('photos'); $media = $variant->getMedia('photos');
$photoKeys = $media->pluck('file_name')->toArray(); $photoKeys = $media->pluck('file_name')->toArray();
$photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray(); $photoUrls = $media->map(fn($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray();
return [ return [
'id' => $variant->id, 'id' => $variant->id,
@ -190,7 +172,7 @@ public function getForEdit(Product $product): array
'retail_stock' => $variant->retail_stock, 'retail_stock' => $variant->retail_stock,
'photo_keys' => $photoKeys, 'photo_keys' => $photoKeys,
'photo_urls' => $photoUrls, 'photo_urls' => $photoUrls,
'prices' => $variant->productPrices->map(fn ($p) => [ 'prices' => $variant->productPrices->map(fn($p) => [
'type' => $p->type->value, 'type' => $p->type->value,
'price' => $p->price, 'price' => $p->price,
]), ]),
@ -209,11 +191,19 @@ public function getForEdit(Product $product): array
public function update(Product $product, array $data): Product public function update(Product $product, array $data): Product
{ {
$this->assertNotPending($product);
$product = DB::transaction(function () use ($product, $data) { $product = DB::transaction(function () use ($product, $data) {
// Auto-resubmit: non-verifier editing rejected product → status becomes pending
$newStatus = $data['status'] ?? $product->status;
if ($product->status === ProductStatus::REJECTED && ! self::hasAnyRole([Role::DEVELOPER, Role::OWNER])) {
$newStatus = ProductStatus::PENDING;
}
$product->update([ $product->update([
'name' => $data['name'], 'name' => $data['name'],
'description' => $data['description'] ?? null, 'description' => $data['description'] ?? null,
'status' => $data['status'] ?? $product->status, 'status' => $newStatus,
]); ]);
$product->categories()->sync($data['category_ids']); $product->categories()->sync($data['category_ids']);
@ -240,7 +230,7 @@ public function update(Product $product, array $data): Product
$existingVariantsMap = ProductVariant::whereIn('id', $existingVariantIds) $existingVariantsMap = ProductVariant::whereIn('id', $existingVariantIds)
->with('media') ->with('media')
->get() ->get()
->mapWithKeys(fn ($v) => [$v->id => $v]); ->mapWithKeys(fn($v) => [$v->id => $v]);
// Collect old stock data + identify changed variants // Collect old stock data + identify changed variants
$oldStockDataMap = []; $oldStockDataMap = [];
@ -266,7 +256,7 @@ public function update(Product $product, array $data): Product
// Bulk update changed variants (only changed ones, not all) // Bulk update changed variants (only changed ones, not all)
if ($changedIds !== []) { if ($changedIds !== []) {
$changedUpdates = collect($data['variants']) $changedUpdates = collect($data['variants'])
->filter(fn ($v) => isset($v['id']) && in_array($v['id'], $changedIds)); ->filter(fn($v) => isset($v['id']) && in_array($v['id'], $changedIds));
foreach ($changedUpdates as $variantData) { foreach ($changedUpdates as $variantData) {
$existingVariantsMap[$variantData['id']]->update([ $existingVariantsMap[$variantData['id']]->update([
@ -279,11 +269,11 @@ public function update(Product $product, array $data): Product
} }
// Bulk create new variants // Bulk create new variants
$newVariantsData = collect($data['variants'])->filter(fn ($v) => ! isset($v['id'])); $newVariantsData = collect($data['variants'])->filter(fn($v) => ! isset($v['id']));
$newVariantIdMap = []; $newVariantIdMap = [];
if ($newVariantsData->isNotEmpty()) { if ($newVariantsData->isNotEmpty()) {
$newVariantRows = $newVariantsData->map(fn ($v) => [ $newVariantRows = $newVariantsData->map(fn($v) => [
'product_id' => $product->id, 'product_id' => $product->id,
'name' => $v['name'], 'name' => $v['name'],
'stock' => $v['stock'], 'stock' => $v['stock'],
@ -300,7 +290,7 @@ public function update(Product $product, array $data): Product
->whereIn('name', $newVariantsData->pluck('name')->toArray()) ->whereIn('name', $newVariantsData->pluck('name')->toArray())
->get(); ->get();
$newVariantIdMap = $newlyCreated->mapWithKeys(fn ($v) => [$v->name => $v->id])->toArray(); $newVariantIdMap = $newlyCreated->mapWithKeys(fn($v) => [$v->name => $v->id])->toArray();
} }
// Build variant_id lookup: existing by id, new by name // Build variant_id lookup: existing by id, new by name
@ -364,7 +354,7 @@ public function update(Product $product, array $data): Product
} }
} }
$changedModels = collect($changedIds)->map(fn ($id) => $existingVariantsMap[$id])->filter(); $changedModels = collect($changedIds)->map(fn($id) => $existingVariantsMap[$id])->filter();
$this->stockMutationService->recordBulkAdjustment( $this->stockMutationService->recordBulkAdjustment(
$changedModels, $changedModels,
$oldStockDataMap, $oldStockDataMap,
@ -403,17 +393,19 @@ public function update(Product $product, array $data): Product
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Produk Diperbarui', title: 'Produk Diperbarui',
body: "Produk \"{$product->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.', body: "Produk \"{$product->name}\" berhasil diperbarui" . ' oleh ' . auth()->user()->full_name . '.',
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
); );
return $product; return $product;
} }
public function delete(Product $product): bool public function destroy(Product $product): bool
{ {
$this->assertNotPending($product);
$result = DB::transaction(function () use ($product) { $result = DB::transaction(function () use ($product) {
$product->productVariants->each(function (ProductVariant $variant) { $product->productVariants->each(function (ProductVariant $variant) {
$variant->productPrices()->delete(); $variant->productPrices()->delete();
@ -427,9 +419,9 @@ public function delete(Product $product): bool
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Produk Dihapus', title: 'Produk Dihapus',
body: "Produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.', body: "Produk \"{$product->name}\" berhasil dihapus" . ' oleh ' . auth()->user()->full_name . '.',
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
); );
@ -438,8 +430,65 @@ public function delete(Product $product): bool
public function toggleStatus(Product $product): void public function toggleStatus(Product $product): void
{ {
$this->assertNotPending($product);
$product->update([ $product->update([
'status' => $product->status->value === 'active' ? 'inactive' : 'active', 'status' => $product->status->value === 'active' ? 'inactive' : 'active',
]); ]);
} }
public function approve(Product $product): void
{
$product->update([
'status' => ProductStatus::ACTIVE,
]);
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER],
title: 'Produk Disetujui',
body: "Produk \"{$product->name}\" telah disetujui oleh " . auth()->user()->full_name . '.',
url: route('admin.master.products.index'),
additionalUser: $product->createdBy ?? null,
);
}
public function reject(Product $product, string $reason = ''): void
{
$product->update([
'status' => ProductStatus::REJECTED,
'rejection_reason' => $reason,
]);
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER],
title: 'Produk Ditolak',
body: "Produk \"{$product->name}\" telah ditolak oleh " . auth()->user()->full_name . '.',
url: route('admin.master.products.index'),
additionalUser: $product->createdBy ?? null,
);
}
public function resubmit(Product $product): void
{
$product->update([
'status' => ProductStatus::PENDING,
'rejection_reason' => null,
]);
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER],
title: 'Produk Diajukan Ulang',
body: "Produk \"{$product->name}\" telah diajukan ulang oleh " . auth()->user()->full_name . '.',
url: route('admin.master.products.index'),
);
}
private function assertNotPending(Product $product): void
{
if ($product->status === ProductStatus::PENDING && ! self::hasAnyRole([Role::DEVELOPER, Role::OWNER])) {
throw ValidationException::withMessages([
'status' => 'Produk sedang menunggu verifikasi dan tidak dapat diubah.',
]);
}
}
} }

View File

@ -2,6 +2,10 @@
namespace App\Services\Admin\Master\Product; namespace App\Services\Admin\Master\Product;
use App\Concerns\HasRoleChecks;
use App\Enums\PriceType;
use App\Enums\ProductStatus;
use App\Enums\Role;
use App\Models\Product; use App\Models\Product;
use App\Models\ProductVariant; use App\Models\ProductVariant;
use App\Services\Concerns\RegistersMedia; use App\Services\Concerns\RegistersMedia;
@ -13,13 +17,66 @@
class ProductVariantService class ProductVariantService
{ {
use RegistersMedia; use HasRoleChecks, RegistersMedia;
public function __construct( public function __construct(
private S3PresignedService $s3Service, private S3PresignedService $s3Service,
private StockMutationService $stockMutationService, private StockMutationService $stockMutationService,
) {} ) {}
public function getForRestock(): array
{
return Product::query()
->select(['id', 'name', 'status'])
->with([
'productVariants:id,product_id,name,stock,reject_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->active()
->orderBy('name')
->get()
->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('photos');
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
$capitalPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::CAPITAL);
$variant->capital_price = $capitalPrice?->price ?? 0;
$rejectPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::REJECT);
$variant->reject_price = $rejectPrice?->price ?? 0;
});
});
}
public function getForTransaction(): array
{
return Product::query()
->select(['id', 'name', 'status'])
->with([
'productVariants:id,product_id,name,stock,reject_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->active()
->orderBy('name')
->get()
->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('photos');
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
$prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]);
$variant->prices = $prices;
});
});
}
public function getForEdit(ProductVariant $variant): array public function getForEdit(ProductVariant $variant): array
{ {
$variant->load([ $variant->load([
@ -49,6 +106,8 @@ public function getForEdit(ProductVariant $variant): array
public function update(ProductVariant $variant, array $data): ProductVariant public function update(ProductVariant $variant, array $data): ProductVariant
{ {
$this->assertNotPending($variant->product);
DB::transaction(function () use ($variant, $data) { DB::transaction(function () use ($variant, $data) {
$oldData = $variant->only(['stock', 'reject_stock', 'retail_stock']); $oldData = $variant->only(['stock', 'reject_stock', 'retail_stock']);
@ -83,7 +142,7 @@ public function update(ProductVariant $variant, array $data): ProductVariant
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Varian Diperbarui', title: 'Varian Diperbarui',
body: "Varian \"{$variant->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.', body: "Varian \"{$variant->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
@ -92,8 +151,10 @@ public function update(ProductVariant $variant, array $data): ProductVariant
return $variant->fresh(); return $variant->fresh();
} }
public function delete(Product $product, ProductVariant $variant): bool public function destroy(Product $product, ProductVariant $variant): bool
{ {
$this->assertNotPending($product);
$result = DB::transaction(function () use ($variant) { $result = DB::transaction(function () use ($variant) {
$variant->productPrices()->delete(); $variant->productPrices()->delete();
$variant->clearMediaCollection('photos'); $variant->clearMediaCollection('photos');
@ -102,7 +163,7 @@ public function delete(Product $product, ProductVariant $variant): bool
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Varian Dihapus', title: 'Varian Dihapus',
body: "Varian \"{$variant->name}\" dari produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.', body: "Varian \"{$variant->name}\" dari produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
@ -134,6 +195,8 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
{ {
$quantity = (int) $data['quantity']; $quantity = (int) $data['quantity'];
$this->assertNotPending($variant->product);
if ($variant->stock < $quantity) { if ($variant->stock < $quantity) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'quantity' => "Stok bagus tidak mencukupi. Stok tersedia: {$variant->stock}.", 'quantity' => "Stok bagus tidak mencukupi. Stok tersedia: {$variant->stock}.",
@ -161,7 +224,7 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Transfer Stok', title: 'Transfer Stok',
body: "{$quantity} unit dari varian \"{$variant->name}\" berhasil ditransfer dari stok bagus ke stok ecer".' oleh '.auth()->user()->full_name.'.', body: "{$quantity} unit dari varian \"{$variant->name}\" berhasil ditransfer dari stok bagus ke stok ecer".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'), url: route('admin.master.products.index'),
@ -169,4 +232,13 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
return $variant->fresh(); return $variant->fresh();
} }
private function assertNotPending(Product $product): void
{
if ($product->status === ProductStatus::PENDING && ! self::hasAnyRole([Role::DEVELOPER, Role::OWNER])) {
throw ValidationException::withMessages([
'status' => 'Produk sedang menunggu verifikasi dan tidak dapat diubah.',
]);
}
}
} }

View File

@ -7,7 +7,6 @@
use App\Services\Concerns\RegistersMedia; use App\Services\Concerns\RegistersMedia;
use App\Services\S3PresignedService; use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class RawMaterialService class RawMaterialService
@ -18,27 +17,6 @@ public function __construct(
private S3PresignedService $s3Service, private S3PresignedService $s3Service,
) {} ) {}
public function getAll(array $filters = []): Collection
{
return RawMaterial::select(['id', 'name', 'unit', 'is_active'])
->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock',
'rawMaterialPrices.media',
])
->when($filters['is_active'] ?? null, fn ($q, $isActive) => $q->where('is_active', $isActive === 'true'))
->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%"))
->latest()
->get()
->each(function ($rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function ($price) {
$media = $price->getMedia('photos');
$price->photo_url = $media->first()
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
: null;
});
});
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
$paginator = RawMaterial::query() $paginator = RawMaterial::query()
@ -71,7 +49,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator; return $paginator;
} }
public function create(array $data): RawMaterial public function store(array $data): RawMaterial
{ {
return DB::transaction(function () use ($data) { return DB::transaction(function () use ($data) {
$rawMaterial = RawMaterial::create([ $rawMaterial = RawMaterial::create([
@ -220,7 +198,7 @@ public function update(RawMaterial $rawMaterial, array $data): RawMaterial
}); });
} }
public function delete(RawMaterial $rawMaterial): bool public function destroy(RawMaterial $rawMaterial): bool
{ {
return DB::transaction(function () use ($rawMaterial) { return DB::transaction(function () use ($rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) { $rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {

View File

@ -2,6 +2,7 @@
namespace App\Services\Admin\Master\RawMaterial; namespace App\Services\Admin\Master\RawMaterial;
use App\Enums\Role;
use App\Models\RawMaterial; use App\Models\RawMaterial;
use App\Models\RawMaterialPrice; use App\Models\RawMaterialPrice;
use App\Services\Concerns\RegistersMedia; use App\Services\Concerns\RegistersMedia;
@ -17,6 +18,27 @@ public function __construct(
private S3PresignedService $s3Service, private S3PresignedService $s3Service,
) {} ) {}
public function getForCutting(): array
{
return RawMaterial::query()
->select(['id', 'name', 'unit', 'is_active'])
->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock',
])
->active()
->orderBy('name')
->get()
->each(function (RawMaterial $rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$media = $price->getFirstMedia('photos');
$price->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
});
})
->toArray();
}
public function getForEdit(RawMaterialPrice $variant): array public function getForEdit(RawMaterialPrice $variant): array
{ {
$variant->load('media'); $variant->load('media');
@ -61,7 +83,7 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Varian Diperbarui', title: 'Varian Diperbarui',
body: "Varian \"{$variant->variant}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.', body: "Varian \"{$variant->variant}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'), url: route('admin.master.raw-materials.index'),
@ -70,7 +92,7 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
return $variant->fresh(); return $variant->fresh();
} }
public function delete(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool
{ {
$result = DB::transaction(function () use ($variant) { $result = DB::transaction(function () use ($variant) {
$variant->clearMediaCollection('photos'); $variant->clearMediaCollection('photos');
@ -79,7 +101,7 @@ public function delete(RawMaterial $rawMaterial, RawMaterialPrice $variant): boo
}); });
NotificationService::notify( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Varian Dihapus', title: 'Varian Dihapus',
body: "Varian \"{$variant->variant}\" dari bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.', body: "Varian \"{$variant->variant}\" dari bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'), url: route('admin.master.raw-materials.index'),

View File

@ -8,11 +8,6 @@
class SupplierService class SupplierService
{ {
public function getAll(array $filters = []): Collection
{
return Supplier::select(['id', 'name', 'phone_number', 'address'])->latest()->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
return Supplier::query() return Supplier::query()
@ -22,7 +17,12 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage); ->paginate($perPage);
} }
public function create(array $data): Supplier public function getAll(): Collection
{
return Supplier::select(['id', 'name', 'phone_number', 'address'])->latest()->get();
}
public function store(array $data): Supplier
{ {
return Supplier::create($data); return Supplier::create($data);
} }
@ -34,7 +34,7 @@ public function update(Supplier $supplier, array $data): Supplier
return $supplier; return $supplier;
} }
public function delete(Supplier $supplier): bool public function destroy(Supplier $supplier): bool
{ {
return $supplier->delete(); return $supplier->delete();
} }

View File

@ -2,37 +2,32 @@
namespace App\Services\Admin\Settings; namespace App\Services\Admin\Settings;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Role as SpatieRole;
class RoleService class RoleService
{ {
public function getAll(): Collection use HasRoleChecks;
{
return Role::withCount('permissions')->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{ {
return Role::query() return SpatieRole::query()
->select(['id', 'name'])
->withCount('permissions') ->withCount('permissions')
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
->orderBy($sort, $direction) ->orderBy($sort, $direction)
->paginate($perPage); ->paginate($perPage);
} }
public function getById(int $id): Role public function store(array $data): Role
{
return Role::with('permissions')->findOrFail($id);
}
public function create(array $data): Role
{ {
return DB::transaction(function () use ($data) { return DB::transaction(function () use ($data) {
$role = Role::create(['name' => $data['name']]); $role = SpatieRole::create(['name' => $data['name']]);
$role->syncPermissions($data['permissions']); $role->syncPermissions($data['permissions']);
return $role; return $role;
@ -49,7 +44,7 @@ public function update(Role $role, array $data): Role
return $role->fresh('permissions'); return $role->fresh('permissions');
} }
public function delete(Role $role): bool public function destroy(Role $role): bool
{ {
return $role->delete(); return $role->delete();
} }
@ -61,4 +56,22 @@ public function getPermissionsByModule(): array
->map(fn ($group) => $group->pluck('name')->map(fn ($name) => explode('.', $name, 2)[1])->values()->toArray()) ->map(fn ($group) => $group->pluck('name')->map(fn ($name) => explode('.', $name, 2)[1])->values()->toArray())
->toArray(); ->toArray();
} }
public function getForEmployee(): Collection
{
$query = SpatieRole::where('name', '!=', Role::DEVELOPER->value);
$user = auth()->user();
if (self::hasAnyRole([Role::ADMIN_TOKO, Role::DIREKTUR])) {
$query->where('name', '!=', Role::ADMIN_BAHAN_BAKU->value);
}
if (! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO])) {
$userRoles = $user->roles->pluck('name');
$query->whereIn('name', $userRoles);
}
return $query->get(['id', 'name']);
}
} }

View File

@ -0,0 +1,578 @@
<?php
namespace App\Services;
use App\Enums\CashTransactionType;
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use App\Enums\PaymentType;
use App\Enums\PriceType;
use App\Enums\RawMaterialUnit;
use App\Models\Attendance;
use App\Models\CashAccount;
use App\Models\Employee;
use App\Models\EmployeeAdvance;
use App\Models\Expense;
use App\Models\LeaveRequest;
use App\Models\Order;
use App\Models\ProductVariant;
use App\Models\Purchase;
use App\Models\PurchaseItem;
use App\Models\RawMaterialPrice;
use App\Models\RestockItem;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class AnalysisService
{
public function getAttendanceStats(?string $startDate, ?string $endDate): array
{
$start = $startDate ? Carbon::parse($startDate) : Carbon::now()->startOfMonth();
$end = $endDate ? Carbon::parse($endDate) : Carbon::now();
$employees = Employee::whereHas('user', fn ($q) => $q
->where('is_active', true)
->whereHas('roles', fn ($r) => $r
->whereHas('permissions', fn ($p) => $p
->where('name', 'attendances.create')
)
)
)
->where('join_date', '<=', $end)
->where(function ($q) use ($start) {
$q->whereNull('resign_date')->orWhere('resign_date', '>=', $start);
})
->get();
$employeeIds = $employees->pluck('id');
$employeeCount = $employeeIds->count();
$present = Attendance::whereIn('employee_id', $employeeIds)
->whereBetween('attendance_date', [$start->toDateString(), $end->toDateString()])
->distinct('employee_id')
->count('employee_id');
$onLeave = LeaveRequest::approved()
->where('start_date', '<=', $end)
->where('end_date', '>=', $start)
->whereIn('employee_id', $employeeIds)
->count();
$absent = max(0, $employeeCount - $present - $onLeave);
return [
'total_employees' => $employeeCount,
'present' => $present,
'absent' => $absent,
'on_leave' => $onLeave,
'percentage' => $employeeCount > 0 ? round(($present / $employeeCount) * 100) : 0,
];
}
public function getMyAttendance(User $user, ?string $startDate, ?string $endDate): ?array
{
$employee = $user->employee;
if (! $employee) {
return null;
}
$start = $startDate ? Carbon::parse($startDate) : Carbon::now()->startOfMonth();
$end = $endDate ? Carbon::parse($endDate) : Carbon::now();
$workingDays = 0;
$current = $start->copy();
while ($current->lte($end)) {
if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) {
$workingDays++;
}
$current->addDay();
}
$present = Attendance::where('employee_id', $employee->id)
->whereBetween('attendance_date', [$start, $end])
->count();
$leaveDays = LeaveRequest::approved()
->where('employee_id', $employee->id)
->where('start_date', '<=', $end)
->where('end_date', '>=', $start)
->get()
->reduce(function ($carry, $leave) use ($start, $end) {
$leaveStart = max($leave->start_date->timestamp, $start->timestamp);
$leaveEnd = min($leave->end_date->timestamp, $end->timestamp);
$days = Carbon::createFromTimestamp($leaveStart)->diffInDays(Carbon::createFromTimestamp($leaveEnd)) + 1;
return $carry + max(0, $days);
}, 0);
$absent = max(0, $workingDays - $present - $leaveDays);
return [
'total_days' => $workingDays,
'present_days' => $present,
'absent_days' => $absent,
'leave_days' => $leaveDays,
'percentage' => $workingDays > 0 ? round(($present / $workingDays) * 100) : 0,
];
}
public function getCashOverview(?string $startDate, ?string $endDate): array
{
$cashAccount = CashAccount::first();
if (! $cashAccount) {
return [
'total_balance' => 0,
'total_transactions' => 0,
'total_deposit' => 0,
'total_withdrawal' => 0,
];
}
$transactions = $cashAccount->cashTransactions();
$this->applyDateFilter($transactions, $startDate, $endDate, 'cash_transactions.created_at');
return [
'total_balance' => $cashAccount->balance,
'total_transactions' => (clone $transactions)->count(),
'total_deposit' => (int) (clone $transactions)->where('type', CashTransactionType::DEPOSIT)->sum('amount'),
'total_withdrawal' => (int) (clone $transactions)->where('type', CashTransactionType::WITHDRAWAL)->sum('amount'),
];
}
public function getRawMaterialStock(): array
{
$query = PurchaseItem::query()
->join('purchases', 'purchase_items.purchase_id', '=', 'purchases.id')
->leftJoin('raw_material_prices', 'purchase_items.raw_material_price_id', '=', 'raw_material_prices.id')
->leftJoin('raw_materials', 'raw_material_prices.raw_material_id', '=', 'raw_materials.id');
$items = $query->select('purchase_items.*', 'raw_materials.unit')
->get();
$totalQty = $items->sum('quantity');
$totalValue = $items->sum('subtotal');
$byUnit = [
'yard' => $items->filter(fn ($i) => $i->unit === RawMaterialUnit::YARD)->sum('quantity'),
'meter' => $items->filter(fn ($i) => $i->unit === RawMaterialUnit::METER)->sum('quantity'),
'kilogram' => $items->filter(fn ($i) => $i->unit === RawMaterialUnit::KG)->sum('quantity'),
];
return [
'total_stock' => $totalQty,
'total_value' => $totalValue,
'by_unit' => $byUnit,
];
}
public function getProductStock(): array
{
$query = RestockItem::query()
->join('restocks', 'restock_items.restock_id', '=', 'restocks.id');
$items = $query->select('restock_items.*', 'restocks.stock_type')
->get();
$totalQty = $items->sum('quantity');
$totalValue = $items->sum('subtotal');
$byType = $items->groupBy(fn ($i) => $i->stock_type ?? 'unknown')
->map(fn ($group) => $group->sum('quantity'))
->toArray();
return [
'total_stock' => $totalQty,
'total_value' => $totalValue,
'by_type' => $byType,
];
}
public function getRevenueSummary(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
$stats = (clone $query)
->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_revenue')
->selectRaw('COALESCE(SUM(discount), 0) as total_discount')
->selectRaw('COALESCE(SUM(nego_price), 0) as total_deduction')
->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs')
->first();
return [
'total_revenue' => (int) $stats->total_revenue,
'total_discount' => (int) $stats->total_discount,
'total_deduction' => (int) $stats->total_deduction,
'cogs' => (int) $stats->total_cogs,
'net' => (int) $stats->total_revenue - (int) $stats->total_cogs,
'total_orders' => (int) $stats->total_orders,
'avg_order' => $stats->total_orders > 0 ? (int) ($stats->total_revenue / $stats->total_orders) : 0,
];
}
public function getMonthlyRevenue(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
$monthly = (clone $query)
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(total_amount), 0) as total')
->selectRaw('COALESCE(SUM(total_amount) - SUM(cogs), 0) as net')
->selectRaw('COALESCE(SUM(discount), 0) as discount')
->selectRaw('COALESCE(SUM(nego_price), 0) as deduction')
->selectRaw('COALESCE(SUM(cogs), 0) as cogs')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->orderBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"))
->get()
->map(fn ($item) => [
'month' => $item->month,
'total' => (int) $item->total,
'net' => (int) $item->net,
'discount' => (int) $item->discount,
'deduction' => (int) $item->deduction,
'cogs' => (int) $item->cogs,
]);
return $monthly->values()->toArray();
}
public function getMonthlyRevenueByChannel(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
$monthly = (clone $query)
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw("COALESCE(SUM(CASE WHEN channel = 'store' THEN total_amount ELSE 0 END), 0) as store")
->selectRaw("COALESCE(SUM(CASE WHEN channel = 'shopee' THEN total_amount ELSE 0 END), 0) as shopee")
->selectRaw("COALESCE(SUM(CASE WHEN channel = 'tiktok' THEN total_amount ELSE 0 END), 0) as tiktok")
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->orderBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"))
->get()
->map(fn ($item) => [
'month' => $item->month,
'store' => (int) $item->store,
'shopee' => (int) $item->shopee,
'tiktok' => (int) $item->tiktok,
]);
return $monthly->values()->toArray();
}
public function getRevenueByPaymentType(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
$data = (clone $query)
->select('payment_type')
->selectRaw('COALESCE(SUM(total_amount), 0) as total')
->groupBy('payment_type')
->get()
->map(fn ($item) => [
'payment_type' => $item->payment_type,
'label' => $item->payment_type->label(),
'total' => (int) $item->total,
]);
return $data->toArray();
}
public function getExpenseSummary(?string $startDate, ?string $endDate): array
{
$expenseQuery = Expense::query();
$this->applyDateFilter($expenseQuery, $startDate, $endDate, 'expenses.created_at');
$advanceQuery = EmployeeAdvance::where('status', 'paid');
$this->applyDateFilter($advanceQuery, $startDate, $endDate, 'employee_advances.created_at');
$purchaseQuery = Purchase::query();
$this->applyDateFilter($purchaseQuery, $startDate, $endDate, 'purchases.created_at');
$expenseTotal = (clone $expenseQuery)->sum('amount');
$advanceTotal = (clone $advanceQuery)->sum('amount');
$purchaseTotal = (clone $purchaseQuery)->sum('total');
return [
'total' => (int) ($expenseTotal + $advanceTotal + $purchaseTotal),
'purchase_total' => (int) $purchaseTotal,
'expense_total' => (int) $expenseTotal,
'advance_total' => (int) $advanceTotal,
];
}
public function getMonthlyExpense(?string $startDate, ?string $endDate): array
{
$expenseMonthly = Expense::query();
$this->applyDateFilter($expenseMonthly, $startDate, $endDate, 'expenses.created_at');
$expenseByMonth = (clone $expenseMonthly)->toBase()
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(amount), 0) as expense')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->get()
->keyBy('month');
$purchaseMonthly = Purchase::query();
$this->applyDateFilter($purchaseMonthly, $startDate, $endDate, 'purchases.created_at');
$purchaseByMonth = (clone $purchaseMonthly)->toBase()
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(total), 0) as purchase')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->get()
->keyBy('month');
$advanceMonthly = EmployeeAdvance::where('status', 'paid');
$this->applyDateFilter($advanceMonthly, $startDate, $endDate, 'employee_advances.created_at');
$advanceByMonth = (clone $advanceMonthly)->toBase()
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(amount), 0) as advance')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->get()
->keyBy('month');
$allMonths = [];
foreach ([$expenseByMonth, $purchaseByMonth, $advanceByMonth] as $data) {
foreach ($data as $month => $row) {
if (! array_key_exists($month, $allMonths)) {
$allMonths[$month] = ['month' => $month, 'total' => 0, 'purchase' => 0, 'expense' => 0, 'advance' => 0];
}
}
}
foreach ($allMonths as $month => &$row) {
$row['purchase'] = (int) ($purchaseByMonth[$month]->purchase ?? 0);
$row['expense'] = (int) ($expenseByMonth[$month]->expense ?? 0);
$row['advance'] = (int) ($advanceByMonth[$month]->advance ?? 0);
$row['total'] = $row['purchase'] + $row['expense'] + $row['advance'];
}
return array_values($allMonths);
}
public function getBusyHours(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
$hours = range(0, 23);
$hourCounts = (clone $query)
->selectRaw('HOUR(created_at) as hour')
->selectRaw('COUNT(*) as orders')
->groupBy(DB::raw('HOUR(created_at)'))
->pluck('orders', 'hour')
->toArray();
return array_map(function ($h) use ($hourCounts) {
return [
'hour' => sprintf('%02d:00', $h),
'orders' => (int) ($hourCounts[$h] ?? 0),
];
}, $hours);
}
public function getProfitMetrics(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
$stats = (clone $query)
->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_revenue')
->selectRaw('COALESCE(SUM(cogs), 0) as hpp')
->first();
$totalProductsSold = (clone $query)
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->sum('order_items.quantity');
$grossProfit = $stats->total_revenue - $stats->hpp;
$netProfit = $grossProfit;
$profitMargin = $stats->total_revenue > 0 ? round(($netProfit / $stats->total_revenue) * 100, 1) : 0;
$aov = $stats->total_orders > 0 ? (int) ($stats->total_revenue / $stats->total_orders) : 0;
$itemsPerTransaction = $stats->total_orders > 0 ? round($totalProductsSold / $stats->total_orders, 1) : 0;
return [
'total_orders' => (int) $stats->total_orders,
'total_products_sold' => (int) $totalProductsSold,
'hpp' => (int) $stats->hpp,
'gross_profit' => (int) $grossProfit,
'net_profit' => (int) $netProfit,
'profit_margin' => $profitMargin,
'aov' => $aov,
'items_per_transaction' => $itemsPerTransaction,
];
}
public function getTopSuppliers(?string $startDate, ?string $endDate): array
{
$query = Purchase::query();
$this->applyDateFilter($query, $startDate, $endDate, 'purchases.created_at');
return (clone $query)->toBase()
->join('suppliers', 'purchases.supplier_id', '=', 'suppliers.id')
->select('suppliers.name')
->selectRaw('COALESCE(SUM(purchases.total), 0) as total_amount')
->selectRaw('COUNT(*) as purchase_count')
->groupBy('suppliers.name')
->orderByDesc('total_amount')
->limit(5)
->get()
->toArray();
}
public function getTopCustomers(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED)->whereNotNull('orders.customer_id');
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
return (clone $query)->toBase()
->join('customers', 'orders.customer_id', '=', 'customers.id')
->select('customers.name')
->selectRaw('COALESCE(SUM(orders.total_amount), 0) as total_amount')
->selectRaw('COUNT(*) as order_count')
->groupBy('customers.name')
->orderByDesc('total_amount')
->limit(5)
->get()
->toArray();
}
public function getTopProducts(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
return (clone $query)->toBase()
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('product_variants', 'order_items.product_variant_id', '=', 'product_variants.id')
->join('products', 'product_variants.product_id', '=', 'products.id')
->select('products.name')
->selectRaw('SUM(order_items.quantity) as total_qty')
->selectRaw('COALESCE(SUM(order_items.subtotal), 0) as total_revenue')
->groupBy('products.name')
->orderByDesc('total_qty')
->limit(5)
->get()
->toArray();
}
public function getMarketingSales(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED)
->whereNotNull('orders.marketing_id');
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
$orders = (clone $query)
->join('users', 'orders.marketing_id', '=', 'users.id')
->leftJoin('user_profiles', 'users.id', '=', 'user_profiles.user_id')
->select('orders.marketing_id', 'user_profiles.full_name as marketing_name')
->selectRaw('COUNT(DISTINCT orders.id) as total_orders')
->selectRaw('COALESCE(SUM(orders.total_amount), 0) as total_revenue')
->selectRaw('COALESCE(SUM(orders.subtotal), 0) as total_subtotal')
->selectRaw('COALESCE(SUM(orders.discount), 0) as total_discount')
->groupBy('orders.marketing_id', 'user_profiles.full_name')
->get();
$productCounts = (clone $query)
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->selectRaw('orders.marketing_id, SUM(order_items.quantity) as total_qty')
->groupBy('orders.marketing_id')
->pluck('total_qty', 'marketing_id');
return $orders->map(function ($item) use ($productCounts) {
$totalOrders = (int) $item->total_orders;
$totalRevenue = (int) $item->total_revenue;
return [
'marketing_name' => $item->marketing_name,
'total_orders' => $totalOrders,
'total_products_sold' => (int) ($productCounts[$item->marketing_id] ?? 0),
'total_revenue' => $totalRevenue,
'total_subtotal' => (int) $item->total_subtotal,
'total_discount' => (int) $item->total_discount,
'avg_order' => $totalOrders > 0 ? (int) ($totalRevenue / $totalOrders) : 0,
];
})->toArray();
}
public function getOrderStats(?string $startDate, ?string $endDate): array
{
$baseQuery = Order::query();
$this->applyDateFilter($baseQuery, $startDate, $endDate, 'orders.created_at');
$byChannel = collect(OrderChannel::values())->map(function ($channel) use ($baseQuery) {
$count = (clone $baseQuery)->where('channel', $channel)->count();
$label = OrderChannel::from($channel)->label();
return [
'channel' => $channel,
'label' => $label,
'count' => $count,
'total' => (int) (clone $baseQuery)->where('channel', $channel)->sum('total_amount'),
];
});
$byPaymentType = collect(PaymentType::values())->map(function ($paymentType) use ($baseQuery) {
$count = (clone $baseQuery)->where('payment_type', $paymentType)->count();
$label = PaymentType::from($paymentType)->label();
return [
'payment_type' => $paymentType,
'label' => $label,
'count' => $count,
'total' => (int) (clone $baseQuery)->where('payment_type', $paymentType)->sum('total_amount'),
];
});
$byMarketing = (clone $baseQuery)
->whereNotNull('marketing_id')
->select('marketing_id')
->selectRaw('COUNT(*) as count')
->selectRaw('COALESCE(SUM(total_amount), 0) as total')
->groupBy('marketing_id')
->with('marketing:id')
->get()
->map(fn($item) => [
'name' => $item->marketing?->userProfile->full_name ?? '-',
'count' => $item->count,
'total' => (int) $item->total,
]);
$byStatus = collect(OrderStatus::values())->map(function ($status) use ($baseQuery) {
$count = (clone $baseQuery)->where('status', $status)->count();
$label = OrderStatus::from($status)->label();
return [
'status' => $status,
'label' => $label,
'count' => $count,
];
});
return [
'by_channel' => $byChannel,
'by_payment_type' => $byPaymentType,
'by_marketing' => $byMarketing,
'by_status' => $byStatus,
];
}
private function applyDateFilter($query, ?string $startDate, ?string $endDate, string $dateColumn = 'created_at'): void
{
if ($startDate) {
$query->whereDate($dateColumn, '>=', $startDate);
}
if ($endDate) {
$query->whereDate($dateColumn, '<=', $endDate);
}
}
}

Some files were not shown because too many files have changed in this diff Show More