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 @@ + +