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 @@ + + + + + + {{ isEditing ? 'Ubah Setor Kas' : 'Setor Kas' }} + + + + + + + Jumlah + + + + + Keterangan + + + + + + + + + Batal + + + + {{ form.processing ? 'Menyimpan...' : 'Simpan' }} + + + + + + diff --git a/resources/js/components/admin/finance/cash/columns.ts b/resources/js/components/admin/finance/cash/columns.ts new file mode 100644 index 0000000..492bc91 --- /dev/null +++ b/resources/js/components/admin/finance/cash/columns.ts @@ -0,0 +1,75 @@ +import type { ColumnDef } from '@tanstack/vue-table'; +import { h } from 'vue'; +import DataTableActions from '@/components/admin/finance/cash/data-table-actions.vue'; +import { DataTableColumnHeader } from '@/components/data-table'; +import { Badge } from '@/components/ui/badge'; +import type { CashTransactionListItem } from '@/types/cash'; + +export function createColumns(onEdit: (transaction: CashTransactionListItem) => void): ColumnDef[] { + return [ + { + accessorKey: 'created_at_formatted', + enableSorting: true, + header: () => h(DataTableColumnHeader, { title: 'Tanggal', column: 'created_at' }), + }, + { + accessorKey: 'reference_label', + enableSorting: true, + header: () => h(DataTableColumnHeader, { title: 'Sumber', column: 'reference_type' }), + cell: ({ row }) => { + const isIncoming = row.original.is_incoming; + + return h( + Badge, + { variant: isIncoming ? 'default' : 'secondary' }, + () => row.original.reference_label, + ); + }, + }, + { + accessorKey: 'amount_formatted', + enableSorting: true, + header: () => h(DataTableColumnHeader, { title: 'Jumlah', column: 'amount' }), + cell: ({ row }) => { + const isIncoming = row.original.is_incoming; + const prefix = isIncoming ? '+' : '-'; + + return h( + 'span', + { class: isIncoming ? 'font-medium text-green-600' : 'font-medium text-red-600' }, + `${prefix} ${row.original.amount_formatted}`, + ); + }, + }, + { + accessorKey: 'balance_after_formatted', + enableSorting: false, + header: () => 'Saldo Setelah', + }, + { + accessorKey: 'description', + enableSorting: false, + header: () => 'Keterangan', + }, + { + accessorKey: 'created_by_name', + enableSorting: false, + header: () => 'Oleh', + }, + { + id: 'actions', + enableSorting: false, + enableHiding: false, + cell: ({ row }) => { + if (row.original.reference_type !== null) { + return null; + } + + return h(DataTableActions, { + transaction: row.original, + onEdit: () => onEdit(row.original), + }); + }, + }, + ]; +} diff --git a/resources/js/components/admin/finance/cash/data-table-actions.vue b/resources/js/components/admin/finance/cash/data-table-actions.vue new file mode 100644 index 0000000..3ccf117 --- /dev/null +++ b/resources/js/components/admin/finance/cash/data-table-actions.vue @@ -0,0 +1,75 @@ + + + + + + + + + + Ubah + + + Ubah + + + + + + + Hapus + + + Hapus + + + + + + diff --git a/resources/js/pages/admin/finance/cash/Index.vue b/resources/js/pages/admin/finance/cash/Index.vue new file mode 100644 index 0000000..dd54422 --- /dev/null +++ b/resources/js/pages/admin/finance/cash/Index.vue @@ -0,0 +1,127 @@ + + + + + + + + + + + {{ cashAccount.name }} + + + + + + Setor Kas + + + + + + + Saldo Saat Ini + + + + + + {{ cashAccount.balance_formatted }} + + + + + + + + + + + + + diff --git a/resources/js/types/cash.ts b/resources/js/types/cash.ts new file mode 100644 index 0000000..cf296ce --- /dev/null +++ b/resources/js/types/cash.ts @@ -0,0 +1,38 @@ +export type CashAccount = { + id: number; + name: string; + balance: number; + balance_formatted: string; +}; + +export type CashTransactionListItem = { + id: number; + reference_type: string | null; + reference_label: string; + is_incoming: boolean; + amount: number; + amount_formatted: string; + balance_after: number; + balance_after_formatted: string; + description: string; + created_at_formatted: string; + created_by_name: string; +}; + +export type CashTransactionFormData = { + amount: string; + description: string; +}; + +export type PaginatedCashTransactions = { + data: CashTransactionListItem[]; + current_page: number; + last_page: number; + per_page: number; + total: number; + links: Array<{ + url: string | null; + label: string; + active: boolean; + }>; +}; diff --git a/routes/web.php b/routes/web.php index 454385e..c42d3bc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Enums\Permission; use App\Http\Controllers\Admin\DashboardController; +use App\Http\Controllers\Admin\Finance\CashController; use App\Http\Controllers\Admin\Hr\EmployeeController; use App\Http\Controllers\Admin\Master\CategoryController; use App\Http\Controllers\Admin\Master\CustomerController; @@ -157,6 +158,26 @@ Route::put('appearance', [AppearanceController::class, 'update'])->name('appearance.update'); }); + Route::prefix('finance')->name('finance.')->group(function () { + Route::prefix('cash')->name('cash.') + ->middleware('permission:'.Permission::CASH_VIEW->value) + ->group(function () { + Route::get('/', [CashController::class, 'index'])->name('index'); + + Route::post('deposit', [CashController::class, 'deposit']) + ->middleware('permission:'.Permission::CASH_DEPOSIT->value) + ->name('deposit'); + + Route::put('transactions/{cashTransaction}', [CashController::class, 'update']) + ->middleware('permission:'.Permission::CASH_UPDATE->value) + ->name('transactions.update'); + + Route::delete('transactions/{cashTransaction}', [CashController::class, 'destroy']) + ->middleware('permission:'.Permission::CASH_DELETE->value) + ->name('transactions.destroy'); + }); + }); + Route::prefix('hr')->name('hr.')->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)->group(function () { Route::prefix('employees')->name('employees.') ->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)