diff --git a/app/Enums/EmployeeAdvanceStatus.php b/app/Enums/EmployeeAdvanceStatus.php new file mode 100644 index 0000000..2233737 --- /dev/null +++ b/app/Enums/EmployeeAdvanceStatus.php @@ -0,0 +1,25 @@ + 'Menunggu', + self::APPROVED => 'Disetujui', + self::REJECTED => 'Ditolak', + self::PAID => 'Lunas', + }; + } +} diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index d12c1ba..9048ff0 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -54,6 +54,13 @@ enum Permission: string case EXPENSES_UPDATE = 'expenses.update'; case EXPENSES_DELETE = 'expenses.delete'; + case EMPLOYEE_ADVANCES_VIEW = 'employee-advances.view'; + case EMPLOYEE_ADVANCES_CREATE = 'employee-advances.create'; + case EMPLOYEE_ADVANCES_UPDATE = 'employee-advances.update'; + case EMPLOYEE_ADVANCES_DELETE = 'employee-advances.delete'; + case EMPLOYEE_ADVANCES_VERIFY = 'employee-advances.verify'; + case EMPLOYEE_ADVANCES_PAY = 'employee-advances.pay'; + public function label(): string { return match ($this) { @@ -102,6 +109,13 @@ public function label(): string self::EXPENSES_CREATE => 'Tambah Pengeluaran', self::EXPENSES_UPDATE => 'Ubah Pengeluaran', self::EXPENSES_DELETE => 'Hapus Pengeluaran', + + self::EMPLOYEE_ADVANCES_VIEW => 'Lihat Kasbon', + self::EMPLOYEE_ADVANCES_CREATE => 'Ajukan Kasbon', + self::EMPLOYEE_ADVANCES_UPDATE => 'Ubah Kasbon', + self::EMPLOYEE_ADVANCES_DELETE => 'Hapus Kasbon', + self::EMPLOYEE_ADVANCES_VERIFY => 'Setujui/Tolak Kasbon', + self::EMPLOYEE_ADVANCES_PAY => 'Pelunasi Kasbon', }; } @@ -124,6 +138,9 @@ public function group(): string 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', + self::EMPLOYEE_ADVANCES_VIEW, self::EMPLOYEE_ADVANCES_CREATE, self::EMPLOYEE_ADVANCES_UPDATE, + self::EMPLOYEE_ADVANCES_DELETE, self::EMPLOYEE_ADVANCES_VERIFY, + self::EMPLOYEE_ADVANCES_PAY => 'Kasbon', }; } diff --git a/app/Enums/Role.php b/app/Enums/Role.php index 74e79d6..4c3e46d 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -74,6 +74,11 @@ public function permissions(): array Permission::EXPENSES_CREATE, Permission::EXPENSES_UPDATE, Permission::EXPENSES_DELETE, + Permission::EMPLOYEE_ADVANCES_VIEW, + Permission::EMPLOYEE_ADVANCES_CREATE, + Permission::EMPLOYEE_ADVANCES_UPDATE, + Permission::EMPLOYEE_ADVANCES_DELETE, + Permission::EMPLOYEE_ADVANCES_PAY, ], self::ADMIN_TOKO => [ Permission::DASHBOARD_VIEW, @@ -107,6 +112,11 @@ public function permissions(): array Permission::EXPENSES_CREATE, Permission::EXPENSES_UPDATE, Permission::EXPENSES_DELETE, + Permission::EMPLOYEE_ADVANCES_VIEW, + Permission::EMPLOYEE_ADVANCES_CREATE, + Permission::EMPLOYEE_ADVANCES_UPDATE, + Permission::EMPLOYEE_ADVANCES_DELETE, + Permission::EMPLOYEE_ADVANCES_PAY, ], self::ADMIN_BAHAN_BAKU => [ Permission::DASHBOARD_VIEW, diff --git a/app/Http/Controllers/Admin/Finance/EmployeeAdvanceController.php b/app/Http/Controllers/Admin/Finance/EmployeeAdvanceController.php new file mode 100644 index 0000000..88cffe6 --- /dev/null +++ b/app/Http/Controllers/Admin/Finance/EmployeeAdvanceController.php @@ -0,0 +1,99 @@ +parseDataTableQuery($request); + $user = auth()->user(); + + return Inertia::render('admin/finance/employee-advances/Index', [ + 'employeeAdvances' => $this->employeeAdvanceService->paginateForIndex($tableQuery), + 'summary' => $this->employeeAdvanceService->outstandingSummary(), + 'authEmployeeId' => $user?->employee?->id, + 'canSubmit' => $user?->can(Permission::EMPLOYEE_ADVANCES_CREATE->value) + && ! $user->can(Permission::EMPLOYEE_ADVANCES_VERIFY->value) + && $user->employee !== null, + 'filters' => $this->dataTableFilters($tableQuery), + ]); + } + + public function store(EmployeeAdvanceRequest $request): RedirectResponse + { + $this->employeeAdvanceService->create($request->validated()); + + Inertia::flash('success', 'Kasbon berhasil diajukan.'); + + return redirect()->route('admin.finance.employee-advances.index'); + } + + public function update(EmployeeAdvanceRequest $request, EmployeeAdvance $employeeAdvance): RedirectResponse + { + $this->employeeAdvanceService->update($employeeAdvance, $request->validated()); + + Inertia::flash('success', 'Kasbon berhasil diperbarui.'); + + return redirect()->route('admin.finance.employee-advances.index'); + } + + public function destroy(EmployeeAdvance $employeeAdvance): RedirectResponse + { + abort_unless(! auth()->user()?->can(Permission::EMPLOYEE_ADVANCES_VERIFY->value), 403); + + $this->employeeAdvanceService->delete($employeeAdvance); + + Inertia::flash('success', 'Kasbon berhasil dihapus.'); + + return redirect()->route('admin.finance.employee-advances.index'); + } + + public function approve(EmployeeAdvance $employeeAdvance): RedirectResponse + { + $this->employeeAdvanceService->approve($employeeAdvance); + + Inertia::flash('success', 'Kasbon disetujui dan dicairkan dari kas.'); + + return redirect()->route('admin.finance.employee-advances.index'); + } + + public function reject(RejectEmployeeAdvanceRequest $request, EmployeeAdvance $employeeAdvance): RedirectResponse + { + $this->employeeAdvanceService->reject( + $employeeAdvance, + $request->validated('reason'), + ); + + Inertia::flash('success', 'Kasbon berhasil ditolak.'); + + return redirect()->route('admin.finance.employee-advances.index'); + } + + public function pay(EmployeeAdvance $employeeAdvance): RedirectResponse + { + $this->employeeAdvanceService->pay($employeeAdvance); + + Inertia::flash('success', 'Kasbon berhasil dilunasi.'); + + return redirect()->route('admin.finance.employee-advances.index'); + } +} diff --git a/app/Http/Requests/Admin/Finance/EmployeeAdvanceRequest.php b/app/Http/Requests/Admin/Finance/EmployeeAdvanceRequest.php new file mode 100644 index 0000000..000166e --- /dev/null +++ b/app/Http/Requests/Admin/Finance/EmployeeAdvanceRequest.php @@ -0,0 +1,47 @@ +user(); + + if ($user === null || $user->can(Permission::EMPLOYEE_ADVANCES_VERIFY->value)) { + return false; + } + + if ($this->isMethod('POST')) { + return $user->can(Permission::EMPLOYEE_ADVANCES_CREATE->value) + && $user->employee !== null; + } + + if (! $user->can(Permission::EMPLOYEE_ADVANCES_UPDATE->value)) { + return false; + } + + /** @var EmployeeAdvance|null $employeeAdvance */ + $employeeAdvance = $this->route('employeeAdvance'); + + return $employeeAdvance?->status === EmployeeAdvanceStatus::PENDING + && $user->employee?->id === $employeeAdvance->employee_id; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'amount' => ['required', 'integer', 'min:1'], + 'description' => ['required', 'string', 'max:500'], + 'due_date' => ['required', 'date'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Finance/RejectEmployeeAdvanceRequest.php b/app/Http/Requests/Admin/Finance/RejectEmployeeAdvanceRequest.php new file mode 100644 index 0000000..b67e1b1 --- /dev/null +++ b/app/Http/Requests/Admin/Finance/RejectEmployeeAdvanceRequest.php @@ -0,0 +1,24 @@ +user()?->can(Permission::EMPLOYEE_ADVANCES_VERIFY->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'reason' => ['required', 'string', 'max:500'], + ]; + } +} diff --git a/app/Models/CashTransaction.php b/app/Models/CashTransaction.php index 73184b0..44c719a 100644 --- a/app/Models/CashTransaction.php +++ b/app/Models/CashTransaction.php @@ -48,14 +48,28 @@ public function balanceAfterFormatted(): Attribute public function referenceLabel(): Attribute { return Attribute::make( - get: fn () => self::labelForReferenceType($this->reference_type), + get: function () { + if ($this->reference_type === EmployeeAdvance::class && $this->reference instanceof EmployeeAdvance) { + return $this->reference->repayment_cash_transaction_id === $this->id + ? 'Pelunasan Kasbon' + : 'Pencairan Kasbon'; + } + + return self::labelForReferenceType($this->reference_type); + }, ); } public function isIncoming(): Attribute { return Attribute::make( - get: fn () => self::isIncomingReference($this->reference_type), + get: function () { + if ($this->reference_type === EmployeeAdvance::class && $this->reference instanceof EmployeeAdvance) { + return $this->reference->repayment_cash_transaction_id === $this->id; + } + + return self::isIncomingTransaction($this); + }, ); } @@ -81,6 +95,7 @@ public static function labelForReferenceType(?string $referenceType): string return match ($referenceType) { Expense::class => 'Pengeluaran', + EmployeeAdvance::class => 'Kasbon Pegawai', default => class_basename($referenceType), }; } @@ -96,6 +111,15 @@ public static function isIncomingReference(?string $referenceType): bool }; } + public static function isIncomingTransaction(self $transaction): bool + { + if ($transaction->reference_type === EmployeeAdvance::class && $transaction->reference instanceof EmployeeAdvance) { + return $transaction->reference->repayment_cash_transaction_id === $transaction->id; + } + + return self::isIncomingReference($transaction->reference_type); + } + public function cashAccount(): BelongsTo { return $this->belongsTo(CashAccount::class); diff --git a/app/Models/Concerns/HasRejection.php b/app/Models/Concerns/HasRejection.php new file mode 100644 index 0000000..a5b57ba --- /dev/null +++ b/app/Models/Concerns/HasRejection.php @@ -0,0 +1,14 @@ +morphOne(Rejection::class, 'rejectable'); + } +} diff --git a/app/Models/Employee.php b/app/Models/Employee.php index 6ecc018..78108ec 100644 --- a/app/Models/Employee.php +++ b/app/Models/Employee.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; #[Guarded(['id'])] @@ -91,4 +92,9 @@ public function user(): BelongsTo { return $this->belongsTo(User::class); } + + public function advances(): HasMany + { + return $this->hasMany(EmployeeAdvance::class); + } } diff --git a/app/Models/EmployeeAdvance.php b/app/Models/EmployeeAdvance.php new file mode 100644 index 0000000..1de739e --- /dev/null +++ b/app/Models/EmployeeAdvance.php @@ -0,0 +1,136 @@ + 'integer', + 'due_date' => 'date', + 'status' => EmployeeAdvanceStatus::class, + 'verified_at' => 'datetime', + 'paid_at' => 'datetime', + ]; + } + + public function amountFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->amount, 0, ',', '.'), + ); + } + + public function dueDateFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->due_date?->translatedFormat('l, d F Y'), + ); + } + + public function dueDateInput(): Attribute + { + return Attribute::make( + get: fn () => $this->due_date?->format('Y-m-d'), + ); + } + + public function statusLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->status?->label(), + ); + } + + public function employeeName(): Attribute + { + return Attribute::make( + get: fn () => $this->employee?->user?->profile?->full_name + ?? $this->employee?->user?->username, + ); + } + + public function rejectionReason(): Attribute + { + return Attribute::make( + get: fn () => $this->rejection?->reason, + ); + } + + public function createdAtFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'), + ); + } + + public function isEditable(): Attribute + { + return Attribute::make( + get: fn () => $this->status === EmployeeAdvanceStatus::PENDING, + ); + } + + public function canVerify(): Attribute + { + return Attribute::make( + get: fn () => $this->status === EmployeeAdvanceStatus::PENDING, + ); + } + + public function canPay(): Attribute + { + return Attribute::make( + get: fn () => $this->status === EmployeeAdvanceStatus::APPROVED, + ); + } + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function cashTransaction(): BelongsTo + { + return $this->belongsTo(CashTransaction::class); + } + + public function repaymentCashTransaction(): BelongsTo + { + return $this->belongsTo(CashTransaction::class, 'repayment_cash_transaction_id'); + } + + public function verifiedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'verified_by_id'); + } + + public function paidBy(): BelongsTo + { + return $this->belongsTo(User::class, 'paid_by_id'); + } +} diff --git a/app/Models/Rejection.php b/app/Models/Rejection.php new file mode 100644 index 0000000..618c367 --- /dev/null +++ b/app/Models/Rejection.php @@ -0,0 +1,22 @@ +morphTo(); + } + + public function rejectedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'rejected_by_id'); + } +} diff --git a/app/Services/Finance/CashService.php b/app/Services/Finance/CashService.php index 2289068..28f3dfe 100644 --- a/app/Services/Finance/CashService.php +++ b/app/Services/Finance/CashService.php @@ -215,13 +215,14 @@ private function recalculateBalances(CashAccount $cashAccount): void $runningBalance = 0; $transactions = CashTransaction::query() + ->with('reference') ->where('cash_account_id', $account->id) ->orderBy('created_at') ->orderBy('id') ->get(); foreach ($transactions as $transaction) { - if (CashTransaction::isIncomingReference($transaction->reference_type)) { + if (CashTransaction::isIncomingTransaction($transaction)) { $runningBalance += $transaction->amount; } else { $runningBalance -= $transaction->amount; diff --git a/app/Services/Finance/EmployeeAdvanceService.php b/app/Services/Finance/EmployeeAdvanceService.php new file mode 100644 index 0000000..d6b11e8 --- /dev/null +++ b/app/Services/Finance/EmployeeAdvanceService.php @@ -0,0 +1,215 @@ +where('status', EmployeeAdvanceStatus::APPROVED) + ->sum('amount'); + + $outstandingCount = EmployeeAdvance::query() + ->where('status', EmployeeAdvanceStatus::APPROVED) + ->count(); + + return [ + 'outstanding_amount' => $outstandingAmount, + 'outstanding_amount_formatted' => 'Rp '.number_format($outstandingAmount, 0, ',', '.'), + 'outstanding_count' => $outstandingCount, + ]; + } + + /** + * @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery + */ + public function paginateForIndex(array $tableQuery): LengthAwarePaginator + { + $query = EmployeeAdvance::query() + ->with(['employee.user.profile', 'rejection']) + ->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}%") + ->orWhereHas('employee.user.profile', fn (Builder $query) => $query->where('full_name', 'like', "%{$search}%")) + ->orWhereHas('employee.user', fn (Builder $query) => $query->where('username', 'like', "%{$search}%")) + ->orWhereHas('rejection', fn (Builder $query) => $query->where('reason', 'like', "%{$search}%")); + }); + }); + + $this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']); + + return $query + ->paginate(10) + ->withQueryString(); + } + + /** + * @param array{amount: int, description: string, due_date: string} $validated + */ + public function create(array $validated): void + { + $employee = auth()->user()?->employee; + + if ($employee === null) { + throw ValidationException::withMessages([ + 'employee' => 'Akun Anda tidak terhubung ke data pegawai.', + ]); + } + + EmployeeAdvance::create([ + 'employee_id' => $employee->id, + 'amount' => (int) $validated['amount'], + 'description' => $validated['description'], + 'due_date' => $validated['due_date'], + 'status' => EmployeeAdvanceStatus::PENDING, + ]); + } + + /** + * @param array{amount: int, description: string, due_date: string} $validated + */ + public function update(EmployeeAdvance $employeeAdvance, array $validated): void + { + $this->ensureOwnedBySubmitter($employeeAdvance); + $this->ensurePending($employeeAdvance, 'Kasbon hanya dapat diubah saat status menunggu.'); + + $employeeAdvance->amount = (int) $validated['amount']; + $employeeAdvance->description = $validated['description']; + $employeeAdvance->due_date = $validated['due_date']; + $employeeAdvance->save(); + } + + public function delete(EmployeeAdvance $employeeAdvance): void + { + $this->ensureOwnedBySubmitter($employeeAdvance); + $this->ensurePending($employeeAdvance, 'Kasbon hanya dapat dihapus saat status menunggu.'); + + $employeeAdvance->delete(); + } + + public function approve(EmployeeAdvance $employeeAdvance): void + { + $this->ensurePending($employeeAdvance, 'Kasbon ini sudah diverifikasi.'); + + $user = auth()->user(); + + DB::transaction(function () use ($employeeAdvance, $user): void { + $employeeAdvance->loadMissing('employee.user.profile'); + + $description = sprintf( + 'Pencairan kasbon: %s', + $employeeAdvance->employeeName, + ); + + $cashTransaction = $this->cashService->recordOutgoing( + $employeeAdvance, + $employeeAdvance->amount, + $description, + $user, + ); + + $employeeAdvance->cash_transaction_id = $cashTransaction->id; + $employeeAdvance->status = EmployeeAdvanceStatus::APPROVED; + $employeeAdvance->verified_at = now(); + $employeeAdvance->verified_by_id = $user->id; + $employeeAdvance->save(); + }); + } + + public function reject(EmployeeAdvance $employeeAdvance, string $reason): void + { + $this->ensurePending($employeeAdvance, 'Kasbon ini sudah diverifikasi.'); + + $user = auth()->user(); + + DB::transaction(function () use ($employeeAdvance, $user, $reason): void { + $employeeAdvance->status = EmployeeAdvanceStatus::REJECTED; + $employeeAdvance->verified_at = now(); + $employeeAdvance->verified_by_id = $user->id; + $employeeAdvance->save(); + + $employeeAdvance->rejection()->create([ + 'reason' => $reason, + 'rejected_by_id' => $user->id, + ]); + }); + } + + public function pay(EmployeeAdvance $employeeAdvance): void + { + if ($employeeAdvance->status !== EmployeeAdvanceStatus::APPROVED) { + throw ValidationException::withMessages([ + 'employee_advance' => 'Kasbon hanya dapat dilunasi setelah disetujui.', + ]); + } + + $user = auth()->user(); + + DB::transaction(function () use ($employeeAdvance, $user): void { + $employeeAdvance->loadMissing('employee.user.profile'); + + $description = sprintf( + 'Pelunasan kasbon: %s', + $employeeAdvance->employeeName, + ); + + $cashTransaction = $this->cashService->recordIncoming( + $employeeAdvance, + $employeeAdvance->amount, + $description, + $user, + ); + + $employeeAdvance->repayment_cash_transaction_id = $cashTransaction->id; + $employeeAdvance->paid_at = now(); + $employeeAdvance->paid_by_id = $user->id; + $employeeAdvance->status = EmployeeAdvanceStatus::PAID; + $employeeAdvance->save(); + }); + } + + private function ensureOwnedBySubmitter(EmployeeAdvance $employeeAdvance): void + { + if (auth()->user()?->employee?->id !== $employeeAdvance->employee_id) { + throw ValidationException::withMessages([ + 'employee_advance' => 'Anda tidak memiliki akses untuk mengubah kasbon ini.', + ]); + } + } + + private function ensurePending(EmployeeAdvance $employeeAdvance, string $message): void + { + if ($employeeAdvance->status !== EmployeeAdvanceStatus::PENDING) { + throw ValidationException::withMessages([ + 'employee_advance' => $message, + ]); + } + } + + private function applySorting(Builder $query, string $sort, string $direction): void + { + if (in_array($sort, ['created_at', 'amount', 'due_date', 'status', 'description'], true)) { + $query->orderBy($sort, $direction); + + return; + } + + $query->latest(); + } +} diff --git a/database/migrations/2026_06_10_160001_create_employee_advances_table.php b/database/migrations/2026_06_10_160001_create_employee_advances_table.php new file mode 100644 index 0000000..80a43b5 --- /dev/null +++ b/database/migrations/2026_06_10_160001_create_employee_advances_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('employee_id')->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('amount'); + $table->string('description', 500); + $table->date('due_date'); + $table->enum('status', array_column(EmployeeAdvanceStatus::cases(), 'value')) + ->default(EmployeeAdvanceStatus::PENDING->value); + $table->foreignId('cash_transaction_id')->nullable()->unique()->constrained()->restrictOnDelete(); + $table->foreignId('repayment_cash_transaction_id')->nullable()->unique()->constrained('cash_transactions')->restrictOnDelete(); + $table->timestamp('verified_at')->nullable(); + $table->foreignId('verified_by_id')->nullable()->constrained('users')->restrictOnDelete(); + $table->timestamp('paid_at')->nullable(); + $table->foreignId('paid_by_id')->nullable()->constrained('users')->restrictOnDelete(); + $table->timestamps(); + + $table->index(['employee_id', 'status']); + $table->index(['status', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('employee_advances'); + } +}; diff --git a/database/migrations/2026_06_10_170001_create_rejections_table.php b/database/migrations/2026_06_10_170001_create_rejections_table.php new file mode 100644 index 0000000..e3588e4 --- /dev/null +++ b/database/migrations/2026_06_10_170001_create_rejections_table.php @@ -0,0 +1,26 @@ +id(); + $table->morphs('rejectable'); + $table->string('reason', 500); + $table->foreignId('rejected_by_id')->constrained('users')->restrictOnDelete(); + $table->timestamps(); + + $table->unique(['rejectable_type', 'rejectable_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('rejections'); + } +}; diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 8e3475e..b604f4f 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@ + +