Compare commits

..

No commits in common. "485ae8e5ce0e301dd3122f9e02aa64d565d8c8b0" and "bc02af2a66f7255e2a0401364a5e5448a66aebdf" have entirely different histories.

273 changed files with 4142 additions and 14092 deletions

View File

@ -1,88 +0,0 @@
# 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 |

File diff suppressed because it is too large Load Diff

View File

@ -1,400 +0,0 @@
# 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) |

1159
AGENTS.md

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +0,0 @@
<?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,6 +121,11 @@ enum Permission: string
case PAYROLL_CANCEL = 'payroll.cancel';
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
case RESTOCKS_VIEW = 'restocks.view';
case RESTOCKS_CREATE = 'restocks.create';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,21 +1,18 @@
<?php
namespace App\Http\Controllers\Admin\Finance\Payroll;
namespace App\Http\Controllers\Admin\Finance;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Enums\PayrollPeriodStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Models\PayrollPeriod;
use App\Services\Admin\Finance\Payroll\PayrollPeriodService;
use App\Services\Admin\Finance\PayrollPeriodService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class PayrollPeriodController extends Controller
{
use HasRoleChecks;
public function __construct(
private PayrollPeriodService $service
) {}
@ -29,23 +26,22 @@ public function index(PaginatedRequest $request): Response
public function current(): RedirectResponse
{
$period = $this->service->getCurrentOrCreate();
$now = now();
$period = PayrollPeriod::firstOrCreate(
['year' => $now->year, 'month' => $now->month],
['status' => PayrollPeriodStatus::OPEN]
);
return to_route('admin.finance.payroll-periods.show', ['payroll_period' => $period->id]);
}
public function show(PayrollPeriod $payrollPeriod): Response
{
$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');
},
]);
$period = $this->service->getDetail($payrollPeriod);
return Inertia::render('admin/finance/payroll-period/show', [
'payrollPeriod' => $payrollPeriod,
'payrollPeriod' => $period,
]);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,17 +2,11 @@
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\Requests\Admin\Manage\TransactionRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Order;
use App\Models\User;
use App\Services\Admin\Manage\TransactionService;
use App\Services\Admin\Master\CustomerService;
use App\Services\Admin\Master\Product\ProductVariantService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
@ -21,8 +15,6 @@ class TransactionController extends Controller
{
public function __construct(
private TransactionService $service,
private ProductVariantService $productVariantService,
private CustomerService $customerService,
) {}
public function index(PaginatedRequest $request): Response
@ -43,19 +35,14 @@ public function index(PaginatedRequest $request): Response
public function create(): Response
{
return Inertia::render('admin/manage/transaction/create', [
'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(),
'data' => $this->service->getForCreate(),
]);
}
public function store(TransactionRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->store($request->validated()),
fn () => $this->service->create($request->validated()),
'Transaksi berhasil ditambahkan.',
'admin.manage.transactions.index',
'admin.manage.transactions.create'
@ -66,12 +53,7 @@ public function edit(Order $transaction): Response
{
return Inertia::render('admin/manage/transaction/edit', [
'transaction' => $this->service->getForEdit($transaction),
'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(),
'data' => $this->service->getForCreate(),
]);
}
@ -89,7 +71,7 @@ public function update(TransactionRequest $request, Order $transaction): Redirec
public function destroy(Order $transaction): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->destroy($transaction),
fn () => $this->service->delete($transaction),
'Transaksi berhasil dihapus.',
'admin.manage.transactions.index'
);
@ -103,16 +85,4 @@ public function updateStatus(Order $transaction): RedirectResponse
'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
{
$this->service->store($request->validated());
$this->service->create($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kategori berhasil ditambahkan.']);
return back();
return to_route('admin.master.categories.index');
}
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.']);
return back();
return to_route('admin.master.categories.index');
}
public function destroy(Category $category): RedirectResponse
{
$this->service->destroy($category);
$this->service->delete($category);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kategori berhasil dihapus.']);
return back();
return to_route('admin.master.categories.index');
}
}

View File

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

View File

@ -84,34 +84,6 @@ public function toggleStatus(Product $product): RedirectResponse
Inertia::flash('toast', ['type' => 'success', 'message' => "Status produk berhasil diubah menjadi {$status->label()}."]);
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();
return to_route('admin.master.products.index');
}
}

View File

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

View File

