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