diff --git a/.ai/CONTEXT.md b/.ai/CONTEXT.md index d3d1452..a6735b6 100644 --- a/.ai/CONTEXT.md +++ b/.ai/CONTEXT.md @@ -66,10 +66,10 @@ ## Module Overview | 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/CashAccountService` | +| 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/PayrollPeriodService` | +| 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` | diff --git a/.ai/CONVENTIONS.md b/.ai/CONVENTIONS.md index b0550af..d8c3a1f 100644 --- a/.ai/CONVENTIONS.md +++ b/.ai/CONVENTIONS.md @@ -178,6 +178,9 @@ ### Urutan Isi Model public function categories(): BelongsToMany { ... } public function productVariants(): HasMany { ... } } + +// ✅ Trait stacking — gabungkan dengan koma +use HasFactory, SoftDeletes; // 1 baris, pisah koma ``` ### Casting — Wajib untuk Semua Tipe @@ -698,7 +701,7 @@ ### Penamaan Method — HARUS KONSISTEN } // 2. getAll() — untuk semua data (tanpa paginasi) -public function getAll(array $filters = []): Collection +public function getAll(): Collection { return Product::query() ->select(['id', 'name']) @@ -753,6 +756,36 @@ ### Property Declaration } ``` +### Urutan Method — KONSISTEN +```php +// ✅ Urutan method di service WAJIB konsisten: +// 1. CRUD (paginated, getAll, store, update, destroy) +// 2. Custom methods (business logic) +// 3. Private helpers (di paling bawah) + +class ProductService +{ + // 1. CRUD + public function paginated(...): LengthAwarePaginator { ... } + public function getAll(...): Collection { ... } + public function store(array $data): Product { ... } + public function update(Product $product, array $data): Product { ... } + public function destroy(Product $product): bool { ... } + + // 2. Custom methods + public function getNames(): array { ... } + public function getForEdit(Product $product): array { ... } + public function toggleStatus(Product $product): void { ... } + public function approve(Product $product): void { ... } + public function reject(Product $product, string $reason): void { ... } + public function resubmit(Product $product): void { ... } + + // 3. Private helpers (paling bawah) + private function canVerify(): bool { ... } + private function assertNotPending(Product $product): void { ... } +} +``` + ### Service Concerns (Traits) ```php // ✅ Gunakan traits untuk kode yang berulang di banyak service @@ -800,6 +833,64 @@ ### Service Concerns (Traits) return $restock; } } + +// 4. HasRoleChecks — untuk role checking +use App\Concerns\HasRoleChecks; + +class ProductService +{ + use HasRoleChecks; + + public function store(array $data): Product + { + $status = self::hasAnyRole([Role::DEVELOPER, Role::OWNER]) + ? ProductStatus::ACTIVE + : ProductStatus::PENDING; + // ... + } +} +``` + +### Trait Stacking — Gunakan Koma +```php +// ✅ Gabungkan multiple traits dengan koma dalam 1 baris +class PayrollPeriodService +{ + use HandlesCashTransactions, HasRoleChecks; +} + +class AttendanceService +{ + use RegistersMedia, HasRoleChecks; +} + +// ❌ JANGAN pisah per baris +class PayrollPeriodService +{ + use HandlesCashTransactions; + use HasRoleChecks; // JANGAN +} +``` + +### Role Checking — Gunakan RoleEnum +```php +// ✅ SELALU gunakan Role enum, JANGAN string +use App\Concerns\HasRoleChecks; +use App\Enums\Role; + +class ProductService +{ + use HasRoleChecks; + + public function store(array $data): Product + { + // ✅ BENAR — pakai Role enum + if (self::hasAnyRole([Role::DEVELOPER, Role::OWNER])) { ... } + + // ❌ SALAH — pakai string + if (auth()->user()->hasAnyRole(['developer', 'owner'])) { ... } + } +} ``` ### Cash Transaction Pattern @@ -820,6 +911,44 @@ ### Stock Adjustment Pattern $this->adjustStock($model, $field, $quantity, $sign); ``` +### NotificationService — Gunakan RoleEnum +```php +// ✅ SELALU gunakan Role enum untuk roles parameter +use App\Enums\Role; +use App\Services\NotificationService; + +NotificationService::notify( + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], + title: 'Judul Notifikasi', + body: 'Isi notifikasi', + url: route('admin.module.index'), +); + +// ❌ JANGAN pakai string +NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], // JANGAN + ... +); +``` + +### Media Sync — Gunakan syncPhoto/syncReceipt +```php +// ✅ SELALU gunakan syncPhoto untuk photo_key, syncReceipt untuk receipt_key +// Trait: App\Services\Concerns\RegistersMedia + +// Photo — otomatis handle comparison + clear + register +$this->syncPhoto($model, $data, 'photos'); + +// Receipt — otomatis handle comparison + cache clearing + clear + register +$this->syncReceipt($model, $data['receipt_key'] ?? null, 'receipts', 'cache_prefix', $data['file_size'] ?? null, $data['file_mime_type'] ?? null); + +// ❌ JANGAN handle manual +if (! empty($data['photo_key'])) { + $model->clearMediaCollection('photos'); + $this->registerMedia($model, $data['photo_key'], 'photos'); // JANGAN +} +``` + --- @@ -1544,6 +1673,8 @@ ### ✅ Do - Selalu pindahkan logic ke Service (controller = lalu lintas saja) - Selalu pakai nama method yang konsisten di semua service (`paginated`, `getAll`, `store`, `update`, `destroy`) - Selalu cari kode berulang → jadikan Trait/Concern +- Selalu pakai `Role` enum untuk role checking & notifikasi (bukan string) +- Selalu pakai `syncPhoto`/`syncReceipt` untuk media handling (bukan manual) - Selalu pakai `withTrashed()` untuk relasi ke model yang mungkin di-soft-delete - Selalu pakai return type di SEMUA method (controller & service) - **Frontend**: Selalu gunakan `formatted_*` accessor dari model, bukan format di TypeScript @@ -1557,6 +1688,8 @@ ### ❌ Don't - Jangan pakai `$fillable` (pakai `#[Guarded]`) - Jangan biarkan kolom tanpa cast - Jangan pakai string biasa untuk data opsi (pakai Enum) +- Jangan pakai string untuk role checking (pakai `Role` enum) +- Jangan handle media sync manual (pakai `syncPhoto`/`syncReceipt`) - Jangan lupa buat scope untuk data opsi, gunakan `#[Scope]` + return type `void` - Jangan pakai `public function` untuk scope (pakai `protected function`) - Jangan lupa tulis relasi 2 ARAH diff --git a/AGENTS.md b/AGENTS.md index cf01ebd..15c937b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,6 +143,7 @@ # Manual create - [ ] Method wajib: `paginated()`, `getAll()`, `store()`, `update()`, `destroy()` - [ ] Select kolom yang dibutuhkan + eager load relasi - [ ] Gunakan traits jika perlu: `HandlesCashTransactions`, `HasStockAdjustment`, `RegistersMedia` +- [ ] Urutan method: CRUD → custom methods → private helpers (di paling bawah) ### 5. Form Request ```bash diff --git a/app/Concerns/HasRoleChecks.php b/app/Concerns/HasRoleChecks.php new file mode 100644 index 0000000..9d4930f --- /dev/null +++ b/app/Concerns/HasRoleChecks.php @@ -0,0 +1,15 @@ +user()->hasAnyRole( + array_map(fn (Role $role) => $role->value, $roles) + ); + } +} diff --git a/app/Enums/Role.php b/app/Enums/Role.php index 785555b..935eaf6 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -2,11 +2,8 @@ namespace App\Enums; -use App\Traits\ProvidesEnumOptions; - enum Role: string { - use ProvidesEnumOptions; case DEVELOPER = 'developer'; case OWNER = 'owner'; @@ -95,7 +92,6 @@ public function permissions(): array Permission::CUSTOMERS_VIEW, Permission::PRODUCTS_VIEW, - Permission::STOCKS_VIEW, Permission::ORDERS_VIEW, @@ -139,8 +135,6 @@ public function permissions(): array Permission::ANALYSIS_TOP_PRODUCTS, Permission::ANALYSIS_MARKETING_SALES, - Permission::STOCKS_VIEW, - Permission::STOK_OPNAMES_VIEW, Permission::ATTENDANCES_VIEW, @@ -167,7 +161,6 @@ public function permissions(): array Permission::PRODUCTS_UPDATE, Permission::PRODUCTS_DELETE, Permission::PRODUCTS_TOGGLE_STATUS, - Permission::STOCKS_VIEW, Permission::ORDERS_VIEW, Permission::ORDERS_CREATE, @@ -293,7 +286,6 @@ public function permissions(): array Permission::RAW_MATERIALS_CREATE, Permission::RAW_MATERIALS_UPDATE, Permission::RAW_MATERIALS_TOGGLE_STATUS, - Permission::STOCKS_VIEW, Permission::PRODUCTS_VIEW, Permission::PRODUCTS_CREATE, @@ -393,7 +385,6 @@ public function permissions(): array Permission::LEAVE_REQUESTS_DELETE, Permission::PRODUCTS_VIEW, - Permission::STOCKS_VIEW, Permission::STOK_OPNAMES_VIEW, Permission::STOK_OPNAMES_CREATE, diff --git a/app/Http/Controllers/Admin/Finance/CashAccountController.php b/app/Http/Controllers/Admin/Finance/CashAccountController.php index 609abad..5965b51 100644 --- a/app/Http/Controllers/Admin/Finance/CashAccountController.php +++ b/app/Http/Controllers/Admin/Finance/CashAccountController.php @@ -7,7 +7,8 @@ use App\Http\Requests\Admin\Finance\CashTransactionRequest; use App\Http\Requests\PaginatedRequest; use App\Models\CashTransaction; -use App\Services\Admin\Finance\CashAccountService; +use App\Services\Admin\Finance\Cash\CashAccountService; +use App\Services\Admin\Finance\Cash\CashTransactionService; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; @@ -15,14 +16,15 @@ class CashAccountController extends Controller { public function __construct( - private CashAccountService $service + private CashAccountService $cashAccountService, + private CashTransactionService $cashTransactionService, ) {} public function index(PaginatedRequest $request): Response { return Inertia::render('admin/finance/cash-account/index', [ - 'cashAccount' => $this->service->get(), - 'transactions' => $this->service->paginatedTransactions( + 'cashAccount' => $this->cashAccountService->get(), + 'transactions' => $this->cashTransactionService->paginated( ...$request->validatedWithDefaults(), filters: $request->only(['type']), ), @@ -36,7 +38,7 @@ public function index(PaginatedRequest $request): Response public function deposit(CashTransactionRequest $request): RedirectResponse { return $this->handleAction( - fn() => $this->service->deposit($request->validated()), + fn () => $this->cashTransactionService->deposit($request->validated()), 'Deposit berhasil ditambahkan.', 'admin.finance.cash-accounts.index' ); @@ -45,7 +47,7 @@ public function deposit(CashTransactionRequest $request): RedirectResponse public function withdrawal(CashTransactionRequest $request): RedirectResponse { return $this->handleAction( - fn() => $this->service->withdrawal($request->validated()), + fn () => $this->cashTransactionService->withdrawal($request->validated()), 'Withdrawal berhasil ditambahkan.', 'admin.finance.cash-accounts.index' ); @@ -54,7 +56,7 @@ public function withdrawal(CashTransactionRequest $request): RedirectResponse public function update(CashTransactionRequest $request, CashTransaction $transaction): RedirectResponse { return $this->handleAction( - fn() => $this->service->updateTransaction($transaction, $request->validated()), + fn () => $this->cashTransactionService->update($transaction, $request->validated()), 'Transaksi berhasil diperbarui.', 'admin.finance.cash-accounts.index' ); @@ -63,7 +65,7 @@ public function update(CashTransactionRequest $request, CashTransaction $transac public function destroy(CashTransaction $transaction): RedirectResponse { return $this->handleAction( - fn() => $this->service->deleteTransaction($transaction), + fn () => $this->cashTransactionService->destroy($transaction), 'Transaksi berhasil dihapus.', 'admin.finance.cash-accounts.index' ); diff --git a/app/Http/Controllers/Admin/Finance/Payroll/PayrollAdjustmentController.php b/app/Http/Controllers/Admin/Finance/Payroll/PayrollAdjustmentController.php index 2cd57d4..55a0066 100644 --- a/app/Http/Controllers/Admin/Finance/Payroll/PayrollAdjustmentController.php +++ b/app/Http/Controllers/Admin/Finance/Payroll/PayrollAdjustmentController.php @@ -6,7 +6,7 @@ use App\Http\Requests\Admin\Finance\PayrollAdjustmentRequest; use App\Models\Payroll; use App\Models\PayrollAdjustment; -use App\Services\Admin\Finance\PayrollAdjustmentService; +use App\Services\Admin\Finance\Payroll\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->store($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->destroy($payrollAdjustment), 'Adjustment gaji berhasil dihapus.', 'admin.finance.payroll-periods.show', parameters: ['payroll_period' => $payrollAdjustment->payroll->payroll_period_id] diff --git a/app/Http/Controllers/Admin/Finance/Payroll/PayrollController.php b/app/Http/Controllers/Admin/Finance/Payroll/PayrollController.php index 4113904..900ddb1 100644 --- a/app/Http/Controllers/Admin/Finance/Payroll/PayrollController.php +++ b/app/Http/Controllers/Admin/Finance/Payroll/PayrollController.php @@ -4,7 +4,7 @@ use App\Http\Controllers\Controller; use App\Models\Payroll; -use App\Services\Admin\Finance\PayrollPeriodService; +use App\Services\Admin\Finance\Payroll\PayrollPeriodService; use Illuminate\Http\RedirectResponse; class PayrollController extends Controller @@ -16,7 +16,7 @@ public function __construct( public function pay(Payroll $payroll): RedirectResponse { return $this->handleAction( - fn() => $this->service->pay($payroll), + fn () => $this->service->pay($payroll), 'Gaji berhasil dibayar.', 'admin.finance.payroll-periods.show', parameters: ['payroll_period' => $payroll->payroll_period_id] @@ -26,7 +26,7 @@ public function pay(Payroll $payroll): RedirectResponse public function cancel(Payroll $payroll): RedirectResponse { return $this->handleAction( - fn() => $this->service->cancel($payroll), + fn () => $this->service->cancel($payroll), 'Gaji berhasil dibatalkan.', 'admin.finance.payroll-periods.show', parameters: ['payroll_period' => $payroll->payroll_period_id] diff --git a/app/Http/Controllers/Admin/Finance/Payroll/PayrollPeriodController.php b/app/Http/Controllers/Admin/Finance/Payroll/PayrollPeriodController.php index 900044d..682e27f 100644 --- a/app/Http/Controllers/Admin/Finance/Payroll/PayrollPeriodController.php +++ b/app/Http/Controllers/Admin/Finance/Payroll/PayrollPeriodController.php @@ -2,16 +2,20 @@ namespace App\Http\Controllers\Admin\Finance\Payroll; +use App\Concerns\HasRoleChecks; +use App\Enums\Role; use App\Http\Controllers\Controller; use App\Http\Requests\PaginatedRequest; use App\Models\PayrollPeriod; -use App\Services\Admin\Finance\PayrollPeriodService; +use App\Services\Admin\Finance\Payroll\PayrollPeriodService; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; class PayrollPeriodController extends Controller { + use HasRoleChecks; + public function __construct( private PayrollPeriodService $service ) {} @@ -32,15 +36,23 @@ public function current(): RedirectResponse 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'); + }, + ]); + return Inertia::render('admin/finance/payroll-period/show', [ - 'payrollPeriod' => $this->service->getDetail($payrollPeriod), + 'payrollPeriod' => $payrollPeriod, ]); } public function close(PayrollPeriod $payrollPeriod): RedirectResponse { return $this->handleAction( - fn() => $this->service->close($payrollPeriod), + fn () => $this->service->close($payrollPeriod), 'Periode gaji berhasil ditutup.', 'admin.finance.payroll-periods.index' ); @@ -49,7 +61,7 @@ public function close(PayrollPeriod $payrollPeriod): RedirectResponse public function reopen(PayrollPeriod $payrollPeriod): RedirectResponse { return $this->handleAction( - fn() => $this->service->reopen($payrollPeriod), + fn () => $this->service->reopen($payrollPeriod), 'Periode gaji berhasil dibuka kembali.', 'admin.finance.payroll-periods.index' ); diff --git a/app/Http/Controllers/Admin/HR/AttendanceController.php b/app/Http/Controllers/Admin/HR/AttendanceController.php index 558e041..b39204a 100644 --- a/app/Http/Controllers/Admin/HR/AttendanceController.php +++ b/app/Http/Controllers/Admin/HR/AttendanceController.php @@ -30,7 +30,7 @@ public function index(Request $request): Response public function store(AttendanceRequest $request): RedirectResponse { return $this->handleAction( - fn() => $this->service->checkIn($request->validated()), + fn () => $this->service->checkIn($request->validated()), 'Berhasil check-in.', 'admin.hr.attendances.index' ); @@ -39,7 +39,7 @@ public function store(AttendanceRequest $request): RedirectResponse public function update(AttendanceRequest $request, Attendance $attendance): RedirectResponse { return $this->handleAction( - fn() => $this->service->checkOut($attendance, $request->validated()), + fn () => $this->service->checkOut($attendance, $request->validated()), 'Berhasil check-out.', 'admin.hr.attendances.index' ); diff --git a/app/Http/Controllers/Admin/HR/EmployeeController.php b/app/Http/Controllers/Admin/HR/EmployeeController.php index 4c17f13..6d269ef 100644 --- a/app/Http/Controllers/Admin/HR/EmployeeController.php +++ b/app/Http/Controllers/Admin/HR/EmployeeController.php @@ -42,7 +42,7 @@ public function create(): Response public function store(EmployeeRequest $request): RedirectResponse { return $this->handleAction( - fn() => $this->service->store($request->validated()), + fn () => $this->service->store($request->validated()), 'Pegawai berhasil ditambahkan.', 'admin.hr.employees.index' ); @@ -62,7 +62,7 @@ public function edit(User $user): Response public function update(EmployeeRequest $request, User $user): RedirectResponse { return $this->handleAction( - fn() => $this->service->update($user, $request->validated()), + fn () => $this->service->update($user, $request->validated()), 'Pegawai berhasil diperbarui.', 'admin.hr.employees.index' ); @@ -71,7 +71,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->destroy($user), 'Pegawai berhasil dihapus.', 'admin.hr.employees.index' ); @@ -90,7 +90,7 @@ public function toggleActive(User $user): RedirectResponse public function resetPassword(User $user): RedirectResponse { return $this->handleAction( - fn() => $this->service->resetPassword($user), + fn () => $this->service->resetPassword($user), 'Kata sandi pegawai berhasil direset ke kata sandi default.', 'admin.hr.employees.index' ); diff --git a/app/Http/Controllers/Admin/Manage/CuttingController.php b/app/Http/Controllers/Admin/Manage/CuttingController.php index 1ea9f12..df6ae22 100644 --- a/app/Http/Controllers/Admin/Manage/CuttingController.php +++ b/app/Http/Controllers/Admin/Manage/CuttingController.php @@ -7,6 +7,7 @@ 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; @@ -15,6 +16,7 @@ class CuttingController extends Controller { public function __construct( private CuttingService $service, + private RawMaterialVariantService $rawMaterialVariantService, ) {} public function index(PaginatedRequest $request): Response @@ -29,14 +31,14 @@ public function index(PaginatedRequest $request): Response public function create(): Response { return Inertia::render('admin/manage/cutting/create', [ - 'data' => $this->service->getForCreate(), + 'rawMaterials' => $this->rawMaterialVariantService->getForCutting(), ]); } public function store(CuttingRequest $request): RedirectResponse { return $this->handleAction( - fn () => $this->service->create($request->validated()), + fn () => $this->service->store($request->validated()), 'Cutting berhasil ditambahkan.', 'admin.manage.cuttings.index', 'admin.manage.cuttings.create' @@ -47,7 +49,7 @@ public function edit(Cutting $cutting): Response { return Inertia::render('admin/manage/cutting/edit', [ 'cutting' => $this->service->getForEdit($cutting), - 'data' => $this->service->getForCreate(), + 'rawMaterials' => $this->rawMaterialVariantService->getForCutting(), ]); } @@ -65,7 +67,7 @@ public function update(CuttingRequest $request, Cutting $cutting): RedirectRespo public function destroy(Cutting $cutting): RedirectResponse { return $this->handleAction( - fn () => $this->service->delete($cutting), + fn () => $this->service->destroy($cutting), 'Cutting berhasil dihapus.', 'admin.manage.cuttings.index' ); diff --git a/app/Http/Controllers/Admin/Manage/PurchaseController.php b/app/Http/Controllers/Admin/Manage/PurchaseController.php index 48a1a79..19114a0 100644 --- a/app/Http/Controllers/Admin/Manage/PurchaseController.php +++ b/app/Http/Controllers/Admin/Manage/PurchaseController.php @@ -7,6 +7,7 @@ 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; @@ -17,6 +18,7 @@ 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 @@ -34,14 +36,15 @@ public function index(PaginatedRequest $request): Response public function create(): Response { return Inertia::render('admin/manage/purchase/create', [ - 'data' => $this->service->getForCreate(), + 'suppliers' => $this->supplierService->getAll(), + 'rawMaterials' => $this->rawMaterialVariantService->getForCutting(), ]); } public function store(PurchaseRequest $request): RedirectResponse { return $this->handleAction( - fn () => $this->service->create($request->validated()), + fn () => $this->service->store($request->validated()), 'Belanja berhasil ditambahkan.', 'admin.manage.purchases.index', 'admin.manage.purchases.create' @@ -52,7 +55,8 @@ public function edit(Purchase $purchase): Response { return Inertia::render('admin/manage/purchase/edit', [ 'purchase' => $this->service->getForEdit($purchase), - 'data' => $this->service->getForCreate(), + 'suppliers' => $this->supplierService->getAll(), + 'rawMaterials' => $this->rawMaterialVariantService->getForCutting(), ]); } @@ -70,7 +74,7 @@ public function update(PurchaseRequest $request, Purchase $purchase): RedirectRe public function destroy(Purchase $purchase): RedirectResponse { return $this->handleAction( - fn () => $this->service->delete($purchase), + fn () => $this->service->destroy($purchase), 'Belanja berhasil dihapus.', 'admin.manage.purchases.index' ); diff --git a/app/Http/Controllers/Admin/Manage/RestockController.php b/app/Http/Controllers/Admin/Manage/RestockController.php index b0b94f9..32a387f 100644 --- a/app/Http/Controllers/Admin/Manage/RestockController.php +++ b/app/Http/Controllers/Admin/Manage/RestockController.php @@ -7,6 +7,7 @@ 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; @@ -15,6 +16,7 @@ class RestockController extends Controller { public function __construct( private RestockService $service, + private ProductVariantService $productVariantService, ) {} public function index(PaginatedRequest $request): Response @@ -29,14 +31,14 @@ public function index(PaginatedRequest $request): Response public function create(): Response { return Inertia::render('admin/manage/restock/create', [ - 'data' => $this->service->getForCreate(), + 'products' => $this->productVariantService->getForRestock(), ]); } public function store(RestockRequest $request): RedirectResponse { return $this->handleAction( - fn () => $this->service->create($request->validated()), + fn () => $this->service->store($request->validated()), 'Restock berhasil ditambahkan.', 'admin.manage.restocks.index', 'admin.manage.restocks.create' @@ -47,7 +49,7 @@ public function edit(Restock $restock): Response { return Inertia::render('admin/manage/restock/edit', [ 'restock' => $this->service->getForEdit($restock), - 'data' => $this->service->getForCreate(), + 'products' => $this->productVariantService->getForRestock(), ]); } @@ -65,7 +67,7 @@ public function update(RestockRequest $request, Restock $restock): RedirectRespo public function destroy(Restock $restock): RedirectResponse { return $this->handleAction( - fn () => $this->service->delete($restock), + fn () => $this->service->destroy($restock), 'Restock berhasil dihapus.', 'admin.manage.restocks.index' ); diff --git a/app/Http/Controllers/Admin/Manage/TransactionController.php b/app/Http/Controllers/Admin/Manage/TransactionController.php index cf14cf5..bc3481f 100644 --- a/app/Http/Controllers/Admin/Manage/TransactionController.php +++ b/app/Http/Controllers/Admin/Manage/TransactionController.php @@ -2,11 +2,18 @@ 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\Customer; 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; @@ -15,6 +22,8 @@ class TransactionController extends Controller { public function __construct( private TransactionService $service, + private ProductVariantService $productVariantService, + private CustomerService $customerService, ) {} public function index(PaginatedRequest $request): Response @@ -35,14 +44,19 @@ public function index(PaginatedRequest $request): Response public function create(): Response { return Inertia::render('admin/manage/transaction/create', [ - 'data' => $this->service->getForCreate(), + 'products' => $this->productVariantService->getForTransaction(), + 'customers' => $this->customerService->getAll(), + 'employees' => $this->getEmployees(), + 'channelOptions' => OrderChannel::toSelect(), + 'paymentTypeOptions' => PaymentType::toSelect(), + 'priceTypeOptions' => PriceType::toSelect()->filter(fn ($p) => $p['value'] !== PriceType::CAPITAL->value)->values(), ]); } public function store(TransactionRequest $request): RedirectResponse { return $this->handleAction( - fn() => $this->service->create($request->validated()), + fn () => $this->service->store($request->validated()), 'Transaksi berhasil ditambahkan.', 'admin.manage.transactions.index', 'admin.manage.transactions.create' @@ -53,14 +67,19 @@ public function edit(Order $transaction): Response { return Inertia::render('admin/manage/transaction/edit', [ 'transaction' => $this->service->getForEdit($transaction), - 'data' => $this->service->getForCreate(), + 'products' => $this->productVariantService->getForTransaction(), + 'customers' => $this->customerService->getAll(), + 'employees' => $this->getEmployees(), + 'channelOptions' => OrderChannel::toSelect(), + 'paymentTypeOptions' => PaymentType::toSelect(), + 'priceTypeOptions' => PriceType::toSelect()->filter(fn ($p) => $p['value'] !== PriceType::CAPITAL->value)->values(), ]); } public function update(TransactionRequest $request, Order $transaction): RedirectResponse { return $this->handleAction( - fn() => $this->service->update($transaction, $request->validated()), + fn () => $this->service->update($transaction, $request->validated()), 'Transaksi berhasil diperbarui.', 'admin.manage.transactions.index', 'admin.manage.transactions.edit', @@ -71,7 +90,7 @@ public function update(TransactionRequest $request, Order $transaction): Redirec public function destroy(Order $transaction): RedirectResponse { return $this->handleAction( - fn() => $this->service->delete($transaction), + fn () => $this->service->destroy($transaction), 'Transaksi berhasil dihapus.', 'admin.manage.transactions.index' ); @@ -80,9 +99,21 @@ public function destroy(Order $transaction): RedirectResponse public function updateStatus(Order $transaction): RedirectResponse { return $this->handleAction( - fn() => $this->service->updateStatus($transaction, request('status')), + fn () => $this->service->updateStatus($transaction, request('status')), 'Status transaksi berhasil diperbarui.', '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(); + } } diff --git a/app/Http/Controllers/Admin/Master/Product/ProductController.php b/app/Http/Controllers/Admin/Master/Product/ProductController.php index ea60110..a7ca796 100644 --- a/app/Http/Controllers/Admin/Master/Product/ProductController.php +++ b/app/Http/Controllers/Admin/Master/Product/ProductController.php @@ -42,7 +42,7 @@ public function create(): Response public function store(ProductRequest $request): RedirectResponse { return $this->handleAction( - fn() => $this->service->create($request->validated()), + fn () => $this->service->create($request->validated()), 'Produk berhasil ditambahkan.', 'admin.master.products.index', 'admin.master.products.create' @@ -60,7 +60,7 @@ public function edit(Product $product): Response public function update(ProductRequest $request, Product $product): RedirectResponse { return $this->handleAction( - fn() => $this->service->update($product, $request->validated()), + fn () => $this->service->update($product, $request->validated()), 'Produk berhasil diperbarui.', 'admin.master.products.index', 'admin.master.products.edit', @@ -71,7 +71,7 @@ public function update(ProductRequest $request, Product $product): RedirectRespo public function destroy(Product $product): RedirectResponse { return $this->handleAction( - fn() => $this->service->delete($product), + fn () => $this->service->delete($product), 'Produk berhasil dihapus.', 'admin.master.products.index' ); diff --git a/app/Http/Controllers/Admin/Master/RawMaterial/RawMaterialController.php b/app/Http/Controllers/Admin/Master/RawMaterial/RawMaterialController.php index ec3d81c..b80ae9f 100644 --- a/app/Http/Controllers/Admin/Master/RawMaterial/RawMaterialController.php +++ b/app/Http/Controllers/Admin/Master/RawMaterial/RawMaterialController.php @@ -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->store($request->validated()), 'Bahan baku berhasil ditambahkan.', 'admin.master.raw-materials.index', 'admin.master.raw-materials.create' @@ -53,7 +53,7 @@ public function edit(RawMaterial $rawMaterial): Response public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): RedirectResponse { return $this->handleAction( - fn() => $this->service->update($rawMaterial, $request->validated()), + fn () => $this->service->update($rawMaterial, $request->validated()), 'Bahan baku berhasil diperbarui.', 'admin.master.raw-materials.index', 'admin.master.raw-materials.edit', @@ -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->destroy($rawMaterial), 'Bahan baku berhasil dihapus.', 'admin.master.raw-materials.index' ); diff --git a/app/Http/Controllers/Admin/RoleController.php b/app/Http/Controllers/Admin/RoleController.php index 7b1314e..c02fa62 100644 --- a/app/Http/Controllers/Admin/RoleController.php +++ b/app/Http/Controllers/Admin/RoleController.php @@ -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->store($request->validated()), 'Role berhasil ditambahkan.', 'admin.settings.roles.index' ); @@ -51,7 +51,7 @@ public function edit(Role $role): Response public function update(RoleRequest $request, Role $role): RedirectResponse { return $this->handleAction( - fn() => $this->service->update($role, $request->validated()), + fn () => $this->service->update($role, $request->validated()), 'Role berhasil diperbarui.', 'admin.settings.roles.index' ); diff --git a/app/Http/Controllers/AnalysisController.php b/app/Http/Controllers/AnalysisController.php index 8b691ff..cbb1fdd 100644 --- a/app/Http/Controllers/AnalysisController.php +++ b/app/Http/Controllers/AnalysisController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers; +use App\Concerns\HasRoleChecks; +use App\Enums\Role; use App\Services\AnalysisService; use Illuminate\Http\Request; use Inertia\Inertia; @@ -9,6 +11,8 @@ class AnalysisController extends Controller { + use HasRoleChecks; + public function __construct( private AnalysisService $service ) {} @@ -16,7 +20,7 @@ public function __construct( public function index(Request $request): Response { $user = $request->user(); - $isManager = $user->hasAnyRole(['owner', 'developer', 'admin-toko', 'direktur']); + $isManager = self::hasAnyRole([Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO, Role::DIREKTUR]); $startDate = $request->input('start_date'); $endDate = $request->input('end_date'); diff --git a/app/Http/Controllers/HomepageController.php b/app/Http/Controllers/HomepageController.php index c403a0c..62ef987 100644 --- a/app/Http/Controllers/HomepageController.php +++ b/app/Http/Controllers/HomepageController.php @@ -117,7 +117,7 @@ public function __invoke(Request $request): Response 'category' => $category, ], 'seo' => [ - 'title' => $system->app_name . ' - ' . $homepage->hero_badge, + 'title' => $system->app_name.' - '.$homepage->hero_badge, 'description' => $homepage->hero_description, 'image' => url('/assets/logo.png'), 'url' => url('/'), diff --git a/app/Http/Requests/Admin/Master/Product/ProductRequest.php b/app/Http/Requests/Admin/Master/Product/ProductRequest.php index 2874efe..1158420 100644 --- a/app/Http/Requests/Admin/Master/Product/ProductRequest.php +++ b/app/Http/Requests/Admin/Master/Product/ProductRequest.php @@ -40,9 +40,9 @@ public function rules(): array 'category_ids' => ['required', 'array', 'min:1'], 'category_ids.*' => [Rule::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' => [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'], 'variants' => ['required', 'array', 'min:1'], 'variants.*.id' => ['nullable', 'integer'], 'variants.*.name' => ['required', 'string', 'max:200'], @@ -51,10 +51,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' => [Rule::requiredIf(fn () => ! $useSamePrice), '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' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', Rule::in(PriceType::values())], + 'variants.*.prices.*.price' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', 'integer', 'min:0'], ]; } diff --git a/app/Models/Attendance.php b/app/Models/Attendance.php index 581cbe1..9d1c508 100644 --- a/app/Models/Attendance.php +++ b/app/Models/Attendance.php @@ -2,7 +2,6 @@ namespace App\Models; -use Carbon\Carbon; use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Casts\Attribute; diff --git a/app/Services/Admin/AdminSettingsService.php b/app/Services/Admin/AdminSettingsService.php index bd9ae3d..f5bbcb1 100644 --- a/app/Services/Admin/AdminSettingsService.php +++ b/app/Services/Admin/AdminSettingsService.php @@ -46,15 +46,6 @@ public function getHomepageData(): array ]; } - private function getTemporaryUrl(string $key): string - { - if (str_starts_with($key, 'http')) { - return $key; - } - - return $this->s3Service->getTemporaryUrl($key, 60); - } - public function getSocialMediaData(): array { $settings = app(SocialMediaSettings::class); @@ -167,4 +158,13 @@ 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); + } } diff --git a/app/Services/Admin/Finance/Cash/CashAccountService.php b/app/Services/Admin/Finance/Cash/CashAccountService.php new file mode 100644 index 0000000..00c5bed --- /dev/null +++ b/app/Services/Admin/Finance/Cash/CashAccountService.php @@ -0,0 +1,13 @@ +first(); + } +} diff --git a/app/Services/Admin/Finance/CashAccountService.php b/app/Services/Admin/Finance/Cash/CashTransactionService.php similarity index 70% rename from app/Services/Admin/Finance/CashAccountService.php rename to app/Services/Admin/Finance/Cash/CashTransactionService.php index c3d6023..de28db6 100644 --- a/app/Services/Admin/Finance/CashAccountService.php +++ b/app/Services/Admin/Finance/Cash/CashTransactionService.php @@ -1,8 +1,9 @@ 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(); + $cashAccount = $this->cashAccountService->get(); if (! $cashAccount) { return new PaginationLengthAwarePaginator(collect(), 0, $perPage); @@ -70,29 +48,6 @@ public function paginatedTransactions(int $perPage = 25, string $search = '', st 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( @@ -100,12 +55,12 @@ public function deposit(array $data): CashTransaction description: $data['description'], )); - if (! empty($data['receipt_key'])) { - $this->registerMedia($transaction, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null); + 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); } NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::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'), @@ -122,12 +77,12 @@ public function withdrawal(array $data): CashTransaction type: CashTransactionType::WITHDRAWAL, )); - if (! empty($data['receipt_key'])) { - $this->registerMedia($transaction, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null); + 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); } NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::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'), @@ -136,7 +91,7 @@ public function withdrawal(array $data): CashTransaction return $transaction; } - public function updateTransaction(CashTransaction $transaction, array $data): CashTransaction + public function update(CashTransaction $transaction, array $data): CashTransaction { $transaction = DB::transaction(function () use ($transaction, $data) { if (! in_array($transaction->type, [CashTransactionType::DEPOSIT, CashTransactionType::WITHDRAWAL])) { @@ -168,27 +123,14 @@ public function updateTransaction(CashTransaction $transaction, array $data): Ca ]); if (array_key_exists('receipt_key', $data)) { - $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); - } - } + $this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null); } return $transaction; }); NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::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'), @@ -197,7 +139,7 @@ public function updateTransaction(CashTransaction $transaction, array $data): Ca return $transaction; } - public function deleteTransaction(CashTransaction $transaction): bool + public function destroy(CashTransaction $transaction): bool { return DB::transaction(function () use ($transaction) { if (! in_array($transaction->type, [CashTransactionType::DEPOSIT, CashTransactionType::WITHDRAWAL])) { @@ -221,13 +163,18 @@ public function deleteTransaction(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: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::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'), @@ -237,4 +184,27 @@ public function deleteTransaction(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), + ]; + } } diff --git a/app/Services/Admin/Finance/EmployeeAdvanceService.php b/app/Services/Admin/Finance/EmployeeAdvanceService.php index fb9773e..e1e4691 100644 --- a/app/Services/Admin/Finance/EmployeeAdvanceService.php +++ b/app/Services/Admin/Finance/EmployeeAdvanceService.php @@ -2,41 +2,28 @@ 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; - - 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(); - } + use HandlesCashTransactions, HasRoleChecks; 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(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))) + ->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))) ->when($filters['status'] ?? null, fn ($q) => $q->where('status', $filters['status'])) ->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%")) ->orderBy($sort, $direction) @@ -64,7 +51,7 @@ public function store(array $data): EmployeeAdvance $employeeAdvance->load('employee.user'); NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::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'), @@ -181,7 +168,7 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance }); NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::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'), @@ -234,7 +221,7 @@ public function pay(EmployeeAdvance $employeeAdvance, int $amount): EmployeeAdva : 'Pembayaran kasbon sebesar Rp '.number_format($amount, 0, ',', '.').' oleh '.auth()->user()->full_name.'. Sisa: Rp '.number_format($employeeAdvance->amount - $employeeAdvance->paid_amount, 0, ',', '.').'.'; NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], title: $employeeAdvance->status === EmployeeAdvanceStatus::PAID ? 'Kasbon Dibayar' : 'Pembayaran Kasbon', body: $notificationBody, url: route('admin.finance.employee-advances.index'), diff --git a/app/Services/Admin/Finance/ExpenseService.php b/app/Services/Admin/Finance/ExpenseService.php index f563337..ff03d0e 100644 --- a/app/Services/Admin/Finance/ExpenseService.php +++ b/app/Services/Admin/Finance/ExpenseService.php @@ -3,6 +3,7 @@ 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; @@ -10,7 +11,6 @@ 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,15 +23,6 @@ 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() @@ -46,35 +37,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = return $paginator; } - 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 store(array $data): Expense { $expense = DB::transaction(function () use ($data) { @@ -90,15 +52,15 @@ public function store(array $data): Expense 'description' => $data['description'], ]); - if (! empty($data['receipt_key'])) { - $this->registerMedia($expense, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null); + 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); } return $expense; }); NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::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'), @@ -138,33 +100,14 @@ public function update(Expense $expense, array $data): Expense ]); if (array_key_exists('receipt_key', $data)) { - $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); - } - } + $this->syncReceipt($expense, $data['receipt_key'] ?? null, 'receipts', 'expense_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null); } return $expense; }); NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::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'), @@ -182,7 +125,6 @@ public function destroy(Expense $expense): bool type: CashTransactionType::DEPOSIT, ); - // Invalidate receipt cache $media = $expense->getFirstMedia('receipts'); if ($media) { Cache::forget("expense_receipt_{$media->id}"); @@ -194,7 +136,7 @@ public function destroy(Expense $expense): bool if ($deleted) { NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::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'), @@ -204,4 +146,32 @@ 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, + ]; + } } diff --git a/app/Services/Admin/Finance/PayrollAdjustmentService.php b/app/Services/Admin/Finance/Payroll/PayrollAdjustmentService.php similarity index 98% rename from app/Services/Admin/Finance/PayrollAdjustmentService.php rename to app/Services/Admin/Finance/Payroll/PayrollAdjustmentService.php index eec9226..4f6e2b9 100644 --- a/app/Services/Admin/Finance/PayrollAdjustmentService.php +++ b/app/Services/Admin/Finance/Payroll/PayrollAdjustmentService.php @@ -1,6 +1,6 @@ user()->hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']); - } - - public function getCurrentOrCreate(): PayrollPeriod - { - $now = now(); - - return PayrollPeriod::firstOrCreate( - ['year' => $now->year, 'month' => $now->month], - ['status' => PayrollPeriodStatus::OPEN] - ); - } - - public function getAll(array $filters = []): Collection - { - return PayrollPeriod::select(['id', 'year', 'month', 'status', 'closed_at', 'created_at']) - ->when(! $this->canViewAll(), function ($query) { - $query->withCount(['payrolls as payrolls_count' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))]) - ->withSum(['payrolls as payrolls_sum_total_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'total_amount') - ->withSum(['payrolls as payrolls_sum_bonus_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'bonus_amount') - ->withSum(['payrolls as payrolls_sum_deduction_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'deduction_amount') - ->withCount(['payrolls as paid_count' => fn ($q) => $q->paid()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))]) - ->withCount(['payrolls as cancelled_count' => fn ($q) => $q->cancelled()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))]); - }) - ->when($this->canViewAll(), function ($query) { - $query->withCount('payrolls') - ->withSum('payrolls', 'total_amount') - ->withSum('payrolls', 'bonus_amount') - ->withSum('payrolls', 'deduction_amount') - ->withCount(['payrolls as paid_count' => fn ($q) => $q->paid()]) - ->withCount(['payrolls as cancelled_count' => fn ($q) => $q->cancelled()]); - }) - ->latest('year') - ->latest('month') - ->get(); - } + use HandlesCashTransactions, HasRoleChecks; 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) { + ->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), function ($query) { $query->withCount(['payrolls as payrolls_count' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))]) ->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') @@ -69,7 +31,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = ->withCount(['payrolls as paid_count' => fn ($q) => $q->paid()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))]) ->withCount(['payrolls as cancelled_count' => fn ($q) => $q->cancelled()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))]); }) - ->when($this->canViewAll(), function ($query) { + ->when(self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), function ($query) { $query->withCount('payrolls') ->withSum('payrolls', 'total_amount') ->withSum('payrolls', 'bonus_amount') @@ -82,15 +44,14 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = ->paginate($perPage); } - public function getDetail(PayrollPeriod $period): PayrollPeriod + public function getCurrentOrCreate(): PayrollPeriod { - 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'); - }, - ]); + $now = now(); + + return PayrollPeriod::firstOrCreate( + ['year' => $now->year, 'month' => $now->month], + ['status' => PayrollPeriodStatus::OPEN] + ); } public function close(PayrollPeriod $period): PayrollPeriod @@ -171,7 +132,7 @@ public function pay(Payroll $payroll): Payroll $employeeUser = $payroll->employee->user ?? null; NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::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'), @@ -202,7 +163,7 @@ public function cancel(Payroll $payroll): Payroll $employeeUser = $payroll->employee->user ?? null; NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::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'), diff --git a/app/Services/Admin/HR/AttendanceService.php b/app/Services/Admin/HR/AttendanceService.php index 2a5b372..de1e722 100644 --- a/app/Services/Admin/HR/AttendanceService.php +++ b/app/Services/Admin/HR/AttendanceService.php @@ -2,6 +2,8 @@ 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; @@ -17,16 +19,17 @@ class AttendanceService { - use RegistersMedia; + use HasRoleChecks, RegistersMedia; public function __construct( private S3PresignedService $s3Service, + private EmployeeService $employeeService, ) {} public function getIndexData(int $year, int $month): array { $user = auth()->user(); - $isAdmin = $user->hasAnyRole(['developer', 'owner']); + $isAdmin = self::hasAnyRole([Role::DEVELOPER, Role::OWNER]); $hrSettings = app(HRSettings::class); $employeeId = $isAdmin ? null : $user->employee?->id; @@ -34,7 +37,7 @@ public function getIndexData(int $year, int $month): array return [ 'attendances' => $this->getByMonth($year, $month, $employeeId), 'leaves' => $this->getLeavesByMonth($year, $month, $employeeId), - 'employees' => $isAdmin ? $this->getAllEmployees() : [], + 'employees' => $isAdmin ? $this->employeeService->getAll() : [], 'todayAttendance' => $isAdmin ? null : $this->getToday(), 'currentYear' => $year, 'currentMonth' => $month, @@ -49,14 +52,6 @@ public function getIndexData(int $year, int $month): array ]; } - public function getAll(): Collection - { - 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 { return Attendance::with(['employee.user.userProfile', 'media']) @@ -82,21 +77,6 @@ public function getToday(): ?array return $this->getByDate(now()->toDateString()); } - public function isOnLeave(User $user): bool - { - $employee = $user->employee; - - if (! $employee) { - return false; - } - - return LeaveRequest::approved() - ->where('employee_id', $employee->id) - ->where('start_date', '<=', now()->toDateString()) - ->where('end_date', '>=', now()->toDateString()) - ->exists(); - } - public function getLeavesByMonth(int $year, int $month, ?int $employeeId = null): Collection { $startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth(); @@ -119,17 +99,6 @@ public function getLeavesByMonth(int $year, int $month, ?int $employeeId = null) ]); } - public function getAllEmployees(): 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 getMonthStats(int $year, int $month, ?int $employeeId = null): array { $startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth(); @@ -153,6 +122,87 @@ public function getMonthStats(int $year, int $month, ?int $employeeId = null): a return $this->getMonthStatsForAll($year, $month, $startOfMonth, $statEnd, $workingDays); } + public function isOnLeave(User $user): bool + { + $employee = $user->employee; + + if (! $employee) { + return false; + } + + return LeaveRequest::approved() + ->where('employee_id', $employee->id) + ->where('start_date', '<=', now()->toDateString()) + ->where('end_date', '>=', now()->toDateString()) + ->exists(); + } + + public function checkIn(array $data): Attendance + { + $employee = auth()->user()->employee; + + if (! $employee) { + throw ValidationException::withMessages([ + 'employee' => 'Anda tidak terdaftar sebagai karyawan.', + ]); + } + + $today = now()->toDateString(); + + $existing = Attendance::where('employee_id', $employee->id) + ->where('attendance_date', $today) + ->first(); + + if ($existing) { + throw ValidationException::withMessages([ + 'attendance' => 'Anda sudah melakukan presensi hari ini.', + ]); + } + + $attendance = Attendance::create([ + 'employee_id' => $employee->id, + 'attendance_date' => $today, + 'check_in_at' => now(), + 'check_in_latitude' => $data['latitude'], + 'check_in_longitude' => $data['longitude'], + ]); + + if (! empty($data['photo'])) { + $this->registerMediaFromBase64($attendance, $data['photo'], 'check-in', 'attendances'); + } + + NotificationService::notify( + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR], + title: 'Presensi Masuk', + body: 'Presensi masuk oleh '.auth()->user()->full_name.'.', + url: route('admin.hr.attendances.index'), + ); + + return $attendance; + } + + public function checkOut(Attendance $attendance, array $data): Attendance + { + $attendance->update([ + 'check_out_at' => now(), + 'check_out_latitude' => $data['latitude'], + 'check_out_longitude' => $data['longitude'], + ]); + + if (! empty($data['photo'])) { + $this->registerMediaFromBase64($attendance, $data['photo'], 'check-out', 'attendances'); + } + + NotificationService::notify( + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR], + title: 'Presensi Pulang', + body: 'Presensi pulang oleh '.auth()->user()->full_name.'.', + url: route('admin.hr.attendances.index'), + ); + + return $attendance; + } + private function getMonthStatsForEmployee(int $year, int $month, Carbon $startOfMonth, Carbon $statEnd, int $workingDays, int $employeeId): array { $attendanceQuery = Attendance::whereYear('attendance_date', $year) @@ -240,72 +290,6 @@ private function getMonthStatsForAll(int $year, int $month, Carbon $startOfMonth ]; } - public function checkIn(array $data): Attendance - { - $employee = auth()->user()->employee; - - if (! $employee) { - throw ValidationException::withMessages([ - 'employee' => 'Anda tidak terdaftar sebagai karyawan.', - ]); - } - - $today = now()->toDateString(); - - $existing = Attendance::where('employee_id', $employee->id) - ->where('attendance_date', $today) - ->first(); - - if ($existing) { - throw ValidationException::withMessages([ - 'attendance' => 'Anda sudah melakukan presensi hari ini.', - ]); - } - - $attendance = Attendance::create([ - 'employee_id' => $employee->id, - 'attendance_date' => $today, - 'check_in_at' => now(), - 'check_in_latitude' => $data['latitude'], - 'check_in_longitude' => $data['longitude'], - ]); - - if (! empty($data['photo'])) { - $this->registerMediaFromBase64($attendance, $data['photo'], 'check-in', 'attendances'); - } - - NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur'], - title: 'Presensi Masuk', - body: 'Presensi masuk oleh '.auth()->user()->full_name.'.', - url: route('admin.hr.attendances.index'), - ); - - return $attendance; - } - - public function checkOut(Attendance $attendance, array $data): Attendance - { - $attendance->update([ - 'check_out_at' => now(), - 'check_out_latitude' => $data['latitude'], - 'check_out_longitude' => $data['longitude'], - ]); - - if (! empty($data['photo'])) { - $this->registerMediaFromBase64($attendance, $data['photo'], 'check-out', 'attendances'); - } - - NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur'], - title: 'Presensi Pulang', - body: 'Presensi pulang oleh '.auth()->user()->full_name.'.', - url: route('admin.hr.attendances.index'), - ); - - return $attendance; - } - private function formatAttendance(Attendance $attendance): array { $toArray = $attendance->toArray(); diff --git a/app/Services/Admin/HR/EmployeeService.php b/app/Services/Admin/HR/EmployeeService.php index 2e9d859..c22c0ca 100644 --- a/app/Services/Admin/HR/EmployeeService.php +++ b/app/Services/Admin/HR/EmployeeService.php @@ -2,46 +2,17 @@ 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\Database\Eloquent\Collection; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; class EmployeeService { - 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(); - } + use HasRoleChecks; public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator { @@ -53,11 +24,11 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = 'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']), 'roles' => fn ($q) => $q->select(['id', 'name']), ]) - ->when(! $this->canViewAll(), function ($q) { + ->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), function ($q) { $userRoles = auth()->user()->roles->pluck('name'); $q->whereHas('roles', fn ($rq) => $rq->whereIn('name', $userRoles)); }) - ->when($this->shouldHideAdminBahanBaku(), fn ($q) => $q->whereDoesntHave('roles', fn ($rq) => $rq->where('name', 'admin-bahan-baku'))) + ->when(self::hasAnyRole([Role::ADMIN_TOKO, Role::DIREKTUR]), fn ($q) => $q->whereDoesntHave('roles', fn ($rq) => $rq->where('name', Role::ADMIN_BAHAN_BAKU->value))) ->when($search, fn ($q) => $q->whereHas('userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%"))) ->when($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))) @@ -66,6 +37,17 @@ 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 { return DB::transaction(function () use ($data) { diff --git a/app/Services/Admin/HR/LeaveRequestService.php b/app/Services/Admin/HR/LeaveRequestService.php index af157ae..69b3e1b 100644 --- a/app/Services/Admin/HR/LeaveRequestService.php +++ b/app/Services/Admin/HR/LeaveRequestService.php @@ -2,40 +2,26 @@ 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 { - 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(); - } + use HasRoleChecks; 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(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))) + ->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))) ->when($search, fn ($q) => $q->whereHas('employee.user.userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%"))) ->when($filters['status'] ?? null, function ($query, $status) { $query->where('status', $status); @@ -71,7 +57,7 @@ public function store(array $data): LeaveRequest $leaveRequest->load('employee.user'); NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::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'), @@ -114,7 +100,7 @@ public function approve(LeaveRequest $leaveRequest): LeaveRequest $employeeUser = $leaveRequest->employee->user ?? null; NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], title: 'Cuti Disetujui', body: "Cuti {$leaveRequest->employee->name} telah disetujui oleh ".auth()->user()->full_name.'.', url: route('admin.hr.leave-requests.index'), @@ -135,7 +121,7 @@ public function reject(LeaveRequest $leaveRequest): LeaveRequest $employeeUser = $leaveRequest->employee->user ?? null; NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], title: 'Cuti Ditolak', body: "Cuti {$leaveRequest->employee->name} telah ditolak oleh ".auth()->user()->full_name.'.', url: route('admin.hr.leave-requests.index'), diff --git a/app/Services/Admin/Manage/CuttingService.php b/app/Services/Admin/Manage/CuttingService.php index 5f4c56b..57ce51d 100644 --- a/app/Services/Admin/Manage/CuttingService.php +++ b/app/Services/Admin/Manage/CuttingService.php @@ -7,7 +7,6 @@ use App\Models\CuttingMaterialCombination; use App\Models\CuttingResult; use App\Models\RawMaterialPrice; -use App\Services\Admin\Master\RawMaterial\RawMaterialService; use App\Services\Concerns\RegistersMedia; use App\Services\S3PresignedService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -20,7 +19,6 @@ class CuttingService public function __construct( private S3PresignedService $s3Service, - private RawMaterialService $rawMaterialService, ) {} public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator @@ -62,13 +60,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = return $paginator; } - public function getForCreate(): array - { - return [ - 'rawMaterials' => $this->rawMaterialService->getVariantsForCutting(), - ]; - } - public function getForEdit(Cutting $cutting): array { $cutting->load([ @@ -129,7 +120,7 @@ public function getForEdit(Cutting $cutting): array ]; } - public function create(array $data): Cutting + public function store(array $data): Cutting { return DB::transaction(function () use ($data) { foreach ($data['materials'] as $materialData) { @@ -335,7 +326,7 @@ public function update(Cutting $cutting, array $data): Cutting }); } - public function delete(Cutting $cutting): bool + public function destroy(Cutting $cutting): bool { return DB::transaction(function () use ($cutting) { $cutting->load('cuttingMaterials.rawMaterialPrice'); diff --git a/app/Services/Admin/Manage/PurchaseService.php b/app/Services/Admin/Manage/PurchaseService.php index 4676f0a..342d9c7 100644 --- a/app/Services/Admin/Manage/PurchaseService.php +++ b/app/Services/Admin/Manage/PurchaseService.php @@ -2,6 +2,7 @@ namespace App\Services\Admin\Manage; +use App\Enums\Role; use App\Models\Purchase; use App\Models\PurchaseItem; use App\Models\RawMaterial; @@ -154,16 +155,16 @@ public function getForEdit(Purchase $purchase): array ]; } - public function create(array $data): Purchase + public function store(array $data): Purchase { if (($data['mode'] ?? 'new') === 'existing') { - return $this->createFromExisting($data); + return $this->storeFromExisting($data); } - return $this->createNew($data); + return $this->storeNew($data); } - private function createFromExisting(array $data): Purchase + private function storeFromExisting(array $data): Purchase { return DB::transaction(function () use ($data) { $now = now(); @@ -219,7 +220,7 @@ private function createFromExisting(array $data): Purchase } NotificationService::notify( - roles: ['Owner', 'Developer', 'Admin Bahan Baku'], + roles: [Role::OWNER, Role::DEVELOPER, Role::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'), @@ -229,7 +230,7 @@ private function createFromExisting(array $data): Purchase }); } - private function createNew(array $data): Purchase + private function storeNew(array $data): Purchase { return DB::transaction(function () use ($data) { $rawMaterial = RawMaterial::create([ @@ -309,7 +310,7 @@ private function createNew(array $data): Purchase } NotificationService::notify( - roles: ['Owner', 'Developer', 'Admin Bahan Baku'], + roles: [Role::OWNER, Role::DEVELOPER, Role::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'), @@ -460,7 +461,7 @@ public function update(Purchase $purchase, array $data): Purchase }); } - public function delete(Purchase $purchase): bool + public function destroy(Purchase $purchase): bool { return DB::transaction(function () use ($purchase) { $purchase->load('purchaseItems.rawMaterialPrice'); diff --git a/app/Services/Admin/Manage/RestockService.php b/app/Services/Admin/Manage/RestockService.php index 918fa67..c92f069 100644 --- a/app/Services/Admin/Manage/RestockService.php +++ b/app/Services/Admin/Manage/RestockService.php @@ -4,6 +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; @@ -30,14 +31,14 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = ->with([ 'createdBy:id', 'createdBy.userProfile:id,user_id,full_name', - 'restockItems' => fn ($q) => $q + 'restockItems' => fn($q) => $q ->select(['id', 'restock_id', 'product_variant_id', 'quantity', 'unit_price', 'subtotal']) ->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)'), 'restockItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock', 'restockItems.productVariant.product:id,name', ]) ->when($search, function ($q) use ($search) { - $q->whereHas('restockItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%")) + $q->whereHas('restockItems.productVariant.product', fn($sq) => $sq->where('name', 'like', "%{$search}%")) ->orWhere('notes', 'like', "%{$search}%"); }) ->orderBy($sort, $direction) @@ -59,65 +60,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = return $paginator; } - 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 + public function store(array $data): Restock { return DB::transaction(function () use ($data) { $now = now(); @@ -143,9 +86,9 @@ public function create(array $data): Restock $this->syncPhoto($restock, $data); NotificationService::notify( - roles: ['Owner', 'Developer', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::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.'.', + 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'), ); @@ -189,7 +132,7 @@ public function update(Restock $restock, array $data): Restock }); } - public function delete(Restock $restock): bool + public function destroy(Restock $restock): bool { return DB::transaction(function () use ($restock) { $restock->load('restockItems'); @@ -219,7 +162,7 @@ private function buildItemRows(array $items, string $stockType, $now, int &$subt ->get() ->mapWithKeys(function (ProductVariant $variant) use ($priceType) { $price = $variant->productPrices - ->first(fn ($p) => $p->type === $priceType); + ->first(fn($p) => $p->type === $priceType); return [$variant->id => $price?->price ?? 0]; }); diff --git a/app/Services/Admin/Manage/TransactionService.php b/app/Services/Admin/Manage/TransactionService.php index 7f27b96..67ad9eb 100644 --- a/app/Services/Admin/Manage/TransactionService.php +++ b/app/Services/Admin/Manage/TransactionService.php @@ -7,6 +7,7 @@ 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; @@ -143,83 +144,7 @@ public function getFilterOptions(): array ]; } - 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 + public function store(array $data): Order { return DB::transaction(function () use ($data) { $now = now(); @@ -268,7 +193,7 @@ public function create(array $data): Order } NotificationService::notify( - roles: ['Owner', 'Developer', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::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'), @@ -339,7 +264,7 @@ public function update(Order $order, array $data): Order }); } - public function delete(Order $order): bool + public function destroy(Order $order): bool { return DB::transaction(function () use ($order) { $order->load('orderItems'); diff --git a/app/Services/Admin/Master/CategoryService.php b/app/Services/Admin/Master/CategoryService.php index aaf70a7..144472f 100644 --- a/app/Services/Admin/Master/CategoryService.php +++ b/app/Services/Admin/Master/CategoryService.php @@ -8,20 +8,20 @@ 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() ->select(['id', 'name']) - ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")) + ->when($search, fn($q) => $q->where('name', 'like', "%{$search}%")) ->orderBy($sort, $direction) ->paginate($perPage); } + public function getAll(): Collection + { + return Category::select(['id', 'name'])->latest()->get(); + } + public function store(array $data): Category { return Category::create($data); diff --git a/app/Services/Admin/Master/CustomerService.php b/app/Services/Admin/Master/CustomerService.php index 8e6db70..8ad6631 100644 --- a/app/Services/Admin/Master/CustomerService.php +++ b/app/Services/Admin/Master/CustomerService.php @@ -8,11 +8,6 @@ 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() @@ -22,6 +17,11 @@ 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 { return Customer::create($data); diff --git a/app/Services/Admin/Master/Product/ProductService.php b/app/Services/Admin/Master/Product/ProductService.php index da2bd6a..5e16e69 100644 --- a/app/Services/Admin/Master/Product/ProductService.php +++ b/app/Services/Admin/Master/Product/ProductService.php @@ -2,75 +2,28 @@ 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\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, ) {} - private function canVerify(): bool - { - $user = auth()->user(); - - return $user->hasAnyRole(['developer', 'owner']); - } - - private function assertNotPending(Product $product): void - { - if ($product->status === ProductStatus::PENDING && ! $this->canVerify()) { - throw ValidationException::withMessages([ - 'status' => 'Produk sedang menunggu verifikasi dan tidak dapat diubah.', - ]); - } - } - - public function getNames(): array - { - 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', 'rejection_reason']) - ->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() @@ -105,9 +58,9 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = return $paginator; } - public function create(array $data): Product + public function store(array $data): Product { - $status = $this->canVerify() + $status = self::hasAnyRole([Role::DEVELOPER, Role::OWNER]) ? ($data['status'] ?? ProductStatus::ACTIVE) : ProductStatus::PENDING; @@ -182,7 +135,7 @@ public function create(array $data): Product }); NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], title: 'Produk Baru', body: "Produk \"{$product->name}\" berhasil ditambahkan".' oleh '.auth()->user()->full_name.'.', url: route('admin.master.products.index'), @@ -236,7 +189,7 @@ public function update(Product $product, array $data): 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 && ! $this->canVerify()) { + if ($product->status === ProductStatus::REJECTED && ! self::hasAnyRole([Role::DEVELOPER, Role::OWNER])) { $newStatus = ProductStatus::PENDING; } @@ -433,7 +386,7 @@ public function update(Product $product, array $data): Product }); NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], title: 'Produk Diperbarui', body: "Produk \"{$product->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.', url: route('admin.master.products.index'), @@ -442,7 +395,7 @@ public function update(Product $product, array $data): Product return $product; } - public function delete(Product $product): bool + public function destroy(Product $product): bool { $this->assertNotPending($product); @@ -459,7 +412,7 @@ public function delete(Product $product): bool }); NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], title: 'Produk Dihapus', body: "Produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.', url: route('admin.master.products.index'), @@ -484,7 +437,7 @@ public function approve(Product $product): void ]); NotificationService::notify( - roles: ['Owner', 'Developer'], + 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'), @@ -500,7 +453,7 @@ public function reject(Product $product, string $reason = ''): void ]); NotificationService::notify( - roles: ['Owner', 'Developer'], + 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'), @@ -516,10 +469,19 @@ public function resubmit(Product $product): void ]); NotificationService::notify( - roles: ['Owner', 'Developer'], + 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.', + ]); + } + } } diff --git a/app/Services/Admin/Master/Product/ProductVariantService.php b/app/Services/Admin/Master/Product/ProductVariantService.php index 055c643..a954911 100644 --- a/app/Services/Admin/Master/Product/ProductVariantService.php +++ b/app/Services/Admin/Master/Product/ProductVariantService.php @@ -2,7 +2,10 @@ 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; @@ -14,27 +17,64 @@ class ProductVariantService { - use RegistersMedia; + use HasRoleChecks, RegistersMedia; public function __construct( private S3PresignedService $s3Service, private StockMutationService $stockMutationService, ) {} - private function canVerify(): bool + public function getForRestock(): array { - $user = auth()->user(); + 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; - return $user->hasAnyRole(['developer', 'owner']); + $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; + }); + }); } - private function assertNotPending(Product $product): void + public function getForTransaction(): array { - if ($product->status === ProductStatus::PENDING && ! $this->canVerify()) { - throw ValidationException::withMessages([ - 'status' => 'Produk sedang menunggu verifikasi dan tidak dapat diubah.', - ]); - } + 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 @@ -102,7 +142,7 @@ public function update(ProductVariant $variant, array $data): ProductVariant }); NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], title: 'Varian Diperbarui', body: "Varian \"{$variant->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.', url: route('admin.master.products.index'), @@ -111,7 +151,7 @@ public function update(ProductVariant $variant, array $data): ProductVariant return $variant->fresh(); } - public function delete(Product $product, ProductVariant $variant): bool + public function destroy(Product $product, ProductVariant $variant): bool { $this->assertNotPending($product); @@ -123,7 +163,7 @@ public function delete(Product $product, ProductVariant $variant): bool }); NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::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'), @@ -184,7 +224,7 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari }); NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::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'), @@ -192,4 +232,13 @@ 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.', + ]); + } + } } diff --git a/app/Services/Admin/Master/RawMaterial/RawMaterialService.php b/app/Services/Admin/Master/RawMaterial/RawMaterialService.php index a30bbae..4726662 100644 --- a/app/Services/Admin/Master/RawMaterial/RawMaterialService.php +++ b/app/Services/Admin/Master/RawMaterial/RawMaterialService.php @@ -7,7 +7,6 @@ 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 @@ -18,27 +17,6 @@ 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() @@ -239,26 +217,6 @@ public function toggleStatus(RawMaterial $rawMaterial): void ]); } - public function getVariantsForCutting(): 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; - }); - }); - } - private function registerPhoto(RawMaterialPrice $price, string $s3Key): void { $this->registerMedia( diff --git a/app/Services/Admin/Master/RawMaterial/RawMaterialVariantService.php b/app/Services/Admin/Master/RawMaterial/RawMaterialVariantService.php index 813a36d..8a891d2 100644 --- a/app/Services/Admin/Master/RawMaterial/RawMaterialVariantService.php +++ b/app/Services/Admin/Master/RawMaterial/RawMaterialVariantService.php @@ -2,6 +2,7 @@ namespace App\Services\Admin\Master\RawMaterial; +use App\Enums\Role; use App\Models\RawMaterial; use App\Models\RawMaterialPrice; use App\Services\Concerns\RegistersMedia; @@ -17,6 +18,26 @@ 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; + }); + }); + } + public function getForEdit(RawMaterialPrice $variant): array { $variant->load('media'); @@ -61,7 +82,7 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice }); NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO], title: 'Varian Diperbarui', body: "Varian \"{$variant->variant}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.', url: route('admin.master.raw-materials.index'), @@ -70,7 +91,7 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice return $variant->fresh(); } - public function delete(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool + public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool { $result = DB::transaction(function () use ($variant) { $variant->clearMediaCollection('photos'); @@ -79,7 +100,7 @@ public function delete(RawMaterial $rawMaterial, RawMaterialPrice $variant): boo }); NotificationService::notify( - roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::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'), diff --git a/app/Services/Admin/Master/SupplierService.php b/app/Services/Admin/Master/SupplierService.php index ae4d23d..79a742c 100644 --- a/app/Services/Admin/Master/SupplierService.php +++ b/app/Services/Admin/Master/SupplierService.php @@ -8,11 +8,6 @@ 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() @@ -22,6 +17,11 @@ 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 { return Supplier::create($data); diff --git a/app/Services/Admin/Settings/RoleService.php b/app/Services/Admin/Settings/RoleService.php index e09526b..256df04 100644 --- a/app/Services/Admin/Settings/RoleService.php +++ b/app/Services/Admin/Settings/RoleService.php @@ -2,17 +2,21 @@ 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; +use Spatie\Permission\Models\Role as SpatieRole; class RoleService { + use HasRoleChecks; + public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator { - return Role::query() + return SpatieRole::query() ->select(['id', 'name']) ->withCount('permissions') ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")) @@ -23,7 +27,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort = public function store(array $data): Role { return DB::transaction(function () use ($data) { - $role = Role::create(['name' => $data['name']]); + $role = SpatieRole::create(['name' => $data['name']]); $role->syncPermissions($data['permissions']); return $role; @@ -55,15 +59,15 @@ public function getPermissionsByModule(): array public function getForEmployee(): Collection { - $query = Role::where('name', '!=', 'Developer'); + $query = SpatieRole::where('name', '!=', Role::DEVELOPER->value); $user = auth()->user(); - if ($user->hasAnyRole(['admin-toko', 'direktur'])) { - $query->where('name', '!=', 'admin-bahan-baku'); + if (self::hasAnyRole([Role::ADMIN_TOKO, Role::DIREKTUR])) { + $query->where('name', '!=', Role::ADMIN_BAHAN_BAKU->value); } - if (! $user->hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko'])) { + if (! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO])) { $userRoles = $user->roles->pluck('name'); $query->whereIn('name', $userRoles); } diff --git a/app/Services/AnalysisService.php b/app/Services/AnalysisService.php index fc9ede4..8914951 100644 --- a/app/Services/AnalysisService.php +++ b/app/Services/AnalysisService.php @@ -36,7 +36,7 @@ public function getAttendanceStats(?string $startDate, ?string $endDate): array $current->addDay(); } - $totalEmployees = Employee::whereHas('user', fn($q) => $q->where('is_active', true))->count(); + $totalEmployees = Employee::whereHas('user', fn ($q) => $q->where('is_active', true))->count(); $present = Attendance::whereBetween('attendance_date', [$start, $end])->count(); @@ -142,12 +142,12 @@ public function getRawMaterialStock(): array ->get(); $totalStock = $prices->sum('stock'); - $totalValue = $prices->sum(fn($p) => $p->stock * $p->price); + $totalValue = $prices->sum(fn ($p) => $p->stock * $p->price); $byUnit = [ - 'yard' => $prices->filter(fn($p) => $p->rawMaterial?->unit === RawMaterialUnit::YARD)->sum('stock'), - 'meter' => $prices->filter(fn($p) => $p->rawMaterial?->unit === RawMaterialUnit::METER)->sum('stock'), - 'kilogram' => $prices->filter(fn($p) => $p->rawMaterial?->unit === RawMaterialUnit::KG)->sum('stock'), + 'yard' => $prices->filter(fn ($p) => $p->rawMaterial?->unit === RawMaterialUnit::YARD)->sum('stock'), + 'meter' => $prices->filter(fn ($p) => $p->rawMaterial?->unit === RawMaterialUnit::METER)->sum('stock'), + 'kilogram' => $prices->filter(fn ($p) => $p->rawMaterial?->unit === RawMaterialUnit::KG)->sum('stock'), ]; return [ @@ -226,7 +226,7 @@ public function getMonthlyRevenue(?string $startDate, ?string $endDate): array ->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) => $item->only(['month', 'total', 'net', 'net_warehouse', 'net_retail', 'deduction'])); + ->map(fn ($item) => $item->only(['month', 'total', 'net', 'net_warehouse', 'net_retail', 'deduction'])); return $monthly->values()->toArray(); } @@ -244,7 +244,7 @@ public function getMonthlyRevenueByChannel(?string $startDate, ?string $endDate) ->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) => $item->only(['month', 'store', 'shopee', 'tiktok'])); + ->map(fn ($item) => $item->only(['month', 'store', 'shopee', 'tiktok'])); return $monthly->values()->toArray(); } @@ -259,7 +259,7 @@ public function getRevenueByPaymentType(?string $startDate, ?string $endDate): a ->selectRaw('COALESCE(SUM(total_amount), 0) as total') ->groupBy('payment_type') ->get() - ->map(fn($item) => [ + ->map(fn ($item) => [ 'payment_type' => $item->payment_type, 'label' => $item->payment_type->label(), 'total' => (int) $item->total, diff --git a/app/Services/Concerns/RegistersMedia.php b/app/Services/Concerns/RegistersMedia.php index a93e8b2..67a0369 100644 --- a/app/Services/Concerns/RegistersMedia.php +++ b/app/Services/Concerns/RegistersMedia.php @@ -3,6 +3,7 @@ namespace App\Services\Concerns; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Spatie\MediaLibrary\MediaCollections\Models\Media; @@ -109,4 +110,35 @@ private function syncPhoto(Model $model, array $data, string $collectionName = ' ); } } + + private function syncReceipt(Model $model, ?string $newKey, string $collectionName = 'receipts', string $cachePrefix = 'receipt', ?int $fileSize = null, ?string $fileMimeType = null): void + { + $currentMedia = $model->getFirstMedia($collectionName); + $currentKey = $currentMedia?->file_name; + + if ($currentMedia && ! str_contains($currentKey, '/')) { + $currentKey = $currentMedia->getPath(); + } + + if ($newKey === $currentKey) { + return; + } + + if ($currentMedia) { + Cache::forget("{$cachePrefix}_{$currentMedia->id}"); + } + + $model->clearMediaCollection($collectionName); + + if (! empty($newKey)) { + $this->registerMedia( + model: $model, + s3Key: $newKey, + collectionName: $collectionName, + generatedConversions: ['thumb' => true], + fileSize: $fileSize, + mimeType: $fileMimeType, + ); + } + } } diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index 7a18f6c..53d4595 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -2,18 +2,24 @@ namespace App\Services; +use App\Enums\Role; use App\Models\User; use App\Notifications\WebPushNotification; use Illuminate\Support\Facades\Log; class NotificationService { + /** + * @param array $roles + */ public static function notify(array $roles, string $title, string $body, string $url, ?User $additionalUser = null): void { + $roleLabels = array_map(fn (Role $role) => $role->label(), $roles); + $users = User::query() ->select(['id']) ->where('is_active', true) - ->whereHas('roles', fn ($q) => $q->whereIn('name', $roles)) + ->whereHas('roles', fn ($q) => $q->whereIn('name', $roleLabels)) ->get(); if ($additionalUser && ! $users->contains('id', $additionalUser->id)) { diff --git a/app/Services/StockMutationService.php b/app/Services/StockMutationService.php index 2eca6c5..0301289 100644 --- a/app/Services/StockMutationService.php +++ b/app/Services/StockMutationService.php @@ -17,6 +17,16 @@ class StockMutationService 'retail_stock' => 'retail', ]; + public function paginated(Model $model, int $perPage = 20): LengthAwarePaginator + { + return StockMutation::query() + ->where('stockable_type', get_class($model)) + ->where('stockable_id', $model->id) + ->with('user:id,username,email') + ->latest() + ->paginate($perPage); + } + public function recordInitial(Model $model, array $stockData, string $description = 'Stok awal'): void { $userId = auth()->id(); @@ -172,14 +182,4 @@ public function recordTransfer( ]); }); } - - public function paginated(Model $model, int $perPage = 20): LengthAwarePaginator - { - return StockMutation::query() - ->where('stockable_type', get_class($model)) - ->where('stockable_id', $model->id) - ->with('user:id,username,email') - ->latest() - ->paginate($perPage); - } } diff --git a/database/migrations/2026_08_07_105308_drop_owner_verification_requests_table.php b/database/migrations/2026_08_07_105308_drop_owner_verification_requests_table.php index 22f8777..1d75373 100644 --- a/database/migrations/2026_08_07_105308_drop_owner_verification_requests_table.php +++ b/database/migrations/2026_08_07_105308_drop_owner_verification_requests_table.php @@ -1,7 +1,6 @@ }; const userId = auth.user?.id; diff --git a/resources/js/pages/admin/manage/cutting/edit.tsx b/resources/js/pages/admin/manage/cutting/edit.tsx index 00b7b7b..829de83 100644 --- a/resources/js/pages/admin/manage/cutting/edit.tsx +++ b/resources/js/pages/admin/manage/cutting/edit.tsx @@ -42,11 +42,10 @@ type CombinationState = { type Props = { cutting: CuttingForEdit; - data: CuttingCreateData; + rawMaterials: CuttingCreateData['rawMaterials']; }; -export default function CuttingEdit({ cutting, data }: Props) { - const { rawMaterials } = data; +export default function CuttingEdit({ cutting, rawMaterials }: Props) { const { errors } = usePage().props as { errors: Record }; const [materials, setMaterials] = useState(() => { diff --git a/resources/js/pages/admin/manage/purchase/create.tsx b/resources/js/pages/admin/manage/purchase/create.tsx index 1f72a41..f9423a9 100644 --- a/resources/js/pages/admin/manage/purchase/create.tsx +++ b/resources/js/pages/admin/manage/purchase/create.tsx @@ -72,11 +72,11 @@ type CartLine = { }; type Props = { - data: PurchaseCreateData; + suppliers: PurchaseCreateData['suppliers']; + rawMaterials: PurchaseCreateData['rawMaterials']; }; -export default function PurchaseCreate({ data }: Props) { - const { suppliers, rawMaterials } = data; +export default function PurchaseCreate({ suppliers, rawMaterials }: Props) { const { auth } = usePage().props as { auth: { user?: { id?: number } } }; const userId = auth.user?.id; diff --git a/resources/js/pages/admin/manage/purchase/edit.tsx b/resources/js/pages/admin/manage/purchase/edit.tsx index 9912bb1..1f906c8 100644 --- a/resources/js/pages/admin/manage/purchase/edit.tsx +++ b/resources/js/pages/admin/manage/purchase/edit.tsx @@ -57,11 +57,15 @@ type CartLine = { type Props = { purchase: PurchaseForEdit; - data: PurchaseCreateData; + suppliers: PurchaseCreateData['suppliers']; + rawMaterials: PurchaseCreateData['rawMaterials']; }; -export default function PurchaseEdit({ purchase, data }: Props) { - const { suppliers, rawMaterials } = data; +export default function PurchaseEdit({ + purchase, + suppliers, + rawMaterials, +}: Props) { const [supplierId, setSupplierId] = useState(String(purchase.supplier_id)); const selectedSupplier = diff --git a/resources/js/pages/admin/manage/restock/create.tsx b/resources/js/pages/admin/manage/restock/create.tsx index 9ff4ebc..3a2960b 100644 --- a/resources/js/pages/admin/manage/restock/create.tsx +++ b/resources/js/pages/admin/manage/restock/create.tsx @@ -50,11 +50,10 @@ type CartLine = { }; type Props = { - data: RestockCreateData; + products: RestockCreateData['products']; }; -export default function RestockCreate({ data }: Props) { - const { products } = data; +export default function RestockCreate({ products }: Props) { const { auth } = usePage().props as { auth: { user?: { id?: number } } }; const userId = auth.user?.id; diff --git a/resources/js/pages/admin/manage/restock/edit.tsx b/resources/js/pages/admin/manage/restock/edit.tsx index 21c23b8..a2c5565 100644 --- a/resources/js/pages/admin/manage/restock/edit.tsx +++ b/resources/js/pages/admin/manage/restock/edit.tsx @@ -41,11 +41,10 @@ type CartLine = { type Props = { restock: RestockForEdit; - data: RestockCreateData; + products: RestockCreateData['products']; }; -export default function RestockEdit({ restock, data }: Props) { - const { products } = data; +export default function RestockEdit({ restock, products }: Props) { const [stockType, setStockType] = useState<'good' | 'reject'>( restock.stock_type === 'reject' ? 'reject' : 'good', diff --git a/resources/js/pages/admin/manage/transaction/create.tsx b/resources/js/pages/admin/manage/transaction/create.tsx index 164f857..36fb670 100644 --- a/resources/js/pages/admin/manage/transaction/create.tsx +++ b/resources/js/pages/admin/manage/transaction/create.tsx @@ -60,13 +60,24 @@ type CartLine = { }; type Props = { - data: TransactionCreateData; + products: TransactionCreateData['products']; + customers: TransactionCreateData['customers']; + employees: TransactionCreateData['employees']; + channelOptions: TransactionCreateData['channelOptions']; + paymentTypeOptions: TransactionCreateData['paymentTypeOptions']; + priceTypeOptions: TransactionCreateData['priceTypeOptions']; }; const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee']; -export default function TransactionCreate({ data }: Props) { - const { products, customers, employees, channelOptions, paymentTypeOptions, priceTypeOptions } = data; +export default function TransactionCreate({ + products, + customers, + employees, + channelOptions, + paymentTypeOptions, + priceTypeOptions, +}: Props) { const { auth } = usePage().props as { auth: { user?: { id?: number } } }; const userId = auth.user?.id; diff --git a/resources/js/pages/admin/manage/transaction/edit.tsx b/resources/js/pages/admin/manage/transaction/edit.tsx index faf1056..e8410a2 100644 --- a/resources/js/pages/admin/manage/transaction/edit.tsx +++ b/resources/js/pages/admin/manage/transaction/edit.tsx @@ -59,13 +59,25 @@ type CartLine = { type Props = { transaction: TransactionForEdit; - data: TransactionCreateData; + products: TransactionCreateData['products']; + customers: TransactionCreateData['customers']; + employees: TransactionCreateData['employees']; + channelOptions: TransactionCreateData['channelOptions']; + paymentTypeOptions: TransactionCreateData['paymentTypeOptions']; + priceTypeOptions: TransactionCreateData['priceTypeOptions']; }; const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee']; -export default function TransactionEdit({ transaction, data }: Props) { - const { products, customers, employees, channelOptions, paymentTypeOptions, priceTypeOptions } = data; +export default function TransactionEdit({ + transaction, + products, + customers, + employees, + channelOptions, + paymentTypeOptions, + priceTypeOptions, +}: Props) { const [stockType, setStockType] = useState<'good' | 'reject'>( transaction.stock_type === 'reject' ? 'reject' : 'good', diff --git a/routes/web.php b/routes/web.php index ac25718..ba41671 100644 --- a/routes/web.php +++ b/routes/web.php @@ -132,4 +132,4 @@ }); }); -require __DIR__ . '/settings.php'; +require __DIR__.'/settings.php'; diff --git a/tests/Feature/Admin/NotificationTest.php b/tests/Feature/Admin/NotificationTest.php index be1a3d8..50b0768 100644 --- a/tests/Feature/Admin/NotificationTest.php +++ b/tests/Feature/Admin/NotificationTest.php @@ -9,10 +9,10 @@ use App\Models\Product; use App\Models\User; use App\Notifications\WebPushNotification; -use App\Services\Admin\Finance\CashAccountService; +use App\Services\Admin\Finance\Cash\CashTransactionService; use App\Services\Admin\Finance\EmployeeAdvanceService; use App\Services\Admin\Finance\ExpenseService; -use App\Services\Admin\Finance\PayrollPeriodService; +use App\Services\Admin\Finance\Payroll\PayrollPeriodService; use App\Services\Admin\HR\AttendanceService; use App\Services\Admin\HR\LeaveRequestService; use App\Services\Admin\Master\Product\ProductService; @@ -283,7 +283,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array |-------------------------------------------------------------------------- */ -test('CashAccountService deposit sends notification to correct roles', function () { +test('CashTransactionService deposit sends notification to correct roles', function () { Notification::fake(); $cashAccount = CashAccount::factory()->create(['balance' => 1000000]); @@ -293,7 +293,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array $this->actingAs($developer); - $service = new CashAccountService; + $service = new CashTransactionService; $service->deposit([ 'amount' => 500000, 'description' => 'Setoran modal', @@ -306,7 +306,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array expect($developer->notifications()->first()->title)->toBe('Setoran Kas Toko'); }); -test('CashAccountService withdrawal sends notification to correct roles', function () { +test('CashTransactionService withdrawal sends notification to correct roles', function () { Notification::fake(); $cashAccount = CashAccount::factory()->create(['balance' => 1000000]); @@ -315,7 +315,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array $this->actingAs($developer); - $service = new CashAccountService; + $service = new CashTransactionService; $service->withdrawal([ 'amount' => 200000, 'description' => 'Penarikan kas', @@ -327,7 +327,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array expect($developer->notifications()->first()->title)->toBe('Penarikan Kas Toko'); }); -test('CashAccountService updateTransaction sends notification to correct roles', function () { +test('CashTransactionService update sends notification to correct roles', function () { Notification::fake(); $cashAccount = CashAccount::factory()->create(['balance' => 1000000]); @@ -336,7 +336,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array $this->actingAs($developer); - $service = new CashAccountService; + $service = new CashTransactionService; $transaction = $service->deposit([ 'amount' => 500000, 'description' => 'Initial deposit', @@ -344,7 +344,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array Notification::fake(); - $service->updateTransaction($transaction, [ + $service->update($transaction, [ 'amount' => 600000, 'description' => 'Updated deposit', ]); @@ -355,7 +355,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array expect($developer->notifications()->where('title', 'Transaksi Kas Diperbarui')->count())->toBe(1); }); -test('CashAccountService deleteTransaction sends notification to correct roles', function () { +test('CashTransactionService destroy sends notification to correct roles', function () { Notification::fake(); $cashAccount = CashAccount::factory()->create(['balance' => 1000000]); @@ -364,7 +364,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array $this->actingAs($developer); - $service = new CashAccountService; + $service = new CashTransactionService; $transaction = $service->deposit([ 'amount' => 500000, 'description' => 'To be deleted', @@ -372,7 +372,7 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array Notification::fake(); - $service->deleteTransaction($transaction); + $service->destroy($transaction); Notification::assertSentTo($developer, WebPushNotification::class); Notification::assertSentTo($owner, WebPushNotification::class); @@ -849,13 +849,13 @@ function notifEmployeeWithRole(string $role = 'Kasir'): array ]); }); -test('CashAccountService deposit creates in-app notification record', function () { +test('CashTransactionService deposit creates in-app notification record', function () { CashAccount::factory()->create(['balance' => 5000000]); $owner = notifUserWithRole('Owner'); $this->actingAs($owner); - $service = new CashAccountService; + $service = new CashTransactionService; $service->deposit([ 'amount' => 250000, 'description' => 'Test deposit',