From 0e8166a7ff9103fb87ba54bab276445238081e67 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 10 Jun 2026 23:48:49 +0700 Subject: [PATCH] Add cash management features including CashAccount and CashTransaction models, CashController for handling transactions, and corresponding requests for validation. Implement permissions in the Permission and Role enums, and update routes for cash operations. Enhance UI components for cash transaction management, including a data table and form modal. Update sidebar for cash navigation. --- app/Enums/Permission.php | 11 + app/Enums/Role.php | 8 + .../Admin/Finance/CashController.php | 64 ++++++ .../Admin/Finance/DepositCashRequest.php | 22 ++ .../Finance/UpdateCashTransactionRequest.php | 25 +++ app/Models/CashAccount.php | 33 +++ app/Models/CashTransaction.php | 112 +++++++++ app/Models/User.php | 6 + app/Services/Finance/CashService.php | 212 ++++++++++++++++++ ...6_10_120001_create_cash_accounts_table.php | 29 +++ ..._120002_create_cash_transactions_table.php | 32 +++ ..._140001_change_cash_amounts_to_integer.php | 32 +++ database/seeders/CashAccountSeeder.php | 19 ++ database/seeders/DatabaseSeeder.php | 1 + resources/js/components/AppSidebar.vue | 18 +- .../finance/cash/CashTransactionFormModal.vue | 132 +++++++++++ .../components/admin/finance/cash/columns.ts | 75 +++++++ .../admin/finance/cash/data-table-actions.vue | 75 +++++++ .../js/pages/admin/finance/cash/Index.vue | 127 +++++++++++ resources/js/types/cash.ts | 38 ++++ routes/web.php | 21 ++ 21 files changed, 1091 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Admin/Finance/CashController.php create mode 100644 app/Http/Requests/Admin/Finance/DepositCashRequest.php create mode 100644 app/Http/Requests/Admin/Finance/UpdateCashTransactionRequest.php create mode 100644 app/Models/CashAccount.php create mode 100644 app/Models/CashTransaction.php create mode 100644 app/Services/Finance/CashService.php create mode 100644 database/migrations/2026_06_10_120001_create_cash_accounts_table.php create mode 100644 database/migrations/2026_06_10_120002_create_cash_transactions_table.php create mode 100644 database/migrations/2026_06_10_140001_change_cash_amounts_to_integer.php create mode 100644 database/seeders/CashAccountSeeder.php create mode 100644 resources/js/components/admin/finance/cash/CashTransactionFormModal.vue create mode 100644 resources/js/components/admin/finance/cash/columns.ts create mode 100644 resources/js/components/admin/finance/cash/data-table-actions.vue create mode 100644 resources/js/pages/admin/finance/cash/Index.vue create mode 100644 resources/js/types/cash.ts diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index 6616f28..e572732 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -44,6 +44,11 @@ enum Permission: string case RAW_MATERIALS_DELETE = 'raw-materials.delete'; case RAW_MATERIALS_TOGGLE_STATUS = 'raw-materials.toggle-status'; + case CASH_VIEW = 'cash.view'; + case CASH_DEPOSIT = 'cash.deposit'; + case CASH_UPDATE = 'cash.update'; + case CASH_DELETE = 'cash.delete'; + public function label(): string { return match ($this) { @@ -82,6 +87,11 @@ public function label(): string self::RAW_MATERIALS_UPDATE => 'Ubah Bahan Baku', self::RAW_MATERIALS_DELETE => 'Hapus Bahan Baku', self::RAW_MATERIALS_TOGGLE_STATUS => 'Ubah Status Bahan Baku', + + self::CASH_VIEW => 'Lihat Kas', + self::CASH_DEPOSIT => 'Setor Kas', + self::CASH_UPDATE => 'Ubah Setor Kas', + self::CASH_DELETE => 'Hapus Setor Kas', }; } @@ -101,6 +111,7 @@ public function group(): string self::PRODUCTS_DELETE, self::PRODUCTS_TOGGLE_STATUS => 'Produk', 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', }; } diff --git a/app/Enums/Role.php b/app/Enums/Role.php index ca31ec0..812db5a 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -66,6 +66,10 @@ public function permissions(): array Permission::RAW_MATERIALS_UPDATE, Permission::RAW_MATERIALS_DELETE, Permission::RAW_MATERIALS_TOGGLE_STATUS, + Permission::CASH_VIEW, + Permission::CASH_DEPOSIT, + Permission::CASH_UPDATE, + Permission::CASH_DELETE, ], self::ADMIN_TOKO => [ Permission::DASHBOARD_VIEW, @@ -91,6 +95,10 @@ public function permissions(): array Permission::PRODUCTS_UPDATE, Permission::PRODUCTS_DELETE, Permission::PRODUCTS_TOGGLE_STATUS, + Permission::CASH_VIEW, + Permission::CASH_DEPOSIT, + Permission::CASH_UPDATE, + Permission::CASH_DELETE, ], self::ADMIN_BAHAN_BAKU => [ Permission::DASHBOARD_VIEW, diff --git a/app/Http/Controllers/Admin/Finance/CashController.php b/app/Http/Controllers/Admin/Finance/CashController.php new file mode 100644 index 0000000..0cd52e3 --- /dev/null +++ b/app/Http/Controllers/Admin/Finance/CashController.php @@ -0,0 +1,64 @@ +parseDataTableQuery($request); + $cashAccount = $this->cashService->getDefaultAccount(); + + return Inertia::render('admin/finance/cash/Index', [ + 'cashAccount' => $cashAccount, + 'transactions' => $this->cashService->paginateForIndex($cashAccount, $tableQuery), + 'filters' => $this->dataTableFilters($tableQuery), + ]); + } + + public function deposit(DepositCashRequest $request): RedirectResponse + { + $cashAccount = $this->cashService->getDefaultAccount(); + + $this->cashService->deposit($cashAccount, $request->validated(), $request->user()); + + Inertia::flash('success', 'Setor kas berhasil dicatat.'); + + return redirect()->route('admin.finance.cash.index'); + } + + public function update(UpdateCashTransactionRequest $request, CashTransaction $cashTransaction): RedirectResponse + { + $this->cashService->updateDeposit($cashTransaction, $request->validated()); + + Inertia::flash('success', 'Setor kas berhasil diperbarui.'); + + return redirect()->route('admin.finance.cash.index'); + } + + public function destroy(CashTransaction $cashTransaction): RedirectResponse + { + $this->cashService->deleteTransaction($cashTransaction); + + Inertia::flash('success', 'Setor kas berhasil dihapus.'); + + return redirect()->route('admin.finance.cash.index'); + } +} diff --git a/app/Http/Requests/Admin/Finance/DepositCashRequest.php b/app/Http/Requests/Admin/Finance/DepositCashRequest.php new file mode 100644 index 0000000..9623fe0 --- /dev/null +++ b/app/Http/Requests/Admin/Finance/DepositCashRequest.php @@ -0,0 +1,22 @@ +user()?->can(Permission::CASH_DEPOSIT->value) ?? false; + } + + public function rules(): array + { + return [ + 'amount' => ['required', 'integer', 'min:1'], + 'description' => ['required', 'string', 'max:200'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Finance/UpdateCashTransactionRequest.php b/app/Http/Requests/Admin/Finance/UpdateCashTransactionRequest.php new file mode 100644 index 0000000..0a326a0 --- /dev/null +++ b/app/Http/Requests/Admin/Finance/UpdateCashTransactionRequest.php @@ -0,0 +1,25 @@ +route('cashTransaction'); + + return $this->user()?->can(Permission::CASH_UPDATE->value) + && $transaction?->reference_type === null; + } + + public function rules(): array + { + return [ + 'amount' => ['required', 'integer', 'min:1'], + 'description' => ['required', 'string', 'max:200'], + ]; + } +} diff --git a/app/Models/CashAccount.php b/app/Models/CashAccount.php new file mode 100644 index 0000000..a252f4c --- /dev/null +++ b/app/Models/CashAccount.php @@ -0,0 +1,33 @@ + 'integer', + ]; + } + + public function balanceFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->balance, 0, ',', '.'), + ); + } + + public function transactions(): HasMany + { + return $this->hasMany(CashTransaction::class); + } +} diff --git a/app/Models/CashTransaction.php b/app/Models/CashTransaction.php new file mode 100644 index 0000000..a86fbaa --- /dev/null +++ b/app/Models/CashTransaction.php @@ -0,0 +1,112 @@ + 'integer', + 'balance_after' => 'integer', + ]; + } + + public function amountFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->amount, 0, ',', '.'), + ); + } + + public function balanceAfterFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->balance_after, 0, ',', '.'), + ); + } + + public function referenceLabel(): Attribute + { + return Attribute::make( + get: fn () => self::labelForReferenceType($this->reference_type), + ); + } + + public function isIncoming(): Attribute + { + return Attribute::make( + get: fn () => self::isIncomingReference($this->reference_type), + ); + } + + 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 static function labelForReferenceType(?string $referenceType): string + { + if ($referenceType === null) { + return 'Setor Kas'; + } + + return match ($referenceType) { + default => class_basename($referenceType), + }; + } + + public static function isIncomingReference(?string $referenceType): bool + { + if ($referenceType === null) { + return true; + } + + return match ($referenceType) { + default => false, + }; + } + + public function cashAccount(): BelongsTo + { + return $this->belongsTo(CashAccount::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_id'); + } + + public function reference(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 941a389..99af3a2 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; @@ -64,4 +65,9 @@ public function employee(): HasOne { return $this->hasOne(Employee::class); } + + public function cashTransactions(): HasMany + { + return $this->hasMany(CashTransaction::class, 'created_by_id'); + } } diff --git a/app/Services/Finance/CashService.php b/app/Services/Finance/CashService.php new file mode 100644 index 0000000..1112bd5 --- /dev/null +++ b/app/Services/Finance/CashService.php @@ -0,0 +1,212 @@ +firstOrFail(); + } + + /** + * @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery + */ + public function paginateForIndex(CashAccount $cashAccount, array $tableQuery): LengthAwarePaginator + { + $query = CashTransaction::query() + ->with(['createdBy.profile', 'reference']) + ->where('cash_account_id', $cashAccount->id) + ->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 deposit(CashAccount $cashAccount, array $validated, User $user): CashTransaction + { + return DB::transaction(function () use ($cashAccount, $validated, $user): CashTransaction { + $account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id); + $amount = (int) $validated['amount']; + $newBalance = $account->balance + $amount; + + $account->balance = $newBalance; + $account->save(); + + return CashTransaction::create([ + 'cash_account_id' => $account->id, + 'amount' => $amount, + 'balance_after' => $newBalance, + 'description' => $validated['description'], + 'created_by_id' => $user->id, + ]); + }); + } + + public function recordOutgoing( + Model $reference, + int $amount, + string $description, + User $user, + ?CashAccount $cashAccount = null, + ): CashTransaction { + return DB::transaction(function () use ($reference, $amount, $description, $user, $cashAccount): CashTransaction { + $account = CashAccount::query()->lockForUpdate()->findOrFail( + ($cashAccount ?? $this->getDefaultAccount())->id, + ); + + if ($account->balance < $amount) { + throw ValidationException::withMessages([ + 'amount' => 'Saldo kas tidak mencukupi.', + ]); + } + + $newBalance = $account->balance - $amount; + + $account->balance = $newBalance; + $account->save(); + + $transaction = new CashTransaction([ + 'cash_account_id' => $account->id, + 'amount' => $amount, + 'balance_after' => $newBalance, + 'description' => $description, + 'created_by_id' => $user->id, + ]); + + $transaction->reference()->associate($reference); + $transaction->save(); + + return $transaction; + }); + } + + public function recordIncoming( + Model $reference, + int $amount, + string $description, + User $user, + ?CashAccount $cashAccount = null, + ): CashTransaction { + return DB::transaction(function () use ($reference, $amount, $description, $user, $cashAccount): CashTransaction { + $account = CashAccount::query()->lockForUpdate()->findOrFail( + ($cashAccount ?? $this->getDefaultAccount())->id, + ); + + $newBalance = $account->balance + $amount; + + $account->balance = $newBalance; + $account->save(); + + $transaction = new CashTransaction([ + 'cash_account_id' => $account->id, + 'amount' => $amount, + 'balance_after' => $newBalance, + 'description' => $description, + 'created_by_id' => $user->id, + ]); + + $transaction->reference()->associate($reference); + $transaction->save(); + + return $transaction; + }); + } + + /** + * @param array{amount: int, description: string} $validated + */ + public function updateDeposit(CashTransaction $transaction, array $validated): void + { + $this->ensureEditable($transaction); + + DB::transaction(function () use ($transaction, $validated): void { + CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id); + + $transaction->amount = (int) $validated['amount']; + $transaction->description = $validated['description']; + $transaction->save(); + + $this->recalculateBalances($transaction->cashAccount); + }); + } + + public function deleteTransaction(CashTransaction $transaction): void + { + $this->ensureEditable($transaction); + + DB::transaction(function () use ($transaction): void { + CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id); + + $transaction->delete(); + + $this->recalculateBalances($transaction->cashAccount); + }); + } + + private function ensureEditable(CashTransaction $transaction): void + { + if ($transaction->reference_type !== null) { + throw ValidationException::withMessages([ + 'transaction' => 'Transaksi ini tidak dapat diubah dari halaman kas.', + ]); + } + } + + private function recalculateBalances(CashAccount $cashAccount): void + { + $account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id); + $runningBalance = 0; + + $transactions = CashTransaction::query() + ->where('cash_account_id', $account->id) + ->orderBy('created_at') + ->orderBy('id') + ->get(); + + foreach ($transactions as $transaction) { + if (CashTransaction::isIncomingReference($transaction->reference_type)) { + $runningBalance += $transaction->amount; + } else { + $runningBalance -= $transaction->amount; + } + + $transaction->balance_after = $runningBalance; + $transaction->saveQuietly(); + } + + $account->balance = $runningBalance; + $account->save(); + } + + private function applySorting(Builder $query, string $sort, string $direction): void + { + if (in_array($sort, ['created_at', 'amount', 'reference_type'], true)) { + $query->orderBy($sort, $direction); + + return; + } + + $query->latest(); + } +} diff --git a/database/migrations/2026_06_10_120001_create_cash_accounts_table.php b/database/migrations/2026_06_10_120001_create_cash_accounts_table.php new file mode 100644 index 0000000..6fb03e4 --- /dev/null +++ b/database/migrations/2026_06_10_120001_create_cash_accounts_table.php @@ -0,0 +1,29 @@ +id(); + + $table->foreignId('created_by_id')->constrained('users')->restrictOnDelete(); + + $table->string('name', 200); + $table->unsignedBigInteger('balance')->default(0); + + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('cash_accounts'); + } +}; diff --git a/database/migrations/2026_06_10_120002_create_cash_transactions_table.php b/database/migrations/2026_06_10_120002_create_cash_transactions_table.php new file mode 100644 index 0000000..b586203 --- /dev/null +++ b/database/migrations/2026_06_10_120002_create_cash_transactions_table.php @@ -0,0 +1,32 @@ +id(); + + $table->foreignId('cash_account_id')->constrained()->cascadeOnDelete(); + $table->foreignId('created_by_id')->constrained('users')->restrictOnDelete(); + + $table->nullableMorphs('reference'); + $table->unsignedBigInteger('amount'); + $table->unsignedBigInteger('balance_after'); + $table->string('description', 200); + + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('cash_transactions'); + } +}; diff --git a/database/migrations/2026_06_10_140001_change_cash_amounts_to_integer.php b/database/migrations/2026_06_10_140001_change_cash_amounts_to_integer.php new file mode 100644 index 0000000..9a6c66c --- /dev/null +++ b/database/migrations/2026_06_10_140001_change_cash_amounts_to_integer.php @@ -0,0 +1,32 @@ +unsignedBigInteger('balance')->default(0)->change(); + }); + + Schema::table('cash_transactions', function (Blueprint $table) { + $table->unsignedBigInteger('amount')->change(); + $table->unsignedBigInteger('balance_after')->change(); + }); + } + + public function down(): void + { + Schema::table('cash_accounts', function (Blueprint $table) { + $table->decimal('balance', 18, 2)->default(0)->change(); + }); + + Schema::table('cash_transactions', function (Blueprint $table) { + $table->decimal('amount', 18, 2)->change(); + $table->decimal('balance_after', 18, 2)->change(); + }); + } +}; diff --git a/database/seeders/CashAccountSeeder.php b/database/seeders/CashAccountSeeder.php new file mode 100644 index 0000000..d84e738 --- /dev/null +++ b/database/seeders/CashAccountSeeder.php @@ -0,0 +1,19 @@ + User::first()->id], + ['name' => 'Kas Toko'], + ['balance' => 0], + ); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e6bb69a..c62867c 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -22,6 +22,7 @@ public function run(): void RawMaterialSeeder::class, SupplierSeeder::class, CustomerSeeder::class, + CashAccountSeeder::class, ]); } } diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 22bd723..c0c6f7a 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@ + +