diff --git a/app/Filament/Resources/Approvals/ApprovalResource.php b/app/Filament/Resources/Approvals/ApprovalResource.php index aa67369..d4035ae 100644 --- a/app/Filament/Resources/Approvals/ApprovalResource.php +++ b/app/Filament/Resources/Approvals/ApprovalResource.php @@ -5,6 +5,7 @@ namespace App\Filament\Resources\Approvals; use App\Filament\Resources\Approvals\Pages\CreateApproval; use App\Filament\Resources\Approvals\Pages\EditApproval; use App\Filament\Resources\Approvals\Pages\ListApprovals; +use App\Filament\Resources\Approvals\Pages\ViewApproval; use App\Filament\Resources\Approvals\Schemas\ApprovalForm; use App\Filament\Resources\Approvals\Tables\ApprovalsTable; use App\Models\Approval; @@ -17,7 +18,7 @@ class ApprovalResource extends Resource protected static ?string $model = Approval::class; protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-check'; protected static string|\UnitEnum|null $navigationGroup = 'Keputusan'; - protected static ?string $modelLabel = 'Persetujuan'; + protected static bool $shouldRegisterNavigation = false; public static function form(Schema $form): Schema { @@ -34,6 +35,7 @@ class ApprovalResource extends Resource return [ 'index' => ListApprovals::route('/'), 'create' => CreateApproval::route('/create'), + 'view' => ViewApproval::route('/{record}'), 'edit' => EditApproval::route('/{record}/edit'), ]; } diff --git a/app/Filament/Resources/Approvals/Pages/ViewApproval.php b/app/Filament/Resources/Approvals/Pages/ViewApproval.php new file mode 100644 index 0000000..b8b3a35 --- /dev/null +++ b/app/Filament/Resources/Approvals/Pages/ViewApproval.php @@ -0,0 +1,106 @@ +label('Setujui') + ->icon('heroicon-o-check-circle') + ->color('success') + ->requiresConfirmation() + ->visible(fn () => $this->record->status === 'pending' + && ! $this->record->items()->where('user_id', auth()->id())->exists()) + ->form([Textarea::make('notes')->label('Catatan')->rows(2)]) + ->action(function (array $data): void { + ApprovalItem::create([ + 'approval_id' => $this->record->id, + 'user_id' => auth()->id(), + 'decision' => 'approve', + 'notes' => $data['notes'] ?? null, + ]); + $count = $this->record->items()->where('decision', 'approve')->count(); + if ($count >= $this->record->required_approvals) { + $this->record->update(['status' => 'approved']); + } + $this->refreshFormData([]); + }), + + Action::make('reject') + ->label('Tolak') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->visible(fn () => $this->record->status === 'pending' + && ! $this->record->items()->where('user_id', auth()->id())->exists()) + ->form([Textarea::make('notes')->label('Alasan Penolakan')->required()->rows(2)]) + ->action(function (array $data): void { + ApprovalItem::create([ + 'approval_id' => $this->record->id, + 'user_id' => auth()->id(), + 'decision' => 'reject', + 'notes' => $data['notes'], + ]); + $this->record->update(['status' => 'rejected']); + $this->refreshFormData([]); + }), + ]; + } + + public function infolist(Schema $infolist): Schema + { + $record = $this->record; + $subject = $record->approvable; + $label = class_basename($record->model_type); + + return $infolist->schema([ + Section::make('Yang Dimintakan Persetujuan')->schema([ + TextEntry::make('model_type')->label('Tipe') + ->state($label), + TextEntry::make('subject_title')->label('Judul / Deskripsi') + ->state(match (true) { + $subject instanceof \App\Models\CashRecord => + "{$subject->description} — Rp " . number_format($subject->amount, 0, ',', '.'), + $subject instanceof \App\Models\Activity => + $subject->title, + default => "#{$record->model_id}", + }), + TextEntry::make('status')->badge() + ->color(fn ($state) => match ($state) { + 'approved' => 'success', + 'rejected' => 'danger', + default => 'warning', + }), + TextEntry::make('required_approvals')->label('Persetujuan Dibutuhkan'), + ])->columns(2), + + Section::make('Riwayat Keputusan')->schema([ + TextEntry::make('decisions') + ->label('') + ->state(function () use ($record): string { + if ($record->items->isEmpty()) return 'Belum ada keputusan.'; + return $record->items->map(fn ($item) => + "• {$item->user->name}: " . ($item->decision === 'approve' ? '✓ Setuju' : '✗ Tolak') . + ($item->notes ? " — {$item->notes}" : '') + )->join("\n"); + }) + ->columnSpanFull(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/Approvals/Tables/ApprovalsTable.php b/app/Filament/Resources/Approvals/Tables/ApprovalsTable.php index 2e8c55a..eba4645 100644 --- a/app/Filament/Resources/Approvals/Tables/ApprovalsTable.php +++ b/app/Filament/Resources/Approvals/Tables/ApprovalsTable.php @@ -7,7 +7,7 @@ use App\Models\ApprovalItem; use Filament\Actions\Action; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; -use Filament\Actions\EditAction; +use Filament\Actions\ViewAction; use Filament\Forms\Components\Textarea; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; @@ -20,35 +20,61 @@ class ApprovalsTable return $table ->columns([ 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'), + ->formatStateUsing(fn ($state) => match ($state) { + 'App\\Models\\CashRecord' => '💰 Transaksi Kas', + 'App\\Models\\Activity' => '📅 Kegiatan', + default => class_basename($state), + }), + TextColumn::make('subject') + ->label('Deskripsi') + ->state(function (Approval $record): string { + $subject = $record->approvable; + return match (true) { + $subject instanceof \App\Models\CashRecord => + $subject->description . ' — Rp ' . number_format($subject->amount, 0, ',', '.'), + $subject instanceof \App\Models\Activity => + $subject->title, + default => "#{$record->model_id}", + }; + }) + ->limit(50), + TextColumn::make('progress') + ->label('Progress') + ->state(fn (Approval $record) => + $record->items()->where('decision', 'approve')->count() + . ' / ' . $record->required_approvals . ' persetujuan') + ->badge()->color('info'), TextColumn::make('status')->badge() ->color(fn ($state) => match ($state) { 'approved' => 'success', 'rejected' => 'danger', default => 'warning', + }) + ->formatStateUsing(fn ($state) => match ($state) { + 'approved' => 'Disetujui', + 'rejected' => 'Ditolak', + default => 'Menunggu', }), - TextColumn::make('created_at')->label('Dibuat')->date('d M Y'), + TextColumn::make('created_at')->label('Dibuat')->date('d M Y')->sortable(), ]) + ->defaultSort('created_at', 'desc') ->filters([ SelectFilter::make('status')->options([ - 'pending' => 'Pending', + 'pending' => 'Menunggu', 'approved' => 'Disetujui', 'rejected' => 'Ditolak', ]), ]) ->recordActions([ + ViewAction::make()->label('Detail'), 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), - ]) + ->visible(fn (Approval $record) => $record->status === 'pending' + && ! $record->items()->where('user_id', auth()->id())->exists()) + ->form([Textarea::make('notes')->label('Catatan')->rows(2)]) ->action(function (Approval $record, array $data): void { ApprovalItem::create([ 'approval_id' => $record->id, @@ -56,31 +82,25 @@ class ApprovalsTable 'decision' => 'approve', 'notes' => $data['notes'] ?? null, ]); - - $approveCount = $record->items()->where('decision', 'approve')->count(); - - if ($approveCount >= $record->required_approvals) { + $count = $record->items()->where('decision', 'approve')->count(); + if ($count >= $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}", + 'description' => auth()->user()->name . " menyetujui persetujuan #{$record->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), - ]) + ->visible(fn (Approval $record) => $record->status === 'pending' + && ! $record->items()->where('user_id', auth()->id())->exists()) + ->form([Textarea::make('notes')->label('Alasan Penolakan')->required()->rows(2)]) ->action(function (Approval $record, array $data): void { ApprovalItem::create([ 'approval_id' => $record->id, @@ -88,19 +108,15 @@ class ApprovalsTable '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}", + 'description' => auth()->user()->name . " menolak persetujuan #{$record->id}", ]); }), - - EditAction::make(), ]) ->toolbarActions([BulkActionGroup::make([DeleteBulkAction::make()])]); } diff --git a/app/Filament/Resources/CashRecords/Tables/CashRecordsTable.php b/app/Filament/Resources/CashRecords/Tables/CashRecordsTable.php index 10176a0..27872c5 100644 --- a/app/Filament/Resources/CashRecords/Tables/CashRecordsTable.php +++ b/app/Filament/Resources/CashRecords/Tables/CashRecordsTable.php @@ -2,11 +2,15 @@ namespace App\Filament\Resources\CashRecords\Tables; +use App\Models\Approval; +use App\Models\ApprovalItem; use App\Models\CashCategory; +use App\Models\CashRecord; 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; @@ -22,27 +26,121 @@ class CashRecordsTable TextColumn::make('amount')->label('Jumlah')->money('IDR')->sortable(), TextColumn::make('description')->label('Keterangan')->limit(40), TextColumn::make('creator.name')->label('Dibuat Oleh'), + + // Status approval untuk transaksi 500rb–2jt + TextColumn::make('approval_status') + ->label('Persetujuan Ketua') + ->state(function (CashRecord $record): string { + if ($record->amount < 500_000 || $record->amount > 2_000_000) { + return '-'; + } + $approval = Approval::where('model_type', CashRecord::class) + ->where('model_id', $record->id)->first(); + return match ($approval?->status) { + 'approved' => 'Disetujui', + 'rejected' => 'Ditolak', + 'pending' => 'Menunggu', + default => '-', + }; + }) + ->badge() + ->color(fn ($state) => match ($state) { + 'Disetujui' => 'success', + 'Ditolak' => 'danger', + 'Menunggu' => 'warning', + default => 'gray', + }), + TextColumn::make('verifier.name')->label('Diverifikasi')->default('-'), TextColumn::make('verified_at')->label('Tgl Verifikasi')->dateTime('d M Y')->default('-'), ]) ->filters([ SelectFilter::make('category_id')->label('Kategori') - ->options(\App\Models\CashCategory::pluck('name', 'id')), + ->options(CashCategory::pluck('name', 'id')), ]) ->recordActions([ - EditAction::make()->hidden(fn ($record) => $record->verified_at !== null), - Action::make('verify') - ->label('Verifikasi') + // Ketua: approve transaksi 500rb–2jt + Action::make('approve_ketua') + ->label('Setujui') ->icon('heroicon-o-check-circle') ->color('success') ->requiresConfirmation() - ->hidden(fn ($record) => $record->verified_at !== null) - ->action(function ($record) { - $record->update([ - 'verified_by' => auth()->id(), - 'verified_at' => now(), + ->visible(function (CashRecord $record): bool { + if (! auth()->user()->hasAnyRole(['ketua', 'super_admin'])) return false; + if ($record->amount < 500_000 || $record->amount > 2_000_000) return false; + $approval = Approval::where('model_type', CashRecord::class) + ->where('model_id', $record->id)->first(); + return $approval && $approval->status === 'pending'; + }) + ->form([Textarea::make('notes')->label('Catatan')->rows(2)]) + ->action(function (CashRecord $record, array $data): void { + $approval = Approval::where('model_type', CashRecord::class) + ->where('model_id', $record->id)->firstOrFail(); + ApprovalItem::create([ + 'approval_id' => $approval->id, + 'user_id' => auth()->id(), + 'decision' => 'approve', + 'notes' => $data['notes'] ?? null, ]); + $approval->update(['status' => 'approved']); }), + + // Ketua: tolak transaksi 500rb–2jt + Action::make('reject_ketua') + ->label('Tolak') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->visible(function (CashRecord $record): bool { + if (! auth()->user()->hasAnyRole(['ketua', 'super_admin'])) return false; + if ($record->amount < 500_000 || $record->amount > 2_000_000) return false; + $approval = Approval::where('model_type', CashRecord::class) + ->where('model_id', $record->id)->first(); + return $approval && $approval->status === 'pending'; + }) + ->form([Textarea::make('notes')->label('Alasan Penolakan')->required()->rows(2)]) + ->action(function (CashRecord $record, array $data): void { + $approval = Approval::where('model_type', CashRecord::class) + ->where('model_id', $record->id)->firstOrFail(); + ApprovalItem::create([ + 'approval_id' => $approval->id, + 'user_id' => auth()->id(), + 'decision' => 'reject', + 'notes' => $data['notes'], + ]); + $approval->update(['status' => 'rejected']); + }), + + // Bendahara/ketua: verifikasi (hanya jika approval sudah selesai atau tidak diperlukan) + Action::make('verify') + ->label('Verifikasi') + ->icon('heroicon-o-shield-check') + ->color('info') + ->requiresConfirmation() + ->hidden(fn (CashRecord $record) => $record->verified_at !== null) + ->visible(function (CashRecord $record): bool { + if (! auth()->user()->hasAnyRole(['ketua', 'super_admin', 'bendahara'])) return false; + if ($record->verified_at) return false; + // Cek threshold + if ($record->amount >= 500_000 && $record->amount <= 2_000_000) { + $approval = Approval::where('model_type', CashRecord::class) + ->where('model_id', $record->id)->first(); + return $approval && $approval->status === 'approved'; + } + if ($record->amount > 2_000_000) { + $vote = \App\Models\Vote::where('type', 'finance') + ->where('related_id', $record->id)->first(); + $total = $vote?->items()->count() ?? 0; + $approve = $vote?->items()->where('choice', 'approve')->count() ?? 0; + return $vote && $vote->status === 'closed' && $total > 0 && ($approve / $total) > 0.5; + } + return true; // < 500rb langsung bisa + }) + ->action(fn (CashRecord $record) => $record->update([ + 'verified_by' => auth()->id(), + 'verified_at' => now(), + ])), + + EditAction::make()->hidden(fn ($record) => $record->verified_at !== null), ]) ->toolbarActions([BulkActionGroup::make([DeleteBulkAction::make()])]); } diff --git a/app/Models/Approval.php b/app/Models/Approval.php index c2fbdf7..b9088a1 100644 --- a/app/Models/Approval.php +++ b/app/Models/Approval.php @@ -9,6 +9,8 @@ class Approval extends Model { protected $fillable = ['model_type', 'model_id', 'required_approvals', 'status']; + protected $with = ['items.user']; + public function items(): HasMany { return $this->hasMany(ApprovalItem::class);