From e2f0feed9d9a21824bdb5838fb279dc02d301084 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 10 Jun 2026 23:56:17 +0700 Subject: [PATCH] Add expenses management features including Expense model, ExpenseController for CRUD operations, and corresponding permissions in the Permission and Role enums. Update routes for expenses functionality and enhance UI components for expenses navigation in the sidebar. --- app/Enums/Permission.php | 12 ++ app/Enums/Role.php | 8 ++ .../Admin/Finance/ExpenseController.php | 59 ++++++++ .../Requests/Admin/Finance/ExpenseRequest.php | 29 ++++ app/Models/CashTransaction.php | 1 + app/Models/Expense.php | 55 ++++++++ app/Models/User.php | 5 + app/Services/Finance/CashService.php | 36 +++++ app/Services/Finance/ExpenseService.php | 109 +++++++++++++++ ...026_06_10_150001_create_expenses_table.php | 30 ++++ resources/js/components/AppSidebar.vue | 14 +- .../finance/expenses/ExpenseFormModal.vue | 130 ++++++++++++++++++ .../admin/finance/expenses/columns.ts | 39 ++++++ .../finance/expenses/data-table-actions.vue | 70 ++++++++++ .../js/pages/admin/finance/expenses/Index.vue | 108 +++++++++++++++ resources/js/types/expense.ts | 26 ++++ routes/web.php | 19 +++ 17 files changed, 748 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/Admin/Finance/ExpenseController.php create mode 100644 app/Http/Requests/Admin/Finance/ExpenseRequest.php create mode 100644 app/Models/Expense.php create mode 100644 app/Services/Finance/ExpenseService.php create mode 100644 database/migrations/2026_06_10_150001_create_expenses_table.php create mode 100644 resources/js/components/admin/finance/expenses/ExpenseFormModal.vue create mode 100644 resources/js/components/admin/finance/expenses/columns.ts create mode 100644 resources/js/components/admin/finance/expenses/data-table-actions.vue create mode 100644 resources/js/pages/admin/finance/expenses/Index.vue create mode 100644 resources/js/types/expense.ts diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index e572732..d12c1ba 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -49,6 +49,11 @@ enum Permission: string case CASH_UPDATE = 'cash.update'; case CASH_DELETE = 'cash.delete'; + case EXPENSES_VIEW = 'expenses.view'; + case EXPENSES_CREATE = 'expenses.create'; + case EXPENSES_UPDATE = 'expenses.update'; + case EXPENSES_DELETE = 'expenses.delete'; + public function label(): string { return match ($this) { @@ -92,6 +97,11 @@ public function label(): string self::CASH_DEPOSIT => 'Setor Kas', self::CASH_UPDATE => 'Ubah Setor Kas', self::CASH_DELETE => 'Hapus Setor Kas', + + self::EXPENSES_VIEW => 'Lihat Pengeluaran', + self::EXPENSES_CREATE => 'Tambah Pengeluaran', + self::EXPENSES_UPDATE => 'Ubah Pengeluaran', + self::EXPENSES_DELETE => 'Hapus Pengeluaran', }; } @@ -112,6 +122,8 @@ public function group(): string self::RAW_MATERIALS_VIEW, self::RAW_MATERIALS_CREATE, self::RAW_MATERIALS_UPDATE, self::RAW_MATERIALS_DELETE, self::RAW_MATERIALS_TOGGLE_STATUS => 'Bahan Baku', self::CASH_VIEW, self::CASH_DEPOSIT, self::CASH_UPDATE, self::CASH_DELETE => 'Kas', + self::EXPENSES_VIEW, self::EXPENSES_CREATE, self::EXPENSES_UPDATE, + self::EXPENSES_DELETE => 'Pengeluaran', }; } diff --git a/app/Enums/Role.php b/app/Enums/Role.php index 812db5a..74e79d6 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -70,6 +70,10 @@ public function permissions(): array Permission::CASH_DEPOSIT, Permission::CASH_UPDATE, Permission::CASH_DELETE, + Permission::EXPENSES_VIEW, + Permission::EXPENSES_CREATE, + Permission::EXPENSES_UPDATE, + Permission::EXPENSES_DELETE, ], self::ADMIN_TOKO => [ Permission::DASHBOARD_VIEW, @@ -99,6 +103,10 @@ public function permissions(): array Permission::CASH_DEPOSIT, Permission::CASH_UPDATE, Permission::CASH_DELETE, + Permission::EXPENSES_VIEW, + Permission::EXPENSES_CREATE, + Permission::EXPENSES_UPDATE, + Permission::EXPENSES_DELETE, ], self::ADMIN_BAHAN_BAKU => [ Permission::DASHBOARD_VIEW, diff --git a/app/Http/Controllers/Admin/Finance/ExpenseController.php b/app/Http/Controllers/Admin/Finance/ExpenseController.php new file mode 100644 index 0000000..2cdb774 --- /dev/null +++ b/app/Http/Controllers/Admin/Finance/ExpenseController.php @@ -0,0 +1,59 @@ +parseDataTableQuery($request); + + return Inertia::render('admin/finance/expenses/Index', [ + 'expenses' => $this->expenseService->paginateForIndex($tableQuery), + 'filters' => $this->dataTableFilters($tableQuery), + ]); + } + + public function store(ExpenseRequest $request): RedirectResponse + { + $this->expenseService->create($request->validated(), $request->user()); + + Inertia::flash('success', 'Pengeluaran berhasil ditambahkan.'); + + return redirect()->route('admin.finance.expenses.index'); + } + + public function update(ExpenseRequest $request, Expense $expense): RedirectResponse + { + $this->expenseService->update($expense, $request->validated()); + + Inertia::flash('success', 'Pengeluaran berhasil diperbarui.'); + + return redirect()->route('admin.finance.expenses.index'); + } + + public function destroy(Expense $expense): RedirectResponse + { + $this->expenseService->delete($expense); + + Inertia::flash('success', 'Pengeluaran berhasil dihapus.'); + + return redirect()->route('admin.finance.expenses.index'); + } +} diff --git a/app/Http/Requests/Admin/Finance/ExpenseRequest.php b/app/Http/Requests/Admin/Finance/ExpenseRequest.php new file mode 100644 index 0000000..098dd30 --- /dev/null +++ b/app/Http/Requests/Admin/Finance/ExpenseRequest.php @@ -0,0 +1,29 @@ +isMethod('POST') + ? Permission::EXPENSES_CREATE + : Permission::EXPENSES_UPDATE; + + return $this->user()?->can($permission->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'amount' => ['required', 'integer', 'min:1'], + 'description' => ['required', 'string', 'max:500'], + ]; + } +} diff --git a/app/Models/CashTransaction.php b/app/Models/CashTransaction.php index a86fbaa..73184b0 100644 --- a/app/Models/CashTransaction.php +++ b/app/Models/CashTransaction.php @@ -80,6 +80,7 @@ public static function labelForReferenceType(?string $referenceType): string } return match ($referenceType) { + Expense::class => 'Pengeluaran', default => class_basename($referenceType), }; } diff --git a/app/Models/Expense.php b/app/Models/Expense.php new file mode 100644 index 0000000..ed64582 --- /dev/null +++ b/app/Models/Expense.php @@ -0,0 +1,55 @@ + 'integer', + ]; + } + + public function amountFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->amount, 0, ',', '.'), + ); + } + + public function createdAtFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'), + ); + } + + public function createdByName(): Attribute + { + return Attribute::make( + get: fn () => $this->createdBy?->profile?->full_name ?? $this->createdBy?->username, + ); + } + + public function cashTransaction(): BelongsTo + { + return $this->belongsTo(CashTransaction::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 99af3a2..b2ebd99 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -70,4 +70,9 @@ public function cashTransactions(): HasMany { return $this->hasMany(CashTransaction::class, 'created_by_id'); } + + public function expenses(): HasMany + { + return $this->hasMany(Expense::class, 'created_by_id'); + } } diff --git a/app/Services/Finance/CashService.php b/app/Services/Finance/CashService.php index 1112bd5..2289068 100644 --- a/app/Services/Finance/CashService.php +++ b/app/Services/Finance/CashService.php @@ -164,6 +164,42 @@ public function deleteTransaction(CashTransaction $transaction): void }); } + public function updateReferencedTransaction( + CashTransaction $transaction, + int $amount, + string $description, + ): void { + DB::transaction(function () use ($transaction, $amount, $description): void { + CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id); + + $transaction->amount = $amount; + $transaction->description = $description; + $transaction->save(); + + $this->recalculateBalances($transaction->cashAccount); + + $account = $transaction->cashAccount->fresh(); + + if ($account->balance < 0) { + throw ValidationException::withMessages([ + 'amount' => 'Saldo kas tidak mencukupi.', + ]); + } + }); + } + + public function deleteReferencedTransaction(CashTransaction $transaction): void + { + DB::transaction(function () use ($transaction): void { + CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id); + $account = $transaction->cashAccount; + + $transaction->delete(); + + $this->recalculateBalances($account); + }); + } + private function ensureEditable(CashTransaction $transaction): void { if ($transaction->reference_type !== null) { diff --git a/app/Services/Finance/ExpenseService.php b/app/Services/Finance/ExpenseService.php new file mode 100644 index 0000000..61a7723 --- /dev/null +++ b/app/Services/Finance/ExpenseService.php @@ -0,0 +1,109 @@ +with(['createdBy.profile']) + ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { + $search = $tableQuery['search']; + $query->where(function (Builder $query) use ($search): void { + $query->where('description', 'like', "%{$search}%"); + }); + }); + + $this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']); + + return $query + ->paginate(10) + ->withQueryString(); + } + + /** + * @param array{amount: int, description: string} $validated + */ + public function create(array $validated, User $user): void + { + DB::transaction(function () use ($validated, $user): void { + $amount = (int) $validated['amount']; + $description = $validated['description']; + + $expense = Expense::create([ + 'amount' => $amount, + 'description' => $description, + 'created_by_id' => $user->id, + ]); + + $cashTransaction = $this->cashService->recordOutgoing( + $expense, + $amount, + $description, + $user, + ); + + $expense->cash_transaction_id = $cashTransaction->id; + $expense->save(); + }); + } + + /** + * @param array{amount: int, description: string} $validated + */ + public function update(Expense $expense, array $validated): void + { + DB::transaction(function () use ($expense, $validated): void { + $amount = (int) $validated['amount']; + $description = $validated['description']; + + $expense->amount = $amount; + $expense->description = $description; + $expense->save(); + + if ($expense->cashTransaction) { + $this->cashService->updateReferencedTransaction( + $expense->cashTransaction, + $amount, + $description, + ); + } + }); + } + + public function delete(Expense $expense): void + { + DB::transaction(function () use ($expense): void { + if ($expense->cashTransaction) { + $this->cashService->deleteReferencedTransaction($expense->cashTransaction); + } + + $expense->delete(); + }); + } + + private function applySorting(Builder $query, string $sort, string $direction): void + { + if (in_array($sort, ['created_at', 'amount', 'description'], true)) { + $query->orderBy($sort, $direction); + + return; + } + + $query->latest(); + } +} diff --git a/database/migrations/2026_06_10_150001_create_expenses_table.php b/database/migrations/2026_06_10_150001_create_expenses_table.php new file mode 100644 index 0000000..2847adb --- /dev/null +++ b/database/migrations/2026_06_10_150001_create_expenses_table.php @@ -0,0 +1,30 @@ +id(); + + $table->foreignId('cash_transaction_id')->nullable()->unique()->constrained()->restrictOnDelete(); + $table->foreignId('created_by_id')->constrained('users')->restrictOnDelete(); + + $table->unsignedBigInteger('amount'); + $table->string('description', 200); + + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('expenses'); + } +}; diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index c0c6f7a..8e3475e 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@ + +