From 3d4471ab91887c8e5d71149131feb56fc0a23256 Mon Sep 17 00:00:00 2001 From: tuxarmy Date: Fri, 3 Apr 2026 05:02:33 +0700 Subject: [PATCH] feat: implementasi threshold keuangan otomatis via observer dan action approval --- .../Approvals/Tables/ApprovalsTable.php | 72 ++++++++++++++++++- .../CashRecords/Schemas/CashRecordForm.php | 5 +- app/Observers/CashRecordObserver.php | 50 ++++++++++++- 3 files changed, 123 insertions(+), 4 deletions(-) diff --git a/app/Filament/Resources/Approvals/Tables/ApprovalsTable.php b/app/Filament/Resources/Approvals/Tables/ApprovalsTable.php index 262046d..2e8c55a 100644 --- a/app/Filament/Resources/Approvals/Tables/ApprovalsTable.php +++ b/app/Filament/Resources/Approvals/Tables/ApprovalsTable.php @@ -2,9 +2,13 @@ namespace App\Filament\Resources\Approvals\Tables; +use App\Models\Approval; +use App\Models\ApprovalItem; +use Filament\Actions\Action; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; +use Filament\Forms\Components\Textarea; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; @@ -15,7 +19,8 @@ class ApprovalsTable { return $table ->columns([ - TextColumn::make('model_type')->label('Tipe'), + TextColumn::make('model_type')->label('Tipe') + ->formatStateUsing(fn ($state) => class_basename($state)), TextColumn::make('model_id')->label('ID'), TextColumn::make('required_approvals')->label('Dibutuhkan'), TextColumn::make('items_count')->counts('items')->label('Sudah Approve'), @@ -25,6 +30,7 @@ class ApprovalsTable 'rejected' => 'danger', default => 'warning', }), + TextColumn::make('created_at')->label('Dibuat')->date('d M Y'), ]) ->filters([ SelectFilter::make('status')->options([ @@ -33,7 +39,69 @@ class ApprovalsTable 'rejected' => 'Ditolak', ]), ]) - ->recordActions([EditAction::make()]) + ->recordActions([ + Action::make('approve') + ->label('Setujui') + ->icon('heroicon-o-check-circle') + ->color('success') + ->requiresConfirmation() + ->visible(fn (Approval $record) => $record->status === 'pending') + ->form([ + Textarea::make('notes')->label('Catatan')->rows(2), + ]) + ->action(function (Approval $record, array $data): void { + ApprovalItem::create([ + 'approval_id' => $record->id, + 'user_id' => auth()->id(), + 'decision' => 'approve', + 'notes' => $data['notes'] ?? null, + ]); + + $approveCount = $record->items()->where('decision', 'approve')->count(); + + if ($approveCount >= $record->required_approvals) { + $record->update(['status' => 'approved']); + } + + \App\Models\ActivityLog::create([ + 'user_id' => auth()->id(), + 'action' => 'approved', + 'model_type' => Approval::class, + 'model_id' => $record->id, + 'description' => auth()->user()->name . " menyetujui " . class_basename($record->model_type) . " #{$record->model_id}", + ]); + }), + + Action::make('reject') + ->label('Tolak') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (Approval $record) => $record->status === 'pending') + ->form([ + Textarea::make('notes')->label('Alasan Penolakan')->required()->rows(2), + ]) + ->action(function (Approval $record, array $data): void { + ApprovalItem::create([ + 'approval_id' => $record->id, + 'user_id' => auth()->id(), + 'decision' => 'reject', + 'notes' => $data['notes'], + ]); + + $record->update(['status' => 'rejected']); + + \App\Models\ActivityLog::create([ + 'user_id' => auth()->id(), + 'action' => 'rejected', + 'model_type' => Approval::class, + 'model_id' => $record->id, + 'description' => auth()->user()->name . " menolak " . class_basename($record->model_type) . " #{$record->model_id}", + ]); + }), + + EditAction::make(), + ]) ->toolbarActions([BulkActionGroup::make([DeleteBulkAction::make()])]); } } diff --git a/app/Filament/Resources/CashRecords/Schemas/CashRecordForm.php b/app/Filament/Resources/CashRecords/Schemas/CashRecordForm.php index d065bc5..184e96c 100644 --- a/app/Filament/Resources/CashRecords/Schemas/CashRecordForm.php +++ b/app/Filament/Resources/CashRecords/Schemas/CashRecordForm.php @@ -4,6 +4,7 @@ namespace App\Filament\Resources\CashRecords\Schemas; use App\Models\CashCategory; use Filament\Forms\Components\DatePicker; +use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; @@ -17,7 +18,9 @@ class CashRecordForm Select::make('category_id')->label('Kategori') ->options(CashCategory::pluck('name', 'id')) ->required(), - TextInput::make('amount')->label('Jumlah (Rp)')->numeric()->required(), + TextInput::make('amount')->label('Jumlah (Rp)')->numeric()->required() + ->helperText('< Rp500.000: langsung | Rp500.000–2.000.000: perlu approval ketua | > Rp2.000.000: perlu voting') + ->live(), Textarea::make('description')->label('Keterangan')->required()->columnSpanFull(), DatePicker::make('date')->label('Tanggal')->required(), ]); diff --git a/app/Observers/CashRecordObserver.php b/app/Observers/CashRecordObserver.php index 540be1a..9c7ad18 100644 --- a/app/Observers/CashRecordObserver.php +++ b/app/Observers/CashRecordObserver.php @@ -3,7 +3,9 @@ namespace App\Observers; use App\Models\ActivityLog; +use App\Models\Approval; use App\Models\CashRecord; +use App\Models\Vote; use Illuminate\Support\Facades\Auth; class CashRecordObserver @@ -17,16 +19,62 @@ class CashRecordObserver 'model_id' => $record->id, 'description' => "Transaksi kas baru: {$record->description} sebesar Rp " . number_format($record->amount, 0, ',', '.'), ]); + + // Threshold: 500rb–2jt → buat approval ketua + if ($record->amount >= 500_000 && $record->amount <= 2_000_000) { + Approval::create([ + 'model_type' => CashRecord::class, + 'model_id' => $record->id, + 'required_approvals' => 1, + 'status' => 'pending', + ]); + } + + // Threshold: > 2jt → buat voting + if ($record->amount > 2_000_000) { + Vote::create([ + 'title' => "Persetujuan Transaksi: {$record->description}", + 'description' => "Transaksi senilai Rp " . number_format($record->amount, 0, ',', '.') . " memerlukan persetujuan voting.", + 'type' => 'finance', + 'related_id' => $record->id, + 'status' => 'open', + 'deadline' => now()->addDays(3), + 'created_by' => Auth::id(), + ]); + } } public function updated(CashRecord $record): void { - // Setelah diverifikasi, tidak bisa diubah lagi if ($record->getOriginal('verified_at') !== null) { throw new \Exception('Transaksi yang sudah diverifikasi tidak dapat diubah.'); } if ($record->wasChanged('verified_by') && $record->verified_by !== null) { + // Pastikan threshold terpenuhi sebelum verifikasi + if ($record->amount >= 500_000 && $record->amount <= 2_000_000) { + $approval = Approval::where('model_type', CashRecord::class) + ->where('model_id', $record->id) + ->first(); + + if (! $approval || $approval->status !== 'approved') { + throw new \Exception('Transaksi ini memerlukan persetujuan ketua sebelum diverifikasi.'); + } + } + + if ($record->amount > 2_000_000) { + $vote = Vote::where('type', 'finance') + ->where('related_id', $record->id) + ->first(); + + $total = $vote?->items()->count() ?? 0; + $approve = $vote?->items()->where('choice', 'approve')->count() ?? 0; + + if (! $vote || $vote->status !== 'closed' || $total === 0 || ($approve / $total) <= 0.5) { + throw new \Exception('Transaksi ini memerlukan voting dengan mayoritas setuju sebelum diverifikasi.'); + } + } + ActivityLog::create([ 'user_id' => Auth::id(), 'action' => 'verified',