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:
276
temp_files/AIAppealReviewService.php
Normal file
276
temp_files/AIAppealReviewService.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
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