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:
2026-03-04 20:50:42 +08:00
parent fcfae1c1a4
commit 170a2e7c68
13 changed files with 1867 additions and 1399 deletions

View File

@@ -0,0 +1,276 @@
<?php
namespace App\Services;
use App\Models\Appeal;
use App\Models\ApprovalQueue;
use App\Models\DonationCountry;
use App\Models\DonationType;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class AIAppealReviewService
{
/**
* Review an appeal and return a structured verdict.
*/
public function review(Appeal $appeal): array
{
// First, run fast deterministic checks (no AI needed)
$preflightResult = $this->preflightChecks($appeal);
if ($preflightResult !== null) {
return $preflightResult;
}
// Run AI review for content that passes preflight
return $this->aiReview($appeal);
}
/**
* Bulk review all pending approvals.
*/
public function reviewAllPending(): array
{
$pending = ApprovalQueue::where('status', 'pending')
->with('appeal')
->get();
$stats = ['reviewed' => 0, 'approved' => 0, 'rejected' => 0, 'flagged' => 0];
foreach ($pending as $item) {
if (!$item->appeal) {
$item->update(['status' => 'confirmed', 'message' => 'Auto-closed: fundraiser was deleted']);
$stats['reviewed']++;
$stats['rejected']++;
continue;
}
$result = $this->review($item->appeal);
$stats['reviewed']++;
$item->update([
'extra_data' => json_encode([
'ai_review' => $result,
'previous_extra_data' => $item->extra_data,
]),
]);
if ($result['decision'] === 'approve') {
app(ApprovalQueueService::class)->approveAppeal($item);
$stats['approved']++;
} elseif ($result['decision'] === 'reject') {
$item->update([
'status' => 'change_requested',
'message' => 'Auto-flagged: ' . implode('; ', $result['reasons']),
]);
$appeal = $item->appeal;
$appeal->status = 'change_requested';
$appeal->save();
$stats['rejected']++;
} else {
$stats['flagged']++;
}
}
return $stats;
}
/**
* Fast deterministic checks that don't need AI.
*/
protected function preflightChecks(Appeal $appeal): ?array
{
$flags = [];
$reasons = [];
// 1. Check for duplicate names by same user
$duplicateCount = Appeal::where('id', '!=', $appeal->id)
->where('name', $appeal->name)
->where('user_id', $appeal->user_id)
->count();
if ($duplicateCount > 0) {
return [
'decision' => 'reject',
'confidence' => 0.99,
'reasons' => ['Duplicate fundraiser — same person already created an identical page'],
'summary' => 'This is a duplicate of an existing fundraiser by the same person.',
'flags' => ['duplicate'],
];
}
// 2. Check for obvious spam keywords
$spamKeywords = [
'buy adderall', 'buy xanax', 'buy viagra', 'buy oxycodone', 'buy tramadol',
'casino', 'gambling', 'poker online', 'sports betting',
'forex trading', 'crypto trading', 'bitcoin investment',
'weight loss pill', 'diet pill',
'seo service', 'web development service', 'digital marketing agency',
];
$nameLower = strtolower($appeal->name ?? '');
$storyLower = strtolower(strip_tags($appeal->story ?? ''));
foreach ($spamKeywords as $keyword) {
if (str_contains($nameLower, $keyword) || str_contains($storyLower, $keyword)) {
return [
'decision' => 'reject',
'confidence' => 0.99,
'reasons' => ["Contains prohibited content: {$keyword}"],
'summary' => 'This fundraiser contains spam/prohibited content.',
'flags' => ['spam', 'prohibited-content'],
];
}
}
// 3. Check for excessive external links (SEO spam pattern)
$externalLinkCount = preg_match_all(
'/href=["\'](?!https?:\/\/(www\.)?charityright)/i',
$appeal->story ?? '',
$matches
);
if ($externalLinkCount >= 3) {
$flags[] = 'excessive-external-links';
$reasons[] = "Contains {$externalLinkCount} external links (possible SEO spam)";
}
// 4. Check if story is too short
$plainStory = trim(strip_tags($appeal->story ?? ''));
if (strlen($plainStory) < 50) {
$flags[] = 'story-too-short';
$reasons[] = 'Story is very short (' . strlen($plainStory) . ' characters)';
}
// 5. Check if donation type exists
if (!$appeal->donation_type_id || !DonationType::find($appeal->donation_type_id)) {
$flags[] = 'invalid-donation-type';
$reasons[] = 'No valid cause selected';
}
if (count($flags) > 0) {
return [
'decision' => 'review',
'confidence' => 0.7,
'reasons' => $reasons,
'summary' => 'Automatic checks flagged potential issues that need human review.',
'flags' => $flags,
];
}
return null;
}
/**
* AI-powered content review using Claude.
*/
protected function aiReview(Appeal $appeal): array
{
$apiKey = config('services.anthropic.api_key');
if (!$apiKey) {
Log::warning('AIAppealReview: No Anthropic API key configured');
return $this->fallbackResult('No API key configured');
}
$donationTypes = DonationType::pluck('display_name')->join(', ');
$donationCountries = DonationCountry::pluck('name')->join(', ');
$plainStory = strip_tags($appeal->story ?? '');
$appealOwner = $appeal->user?->name ?? 'Unknown';
$appealOwnerEmail = $appeal->user?->email ?? 'Unknown';
$isInMemory = $appeal->is_in_memory ? 'Yes' : 'No';
$prompt = "You are a compliance reviewer for CharityRight, a UK-registered Muslim charity (Charity Commission regulated).\n\n";
$prompt .= "CharityRight's mission: Providing food (school meals, family meals), emergency humanitarian relief, and Islamic religious observance programs (Qurbani, Zakat, Fidya, Kaffarah) across Muslim-majority countries affected by poverty and conflict.\n\n";
$prompt .= "CHARITY'S ACTIVE PROGRAMS: {$donationTypes}\n";
$prompt .= "COUNTRIES OF OPERATION: {$donationCountries}\n\n";
$prompt .= "A member of the public has created a fundraising page on our platform. You must decide if this fundraising page should be approved to go live on our charity's website.\n\n";
$prompt .= "FUNDRAISER DETAILS:\n";
$prompt .= "- Title: {$appeal->name}\n";
$prompt .= "- Description: {$appeal->description}\n";
$prompt .= "- Created by: {$appealOwner} ({$appealOwnerEmail})\n";
$prompt .= "- Cause: " . ($appeal->donationType?->display_name ?? 'None') . "\n";
$prompt .= "- Target amount: £{$appeal->amount_to_raise}\n";
$prompt .= "- In memory of someone: {$isInMemory}\n";
$prompt .= "- Story/content:\n{$plainStory}\n\n";
$prompt .= "REVIEW CRITERIA — Check ALL of these:\n\n";
$prompt .= "1. RELEVANCE: Is this fundraiser related to CharityRight's charitable purposes? (food aid, emergency relief, Islamic programs, humanitarian causes)\n";
$prompt .= "2. LEGITIMACY: Does this look like a genuine fundraising effort, not spam, SEO content, or marketing?\n";
$prompt .= "3. COMPLIANCE: Does the content comply with UK Charity Commission regulations? No political campaigning, no commercial activity, no personal enrichment.\n";
$prompt .= "4. CONTENT QUALITY: Is the story coherent and appropriate for a charity website?\n";
$prompt .= "5. SAFETY: No hate speech, no extremism, no content that could damage the charity's reputation.\n";
$prompt .= "6. LINKS: Are there suspicious external links that suggest SEO spam or affiliate marketing?\n\n";
$prompt .= "RESPOND IN THIS EXACT JSON FORMAT (no markdown, no code blocks, just raw JSON):\n";
$prompt .= "{\"decision\": \"approve\" or \"reject\" or \"review\", \"confidence\": 0.0 to 1.0, \"reasons\": [\"reason 1\"], \"summary\": \"One sentence\", \"flags\": [\"flag1\"]}\n\n";
$prompt .= "Rules:\n";
$prompt .= "- \"approve\" = clearly legitimate fundraiser aligned with charity's mission\n";
$prompt .= "- \"reject\" = clearly spam, prohibited content, or violates charity regulations\n";
$prompt .= "- \"review\" = uncertain, needs human judgment (use this when confidence < 0.7)\n";
$prompt .= "- Be strict about spam but generous with genuine supporters who may have imperfect English\n";
$prompt .= "- A sincere person raising money for food/meals/emergency relief = approve\n";
$prompt .= "- Blog-style content with external links about travel/umrah packages/quran apps = reject (SEO spam)\n";
$prompt .= "- Drug sales, commercial products, gambling = reject immediately\n";
try {
$response = Http::withHeaders([
'x-api-key' => $apiKey,
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
])
->timeout(30)
->post('https://api.anthropic.com/v1/messages', [
'model' => 'claude-sonnet-4-20250514',
'max_tokens' => 500,
'messages' => [
['role' => 'user', 'content' => $prompt],
],
]);
if (!$response->successful()) {
Log::error('AIAppealReview: API error', ['status' => $response->status(), 'body' => $response->body()]);
return $this->fallbackResult('API error: ' . $response->status());
}
$content = $response->json('content.0.text', '');
$content = trim($content);
$content = preg_replace('/^```json?\s*/m', '', $content);
$content = preg_replace('/```\s*$/m', '', $content);
$content = trim($content);
$result = json_decode($content, true);
if (!$result || !isset($result['decision'])) {
Log::warning('AIAppealReview: Could not parse response', ['content' => $content]);
return $this->fallbackResult('Could not parse AI response');
}
if (!in_array($result['decision'], ['approve', 'reject', 'review'])) {
$result['decision'] = 'review';
}
return [
'decision' => $result['decision'],
'confidence' => (float) ($result['confidence'] ?? 0.5),
'reasons' => (array) ($result['reasons'] ?? []),
'summary' => (string) ($result['summary'] ?? ''),
'flags' => (array) ($result['flags'] ?? []),
];
} catch (\Throwable $e) {
Log::error('AIAppealReview: Exception', ['message' => $e->getMessage()]);
return $this->fallbackResult($e->getMessage());
}
}
protected function fallbackResult(string $reason): array
{
return [
'decision' => 'review',
'confidence' => 0.0,
'reasons' => ["AI review failed: {$reason}"],
'summary' => 'Automated review unavailable. Needs manual review.',
'flags' => ['ai-error'],
];
}
}

View 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'),
];
}
}