@ -36,7 +36,7 @@ public function create(): Response
public function store(RawMaterialRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->store($request->validated()),
fn () => $this->service->create($request->validated()),
'Bahan baku berhasil ditambahkan.',
'admin.master.raw-materials.index',
'admin.master.raw-materials.create'
@ -64,7 +64,7 @@ public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): R
public function destroy(RawMaterial $rawMaterial): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->destroy($rawMaterial),
fn () => $this->service->delete($rawMaterial),
'Bahan baku berhasil dihapus.',
'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}."]);
return back();
return to_route('admin.master.raw-materials.index');
}
}

View File

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

View File

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

View File

@ -1,73 +0,0 @@
<?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

@ -1,30 +0,0 @@
<?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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,46 +2,19 @@
namespace App\Http\Requests\Admin\HR;
use App\Models\LeaveRequest;
use Illuminate\Foundation\Http\FormRequest;
class LeaveRequestRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('leave_requests.create')
|| $this->user()->can('leave_requests.update');
return true;
}
public function rules(): array
{
$leaveRequest = $this->route('leaveRequest');
return [
'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.');
}
},
],
'start_date' => ['required', 'date', 'after_or_equal:today'],
'end_date' => ['required', 'date', 'after_or_equal:start_date'],
];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,14 +2,11 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Appends(['formatted_read_at'])]
#[Guarded(['id'])]
class AppNotification extends Model
{
@ -25,13 +22,6 @@ 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
{
return $this->belongsTo(User::class);

View File

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

View File

@ -2,15 +2,12 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Appends(['formatted_material_result', 'formatted_material_usage'])]
#[Guarded(['id'])]
class CuttingMaterial extends Model
{
@ -24,20 +21,6 @@ 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
{
return $this->belongsTo(CuttingMaterialCombination::class, 'combination_id');

View File

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

View File

@ -2,15 +2,12 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Appends(['formatted_cutting_result', 'formatted_original_outside_sample', 'formatted_sample'])]
#[Guarded(['id'])]
class CuttingResult extends Model
{
@ -25,27 +22,6 @@ 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
{
return $this->belongsTo(Cutting::class);

View File

@ -70,7 +70,7 @@ protected function formattedRemainingAmount(): Attribute
protected function statusLabel(): Attribute
{
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
{
return Attribute::make(
get: fn () => $this->paid_at?->translatedFormat('l, d F Y H:i'),
get: fn () => $this->paid_at?->translatedFormat('l, d F Y'),
);
}

View File

@ -45,7 +45,7 @@ protected function casts(): array
protected function channelLabel(): Attribute
{
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
{
return Attribute::make(
get: fn () => $this->payment_type?->label(),
get: fn () => $this->payment_type->label(),
);
}
protected function statusLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->status?->label(),
get: fn () => $this->status->label(),
);
}

View File

@ -0,0 +1,38 @@
<?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,7 +3,6 @@
namespace App\Models;
use App\Enums\PayrollPeriodStatus;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Scope;
@ -15,7 +14,7 @@
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Appends(['formatted_closed_at', 'month_name', 'status_label'])]
#[Appends(['status_label'])]
#[Guarded(['id'])]
class PayrollPeriod extends Model
{
@ -31,20 +30,6 @@ 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
{
return Attribute::make(

View File

@ -61,18 +61,6 @@ protected function inactive(Builder $query): void
$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
{
return $this->belongsToMany(Category::class, 'product_categories')

View File

@ -2,14 +2,11 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
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'])]
class RetailStockHistory extends Model
{
@ -28,41 +25,6 @@ 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
{
return $this->belongsTo(ProductVariant::class);

View File

@ -2,15 +2,12 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Appends(['formatted_quantity', 'formatted_stock_after', 'formatted_stock_before'])]
#[Guarded(['id'])]
class StockMutation extends Model
{
@ -25,27 +22,6 @@ 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
{
return $this->morphTo();

View File

@ -156,6 +156,16 @@ public function orderItems(): HasMany
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
{
return $this->hasMany(Payroll::class, 'paid_by_id');

View File

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

View File

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

View File

@ -1,13 +0,0 @@
<?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,9 +1,8 @@
<?php
namespace App\Services\Admin\Finance\Cash;
namespace App\Services\Admin\Finance;
use App\Enums\CashTransactionType;
use App\Enums\Role;
use App\Models\CashAccount;
use App\Models\CashTransaction;
use App\Services\Concerns\HandlesCashTransactions;
@ -12,22 +11,45 @@
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CashTransactionService
class CashAccountService
{
use HandlesCashTransactions, RegistersMedia;
public function __construct(
private S3PresignedService $s3Service,
private CashAccountService $cashAccountService
) {}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
public function get(): ?CashAccount
{
$cashAccount = $this->cashAccountService->get();
return CashAccount::select(['id', 'name', 'balance'])->first();
}
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) {
return new PaginationLengthAwarePaginator(collect(), 0, $perPage);
@ -48,6 +70,29 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
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
{
$transaction = DB::transaction(fn () => $this->creditCash(
@ -55,12 +100,12 @@ public function deposit(array $data): CashTransaction
description: $data['description'],
));
if (array_key_exists('receipt_key', $data)) {
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
if (! empty($data['receipt_key'])) {
$this->registerMedia($transaction, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
}
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Setoran Kas Toko',
body: 'Setoran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'),
@ -77,12 +122,12 @@ public function withdrawal(array $data): CashTransaction
type: CashTransactionType::WITHDRAWAL,
));
if (array_key_exists('receipt_key', $data)) {
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
if (! empty($data['receipt_key'])) {
$this->registerMedia($transaction, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
}
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Penarikan Kas Toko',
body: 'Penarikan sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'),
@ -91,7 +136,7 @@ public function withdrawal(array $data): CashTransaction
return $transaction;
}
public function update(CashTransaction $transaction, array $data): CashTransaction
public function updateTransaction(CashTransaction $transaction, array $data): CashTransaction
{
$transaction = DB::transaction(function () use ($transaction, $data) {
if (! in_array($transaction->type, [CashTransactionType::DEPOSIT, CashTransactionType::WITHDRAWAL])) {
@ -123,14 +168,27 @@ public function update(CashTransaction $transaction, array $data): CashTransacti
]);
if (array_key_exists('receipt_key', $data)) {
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
$currentMedia = $transaction->getFirstMedia('receipts');
$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;
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Transaksi Kas Diperbarui',
body: 'Transaksi kas sebesar Rp '.number_format($transaction->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'),
@ -139,7 +197,7 @@ public function update(CashTransaction $transaction, array $data): CashTransacti
return $transaction;
}
public function destroy(CashTransaction $transaction): bool
public function deleteTransaction(CashTransaction $transaction): bool
{
return DB::transaction(function () use ($transaction) {
if (! in_array($transaction->type, [CashTransactionType::DEPOSIT, CashTransactionType::WITHDRAWAL])) {
@ -163,18 +221,13 @@ public function destroy(CashTransaction $transaction): bool
$cashAccount->update(['balance' => $newBalance]);
$media = $transaction->getFirstMedia('receipts');
if ($media) {
Cache::forget("cash_transaction_receipt_{$media->id}");
}
$transaction->clearMediaCollection('receipts');
$deleted = $transaction->delete();
if ($deleted) {
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Transaksi Kas Dihapus',
body: 'Transaksi kas sebesar Rp '.number_format($transaction->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'),
@ -184,27 +237,4 @@ public function destroy(CashTransaction $transaction): bool
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,35 +2,48 @@
namespace App\Services\Admin\Finance;
use App\Concerns\HasRoleChecks;
use App\Enums\CashTransactionType;
use App\Enums\EmployeeAdvanceStatus;
use App\Enums\Role;
use App\Models\EmployeeAdvance;
use App\Models\EmployeeAdvancePayment;
use App\Services\Concerns\HandlesCashTransactions;
use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class EmployeeAdvanceService
{
use HandlesCashTransactions, HasRoleChecks;
use HandlesCashTransactions;
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
{
return EmployeeAdvance::query()
->select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at'])
->with(['employee.user.userProfile', 'payments.paidBy.userProfile'])
->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(! $this->canViewAll(), 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($search, fn ($q) => $q->where('description', 'like', "%{$search}%"))
->orderBy($sort, $direction)
->paginate($perPage);
}
public function store(array $data): EmployeeAdvance
public function create(array $data): EmployeeAdvance
{
$employee = auth()->user()->employee;
@ -51,7 +64,7 @@ public function store(array $data): EmployeeAdvance
$employeeAdvance->load('employee.user');
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Kasbon Baru',
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'),
@ -110,7 +123,7 @@ public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeA
return $employeeAdvance;
}
public function destroy(EmployeeAdvance $employeeAdvance): bool
public function delete(EmployeeAdvance $employeeAdvance): bool
{
return DB::transaction(function () use ($employeeAdvance) {
if ($employeeAdvance->status === EmployeeAdvanceStatus::APPROVED && $employeeAdvance->cash_transaction_id) {
@ -168,7 +181,7 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Kasbon Disetujui',
body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah disetujui'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.employee-advances.index'),
@ -221,7 +234,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, ',', '.').'.';
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: $employeeAdvance->status === EmployeeAdvanceStatus::PAID ? 'Kasbon Dibayar' : 'Pembayaran Kasbon',
body: $notificationBody,
url: route('admin.finance.employee-advances.index'),

View File

@ -3,7 +3,6 @@
namespace App\Services\Admin\Finance;
use App\Enums\CashTransactionType;
use App\Enums\Role;
use App\Models\CashAccount;
use App\Models\Expense;
use App\Services\Concerns\HandlesCashTransactions;
@ -11,6 +10,7 @@
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@ -23,6 +23,15 @@ public function __construct(
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
{
$paginator = Expense::query()
@ -37,7 +46,36 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator;
}
public function store(array $data): Expense
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;
// 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) {
$cashTransaction = $this->debitCash(
@ -52,15 +90,15 @@ public function store(array $data): Expense
'description' => $data['description'],
]);
if (array_key_exists('receipt_key', $data)) {
$this->syncReceipt($expense, $data['receipt_key'] ?? null, 'receipts', 'expense_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
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;
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Pengeluaran Baru',
body: 'Pengeluaran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.expenses.index'),
@ -100,14 +138,33 @@ public function update(Expense $expense, array $data): Expense
]);
if (array_key_exists('receipt_key', $data)) {
$this->syncReceipt($expense, $data['receipt_key'] ?? null, 'receipts', 'expense_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
$currentMedia = $expense->getFirstMedia('receipts');
$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;
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Pengeluaran Diperbarui',
body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.expenses.index'),
@ -116,7 +173,7 @@ public function update(Expense $expense, array $data): Expense
return $expense;
}
public function destroy(Expense $expense): bool
public function delete(Expense $expense): bool
{
return DB::transaction(function () use ($expense) {
$this->creditCash(
@ -125,6 +182,7 @@ public function destroy(Expense $expense): bool
type: CashTransactionType::DEPOSIT,
);
// Invalidate receipt cache
$media = $expense->getFirstMedia('receipts');
if ($media) {
Cache::forget("expense_receipt_{$media->id}");
@ -136,7 +194,7 @@ public function destroy(Expense $expense): bool
if ($deleted) {
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Pengeluaran Dihapus',
body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.expenses.index'),
@ -146,32 +204,4 @@ public function destroy(Expense $expense): bool
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
namespace App\Services\Admin\Finance\Payroll;
namespace App\Services\Admin\Finance;
use App\Enums\PayrollAdjustmentType;
use App\Enums\PayrollStatus;
@ -11,7 +11,7 @@
class PayrollAdjustmentService
{
public function store(Payroll $payroll, array $data): PayrollAdjustment
public function create(Payroll $payroll, array $data): PayrollAdjustment
{
if ($payroll->status !== PayrollStatus::UNPAID) {
throw ValidationException::withMessages([
@ -35,7 +35,7 @@ public function store(Payroll $payroll, array $data): PayrollAdjustment
});
}
public function destroy(PayrollAdjustment $adjustment): bool
public function delete(PayrollAdjustment $adjustment): bool
{
$payroll = $adjustment->payroll;

View File

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

View File

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

View File

@ -2,17 +2,46 @@
namespace App\Services\Admin\HR;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Models\Employee;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class EmployeeService
{
use HasRoleChecks;
private const ADMIN_ROLES = ['developer', 'owner', 'direktur', 'admin-toko'];
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
{
@ -24,11 +53,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']),
'roles' => fn ($q) => $q->select(['id', 'name']),
])
->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), function ($q) {
->when(! $this->canViewAll(), function ($q) {
$userRoles = auth()->user()->roles->pluck('name');
$q->whereHas('roles', fn ($rq) => $rq->whereIn('name', $userRoles));
})
->when(self::hasAnyRole([Role::ADMIN_TOKO, Role::DIREKTUR]), fn ($q) => $q->whereDoesntHave('roles', fn ($rq) => $rq->where('name', Role::ADMIN_BAHAN_BAKU->value)))
->when($this->shouldHideAdminBahanBaku(), fn ($q) => $q->whereDoesntHave('roles', fn ($rq) => $rq->where('name', 'admin-bahan-baku')))
->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(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN)))
@ -37,18 +66,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage);
}
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
public function create(array $data): User
{
return DB::transaction(function () use ($data) {
$user = User::create([
@ -114,7 +132,7 @@ public function update(User $user, array $data): User
return $user->fresh(['userProfile', 'employee']);
}
public function destroy(User $user): bool
public function delete(User $user): bool
{
return DB::transaction(function () use ($user) {
$user->employee()->delete();

View File

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

View File

@ -6,6 +6,7 @@
use App\Models\CuttingMaterial;
use App\Models\CuttingMaterialCombination;
use App\Models\CuttingResult;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Services\Concerns\RegistersMedia;
use App\Services\S3PresignedService;
@ -60,6 +61,28 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
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
{
$cutting->load([
@ -120,7 +143,7 @@ public function getForEdit(Cutting $cutting): array
];
}
public function store(array $data): Cutting
public function create(array $data): Cutting
{
return DB::transaction(function () use ($data) {
foreach ($data['materials'] as $materialData) {
@ -326,7 +349,7 @@ public function update(Cutting $cutting, array $data): Cutting
});
}
public function destroy(Cutting $cutting): bool
public function delete(Cutting $cutting): bool
{
return DB::transaction(function () use ($cutting) {
$cutting->load('cuttingMaterials.rawMaterialPrice');

View File

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

View File

@ -4,7 +4,7 @@
use App\Enums\PriceType;
use App\Enums\ProductStockQuality;
use App\Enums\Role;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Restock;
use App\Models\RestockItem;
@ -59,7 +59,65 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator;
}
public function store(array $data): Restock
public function getForCreate(): array
{
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) {
$now = now();
@ -85,7 +143,7 @@ public function store(array $data): Restock
$this->syncPhoto($restock, $data);
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Admin Toko'],
title: 'Restock Baru',
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'),
@ -131,7 +189,7 @@ public function update(Restock $restock, array $data): Restock
});
}
public function destroy(Restock $restock): bool
public function delete(Restock $restock): bool
{
return DB::transaction(function () use ($restock) {
$restock->load('restockItems');

View File

@ -7,10 +7,10 @@
use App\Enums\PaymentType;
use App\Enums\PriceType;
use App\Enums\ProductStockQuality;
use App\Enums\Role;
use App\Models\Customer;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\User;
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
{
$paginator = Order::query()
->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'])
->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'])
->with([
'createdBy:id',
'createdBy.userProfile:id,user_id,full_name',
@ -96,7 +96,7 @@ public function getSummary(array $filters = []): array
$query = Order::query()
->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(subtotal), 0) as total_subtotal')
->selectRaw('COALESCE(SUM(discount) + SUM(COALESCE(nego_price, 0)), 0) as total_discount')
->selectRaw('COALESCE(SUM(subtotal) - SUM(COALESCE(nego_price, subtotal)), 0) as total_discount')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_amount')
->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs')
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
@ -143,7 +143,83 @@ public function getFilterOptions(): array
];
}
public function store(array $data): Order
public function getForCreate(): array
{
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) {
$now = now();
@ -158,7 +234,7 @@ public function store(array $data): Order
$discount = (int) ($data['discount'] ?? 0);
$negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null;
$totalAmount = $subtotal - $discount - ($negoPrice ?? 0);
$totalAmount = $subtotal - $discount + ($negoPrice ?? 0);
$order = Order::create([
'created_by_id' => auth()->id(),
@ -192,7 +268,7 @@ public function store(array $data): Order
}
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Admin Toko'],
title: 'Transaksi Baru',
body: 'Transaksi '.$order->order_number.' sebesar Rp '.number_format($totalAmount, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.transactions.index'),
@ -225,7 +301,7 @@ public function update(Order $order, array $data): Order
$discount = (int) ($data['discount'] ?? 0);
$negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null;
$totalAmount = $subtotal - $discount - ($negoPrice ?? 0);
$totalAmount = $subtotal - $discount + ($negoPrice ?? 0);
foreach ($itemRows as &$row) {
$row['order_id'] = $order->id;
@ -263,7 +339,7 @@ public function update(Order $order, array $data): Order
});
}
public function destroy(Order $order): bool
public function delete(Order $order): bool
{
return DB::transaction(function () use ($order) {
$order->load('orderItems');

View File

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

View File

@ -8,6 +8,11 @@
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
{
return Customer::query()
@ -17,12 +22,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage);
}
public function getAll(): Collection
{
return Customer::select(['id', 'name'])->orderBy('name')->get();
}
public function store(array $data): Customer
public function create(array $data): Customer
{
return Customer::create($data);
}
@ -34,7 +34,7 @@ public function update(Customer $customer, array $data): Customer
return $customer;
}
public function destroy(Customer $customer): bool
public function delete(Customer $customer): bool
{
return $customer->delete();
}

View File

@ -2,48 +2,70 @@
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\ProductVariant;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use App\Services\StockMutationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ProductService
{
use HasRoleChecks;
public function __construct(
private ProductVariantService $variantService,
private S3PresignedService $s3Service,
private StockMutationService $stockMutationService,
) {}
public function getNames(): Collection
public function getNames(): array
{
return Product::select(['id', 'name'])
return Product::where('status', '!=', 'deleted')
->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();
$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
{
$paginator = Product::query()
->select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason'])
->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',
])
->when($search, fn($q) => $q->where('name', 'like', "%{$search}%"))
->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['category'] ?? null, fn($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
->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['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
$cq->where('categories.id', $categoryId);
}))
->when(($filters['stock'] ?? null) === 'empty', function ($q) {
@ -58,24 +80,20 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
$paginator->getCollection()->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();
$variant->photo_urls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray();
});
});
return $paginator;
}
public function store(array $data): Product
public function create(array $data): Product
{
$status = self::hasAnyRole([Role::DEVELOPER, Role::OWNER])
? ($data['status'] ?? ProductStatus::ACTIVE)
: ProductStatus::PENDING;
$product = DB::transaction(function () use ($data, $status) {
$product = DB::transaction(function () use ($data) {
$product = Product::create([
'name' => $data['name'],
'description' => $data['description'] ?? null,
'status' => $status,
'status' => $data['status'] ?? 'active',
]);
$product->categories()->sync($data['category_ids']);
@ -84,7 +102,7 @@ public function store(array $data): Product
// Bulk insert variants
$now = now();
$variantRows = collect($data['variants'])->map(fn($v) => [
$variantRows = collect($data['variants'])->map(fn ($v) => [
'product_id' => $product->id,
'name' => $v['name'],
'stock' => $v['stock'],
@ -98,7 +116,7 @@ public function store(array $data): Product
// Map variant name -> variant ID
$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
$priceRows = [];
@ -142,9 +160,9 @@ public function store(array $data): Product
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
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'),
);
@ -162,7 +180,7 @@ public function getForEdit(Product $product): array
$variants = $product->productVariants->map(function (ProductVariant $variant) {
$media = $variant->getMedia('photos');
$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 [
'id' => $variant->id,
@ -172,7 +190,7 @@ public function getForEdit(Product $product): array
'retail_stock' => $variant->retail_stock,
'photo_keys' => $photoKeys,
'photo_urls' => $photoUrls,
'prices' => $variant->productPrices->map(fn($p) => [
'prices' => $variant->productPrices->map(fn ($p) => [
'type' => $p->type->value,
'price' => $p->price,
]),
@ -191,19 +209,11 @@ public function getForEdit(Product $product): array
public function update(Product $product, array $data): Product
{
$this->assertNotPending($product);
$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([
'name' => $data['name'],
'description' => $data['description'] ?? null,
'status' => $newStatus,
'status' => $data['status'] ?? $product->status,
]);
$product->categories()->sync($data['category_ids']);
@ -230,7 +240,7 @@ public function update(Product $product, array $data): Product
$existingVariantsMap = ProductVariant::whereIn('id', $existingVariantIds)
->with('media')
->get()
->mapWithKeys(fn($v) => [$v->id => $v]);
->mapWithKeys(fn ($v) => [$v->id => $v]);
// Collect old stock data + identify changed variants
$oldStockDataMap = [];
@ -256,7 +266,7 @@ public function update(Product $product, array $data): Product
// Bulk update changed variants (only changed ones, not all)
if ($changedIds !== []) {
$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) {
$existingVariantsMap[$variantData['id']]->update([
@ -269,11 +279,11 @@ public function update(Product $product, array $data): Product
}
// 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 = [];
if ($newVariantsData->isNotEmpty()) {
$newVariantRows = $newVariantsData->map(fn($v) => [
$newVariantRows = $newVariantsData->map(fn ($v) => [
'product_id' => $product->id,
'name' => $v['name'],
'stock' => $v['stock'],
@ -290,7 +300,7 @@ public function update(Product $product, array $data): Product
->whereIn('name', $newVariantsData->pluck('name')->toArray())
->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
@ -354,7 +364,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(
$changedModels,
$oldStockDataMap,
@ -393,19 +403,17 @@ public function update(Product $product, array $data): Product
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
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'),
);
return $product;
}
public function destroy(Product $product): bool
public function delete(Product $product): bool
{
$this->assertNotPending($product);
$result = DB::transaction(function () use ($product) {
$product->productVariants->each(function (ProductVariant $variant) {
$variant->productPrices()->delete();
@ -419,9 +427,9 @@ public function destroy(Product $product): bool
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
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'),
);
@ -430,65 +438,8 @@ public function destroy(Product $product): bool
public function toggleStatus(Product $product): void
{
$this->assertNotPending($product);
$product->update([
'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,10 +2,6 @@
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\ProductVariant;
use App\Services\Concerns\RegistersMedia;
@ -17,66 +13,13 @@
class ProductVariantService
{
use HasRoleChecks, RegistersMedia;
use RegistersMedia;
public function __construct(
private S3PresignedService $s3Service,
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
{
$variant->load([
@ -106,8 +49,6 @@ public function getForEdit(ProductVariant $variant): array
public function update(ProductVariant $variant, array $data): ProductVariant
{
$this->assertNotPending($variant->product);
DB::transaction(function () use ($variant, $data) {
$oldData = $variant->only(['stock', 'reject_stock', 'retail_stock']);
@ -142,7 +83,7 @@ public function update(ProductVariant $variant, array $data): ProductVariant
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Varian Diperbarui',
body: "Varian \"{$variant->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
@ -151,10 +92,8 @@ public function update(ProductVariant $variant, array $data): ProductVariant
return $variant->fresh();
}
public function destroy(Product $product, ProductVariant $variant): bool
public function delete(Product $product, ProductVariant $variant): bool
{
$this->assertNotPending($product);
$result = DB::transaction(function () use ($variant) {
$variant->productPrices()->delete();
$variant->clearMediaCollection('photos');
@ -163,7 +102,7 @@ public function destroy(Product $product, ProductVariant $variant): bool
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Varian Dihapus',
body: "Varian \"{$variant->name}\" dari produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
@ -195,8 +134,6 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
{
$quantity = (int) $data['quantity'];
$this->assertNotPending($variant->product);
if ($variant->stock < $quantity) {
throw ValidationException::withMessages([
'quantity' => "Stok bagus tidak mencukupi. Stok tersedia: {$variant->stock}.",
@ -224,7 +161,7 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Transfer Stok',
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'),
@ -232,13 +169,4 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
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,6 +7,7 @@
use App\Services\Concerns\RegistersMedia;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class RawMaterialService
@ -17,6 +18,27 @@ public function __construct(
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
{
$paginator = RawMaterial::query()
@ -49,7 +71,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator;
}
public function store(array $data): RawMaterial
public function create(array $data): RawMaterial
{
return DB::transaction(function () use ($data) {
$rawMaterial = RawMaterial::create([
@ -198,7 +220,7 @@ public function update(RawMaterial $rawMaterial, array $data): RawMaterial
});
}
public function destroy(RawMaterial $rawMaterial): bool
public function delete(RawMaterial $rawMaterial): bool
{
return DB::transaction(function () use ($rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {

View File

@ -2,7 +2,6 @@
namespace App\Services\Admin\Master\RawMaterial;
use App\Enums\Role;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Services\Concerns\RegistersMedia;
@ -18,27 +17,6 @@ public function __construct(
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
{
$variant->load('media');
@ -83,7 +61,7 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Varian Diperbarui',
body: "Varian \"{$variant->variant}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'),
@ -92,7 +70,7 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
return $variant->fresh();
}
public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool
public function delete(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool
{
$result = DB::transaction(function () use ($variant) {
$variant->clearMediaCollection('photos');
@ -101,7 +79,7 @@ public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bo
});
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Varian Dihapus',
body: "Varian \"{$variant->variant}\" dari bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'),

View File

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

View File

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

@ -1,578 +0,0 @@
<?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