Files
calvana/temp_files/ApprovalQueueResource.php
Omair Saleh 170a2e7c68 Complete dashboard UI overhaul: persona journeys + brand unification
Navigation: goal-oriented, not feature-oriented
- Overview → Home
- Campaigns → Collect ('I want people to pledge')
- Pledges → Money ('Where's the money?')
- Exports → Reports ('My treasurer needs numbers')
- Old routes still work via re-exports

Terminology: human language, not SaaS jargon
- new → Waiting
- initiated → Said they paid
- paid → Received ✓
- overdue → Needs a nudge
- Campaign → Appeal
- QR Source → Pledge link
- Reconcile → Match payments
- Rail → Payment method
- Pipeline by Status → How pledges are doing
- Conversion rate → % who pledged
- CRM Export Pack → Full data download

Visual identity: brand-consistent dashboard
- Sharp edges (no rounded-lg cards)
- Gap-px grids for stats (brand signature pattern)
- Left-border accents (brand signature pattern)
- Midnight/Paper/Promise Blue 60-30-10 color rule
- Typography as hero (big bold numbers, not card-heavy)
- No emoji in UI chrome
- Brand-consistent status badges (colored bg + text, not shadcn Badge)
- Consistent header typography (text-3xl font-black tracking-tight)

Pages rewritten: layout, home, events (collect), pledges (money),
exports (reports), reconcile, settings

Reconcile: auto-detects bank CSV format via presets + AI before upload

UX spec: docs/UX_OVERHAUL_SPEC.md
2026-03-04 20:50:42 +08:00

331 lines
14 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ApprovalQueueResource\Pages;
use App\Models\ApprovalQueue;
use App\Services\AIAppealReviewService;
use App\Services\ApprovalQueueService;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\HtmlString;
class ApprovalQueueResource extends Resource
{
protected static ?string $model = ApprovalQueue::class;
protected static ?string $navigationIcon = 'heroicon-o-shield-check';
protected static ?string $navigationLabel = 'Fundraiser Review';
protected static ?string $label = 'Fundraiser Review';
protected static ?string $pluralLabel = 'Fundraiser Reviews';
protected static ?string $navigationGroup = 'Campaigns';
protected static ?int $navigationSort = 2;
/** Show pending count as badge in sidebar */
public static function getNavigationBadge(): ?string
{
$count = ApprovalQueue::where('status', 'pending')->count();
return $count > 0 ? (string) $count : null;
}
public static function getNavigationBadgeColor(): ?string
{
$count = ApprovalQueue::where('status', 'pending')->count();
return $count > 10 ? 'danger' : ($count > 0 ? 'warning' : 'success');
}
public static function form(Form $form): Form
{
return $form->schema([]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('status')
->label('Status')
->sortable()
->badge()
->color(fn (string $state) => match ($state) {
'pending' => 'warning',
'confirmed' => 'success',
'change_requested' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (string $state) => match ($state) {
'pending' => 'Needs Review',
'confirmed' => 'Approved',
'change_requested' => 'Changes Needed',
default => ucfirst($state),
}),
Tables\Columns\TextColumn::make('action')
->label('Type')
->badge()
->color(fn (string $state) => match ($state) {
'Create' => 'info',
'Update' => 'gray',
default => 'gray',
})
->formatStateUsing(fn (string $state) => match ($state) {
'Create' => 'New Fundraiser',
'Update' => 'Edit',
default => $state,
}),
Tables\Columns\TextColumn::make('appeal.name')
->label('Fundraiser Name')
->sortable()
->searchable()
->limit(50)
->tooltip(fn (ApprovalQueue $r) => $r->appeal?->name),
Tables\Columns\TextColumn::make('appeal.user.name')
->label('Created By')
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('appeal.donationType.display_name')
->label('Cause')
->badge()
->color('success'),
Tables\Columns\TextColumn::make('appeal.amount_to_raise')
->label('Goal')
->formatStateUsing(fn ($state) => $state ? '£' . number_format($state, 0) : '—')
->sortable(),
Tables\Columns\TextColumn::make('ai_verdict')
->label('AI Review')
->getStateUsing(function (ApprovalQueue $record) {
$extra = json_decode($record->extra_data, true);
$ai = $extra['ai_review'] ?? null;
if (!$ai) return '—';
return $ai['decision'] ?? '—';
})
->badge()
->color(fn ($state) => match ($state) {
'approve' => 'success',
'reject' => 'danger',
'review' => 'warning',
default => 'gray',
})
->formatStateUsing(fn ($state) => match ($state) {
'approve' => '✓ Safe',
'reject' => '✗ Flagged',
'review' => '? Uncertain',
default => '—',
})
->tooltip(function (ApprovalQueue $record) {
$extra = json_decode($record->extra_data, true);
$ai = $extra['ai_review'] ?? null;
if (!$ai) return 'No AI review yet';
return ($ai['summary'] ?? '') . "\n\nConfidence: " . round(($ai['confidence'] ?? 0) * 100) . '%';
}),
Tables\Columns\TextColumn::make('message')
->label('Notes')
->limit(40)
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')
->label('Submitted')
->since()
->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->label('Status')
->options([
'pending' => 'Needs Review',
'confirmed' => 'Approved',
'change_requested' => 'Changes Needed',
])
->default('pending'),
Tables\Filters\SelectFilter::make('action')
->label('Type')
->options([
'Create' => 'New Fundraiser',
'Update' => 'Edit',
]),
])
->actions([
Tables\Actions\Action::make('quick_approve')
->label('Approve')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->modalHeading('Approve this fundraiser?')
->modalDescription(fn (ApprovalQueue $r) => "This will make \"{$r->appeal?->name}\" live on the website.")
->visible(fn (ApprovalQueue $r) => $r->status === 'pending')
->action(function (ApprovalQueue $record) {
app(ApprovalQueueService::class)->approveAppeal($record);
Notification::make()->title('Fundraiser approved')->success()->send();
}),
Tables\Actions\Action::make('quick_reject')
->label('Request Changes')
->icon('heroicon-o-x-circle')
->color('danger')
->visible(fn (ApprovalQueue $r) => $r->status === 'pending')
->form([
\Filament\Forms\Components\Textarea::make('message')
->label('What needs to change?')
->placeholder('Tell the fundraiser what to fix...')
->required()
->rows(3),
])
->action(function (ApprovalQueue $record, array $data) {
$record->update(['message' => $data['message']]);
app(ApprovalQueueService::class)->requestChange($record);
Notification::make()->title('Change request sent')->warning()->send();
}),
Tables\Actions\Action::make('view_fundraiser')
->label('View')
->icon('heroicon-o-eye')
->url(fn (ApprovalQueue $r) => $r->appeal_id
? AppealResource::getUrl('edit', ['record' => $r->appeal_id])
: null)
->openUrlInNewTab(),
])
->headerActions([
Tables\Actions\Action::make('ai_review_all')
->label('AI Review All Pending')
->icon('heroicon-o-sparkles')
->color('info')
->requiresConfirmation()
->modalHeading('Run AI Review on All Pending Fundraisers?')
->modalDescription('This will use AI to review all pending fundraisers. Obvious spam will be auto-rejected, clear fundraisers will be auto-approved, and uncertain ones will be flagged for your review.')
->modalSubmitActionLabel('Start AI Review')
->action(function () {
try {
$service = app(AIAppealReviewService::class);
$stats = $service->reviewAllPending();
Notification::make()
->title('AI Review Complete')
->body(
"Reviewed: {$stats['reviewed']}\n" .
"✓ Auto-approved: {$stats['approved']}\n" .
"✗ Auto-rejected: {$stats['rejected']}\n" .
"? Needs your review: {$stats['flagged']}"
)
->success()
->persistent()
->send();
} catch (\Throwable $e) {
Log::error('AI Review failed', ['error' => $e->getMessage()]);
Notification::make()
->title('AI Review Failed')
->body($e->getMessage())
->danger()
->send();
}
}),
Tables\Actions\Action::make('bulk_approve_safe')
->label('Approve All AI-Safe')
->icon('heroicon-o-check-badge')
->color('success')
->requiresConfirmation()
->modalHeading('Approve all AI-verified fundraisers?')
->modalDescription('This will approve all pending fundraisers that the AI marked as safe. Only high-confidence approvals will be processed.')
->visible(function () {
return ApprovalQueue::where('status', 'pending')
->where('extra_data', 'like', '%"decision":"approve"%')
->exists();
})
->action(function () {
$items = ApprovalQueue::where('status', 'pending')
->where('extra_data', 'like', '%"decision":"approve"%')
->with('appeal')
->get();
$count = 0;
foreach ($items as $item) {
if ($item->appeal) {
app(ApprovalQueueService::class)->approveAppeal($item);
$count++;
}
}
Notification::make()
->title("{$count} fundraisers approved")
->success()
->send();
}),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\BulkAction::make('bulk_approve')
->label('Approve Selected')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->action(function ($records) {
$count = 0;
foreach ($records as $record) {
if ($record->status === 'pending' && $record->appeal) {
app(ApprovalQueueService::class)->approveAppeal($record);
$count++;
}
}
Notification::make()->title("{$count} fundraisers approved")->success()->send();
}),
Tables\Actions\BulkAction::make('bulk_reject')
->label('Reject Selected')
->icon('heroicon-o-x-circle')
->color('danger')
->requiresConfirmation()
->form([
\Filament\Forms\Components\Textarea::make('message')
->label('Rejection reason')
->required(),
])
->action(function ($records, array $data) {
$count = 0;
foreach ($records as $record) {
if ($record->status === 'pending') {
$record->update(['message' => $data['message']]);
app(ApprovalQueueService::class)->requestChange($record);
$count++;
}
}
Notification::make()->title("{$count} fundraisers rejected")->warning()->send();
}),
]),
])
->defaultSort('created_at', 'desc')
->poll('30s');
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\ListApprovalQueues::route('/'),
'edit' => Pages\EditApprovalQueue::route('/{record}/edit'),
];
}
}