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
This commit is contained in:
330
temp_files/ApprovalQueueResource.php
Normal file
330
temp_files/ApprovalQueueResource.php
Normal file
@@ -0,0 +1,330 @@
|
||||
<?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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user