Landing page philosophy across ALL dashboard pages

Home:
- Empty state: 2-column with 'How it works' 5-step guide
- Has data: 7/5 grid — pledges left, education right
- Right column: status breakdown, sources, 'What to do next' contextual links, 'What the statuses mean' guide

Money:
- 8/4 two-column layout: table left, education right
- Right column: 'How matching works' 4-step guide, status explainer, collection tips, quick action buttons
- No more wasted right margin

Reports:
- 7/5 two-column layout: downloads left, education right
- Right column: 'For your treasurer' 3-step guide, Gift Aid FAQ, 'Understanding your numbers' explainer
- Activity log moved to right column

Settings:
- Removed max-w-2xl constraint, now uses full width
- 7/5 two-column: checklist left, education right
- Right column: 'What you're setting up' (5 items with Required badges), Privacy & data assurance, Common questions FAQ, 'Need help?' CTA
- Every setting explained in human language
This commit is contained in:
2026-03-05 03:35:08 +08:00
parent 8366054bd7
commit e852250ce0
9 changed files with 1402 additions and 278 deletions

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Filament\Resources\ScheduledGivingDonationResource\Pages;
use App\Filament\Resources\ScheduledGivingDonationResource;
use App\Models\ScheduledGivingCampaign;
use App\Models\ScheduledGivingDonation;
use Filament\Resources\Components\Tab;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
class ListScheduledGivingDonations extends ListRecords
{
protected static string $resource = ScheduledGivingDonationResource::class;
public function getHeading(): string
{
return 'Scheduled Giving Subscribers';
}
public function getSubheading(): string
{
$active = ScheduledGivingDonation::where('is_active', true)->count();
return "{$active} active subscribers across all campaigns.";
}
public function getTabs(): array
{
$campaigns = ScheduledGivingCampaign::withCount([
'donations as active_count' => fn ($q) => $q->where('is_active', true),
])->get();
$tabs = [];
// All active tab first
$totalActive = ScheduledGivingDonation::where('is_active', true)->count();
$tabs['active'] = Tab::make('All Active')
->icon('heroicon-o-check-circle')
->badge($totalActive)
->badgeColor('success')
->modifyQueryUsing(fn (Builder $q) => $q->where('is_active', true));
// One tab per campaign
foreach ($campaigns as $c) {
$slug = str($c->title)->slug()->toString();
$tabs[$slug] = Tab::make($c->title)
->icon('heroicon-o-calendar')
->badge($c->active_count > 0 ? $c->active_count : null)
->badgeColor($c->active ? 'primary' : 'gray')
->modifyQueryUsing(fn (Builder $q) => $q
->where('scheduled_giving_campaign_id', $c->id)
->where('is_active', true));
}
// Failed payments tab
$failedCount = ScheduledGivingDonation::where('is_active', true)
->whereHas('payments', fn ($q) => $q
->where('is_paid', false)
->where('attempts', '>', 0))
->count();
$tabs['failed'] = Tab::make('Failed Payments')
->icon('heroicon-o-exclamation-triangle')
->badge($failedCount > 0 ? $failedCount : null)
->badgeColor('danger')
->modifyQueryUsing(fn (Builder $q) => $q
->where('is_active', true)
->whereHas('payments', fn ($sub) => $sub
->where('is_paid', false)
->where('attempts', '>', 0)));
// Cancelled
$cancelled = ScheduledGivingDonation::where('is_active', false)->count();
$tabs['cancelled'] = Tab::make('Cancelled')
->icon('heroicon-o-x-circle')
->badge($cancelled > 0 ? $cancelled : null)
->badgeColor('gray')
->modifyQueryUsing(fn (Builder $q) => $q->where('is_active', false));
// Everything
$tabs['all'] = Tab::make('Everything')
->icon('heroicon-o-squares-2x2');
return $tabs;
}
protected function getHeaderActions(): array
{
return [];
}
public function getDefaultActiveTab(): string | int | null
{
return 'active';
}
}

View File

@@ -0,0 +1,204 @@
<?php
namespace App\Filament\Pages;
use App\Models\ScheduledGivingCampaign;
use App\Models\ScheduledGivingDonation;
use App\Models\ScheduledGivingPayment;
use Filament\Pages\Page;
use Illuminate\Support\Facades\DB;
/**
* Scheduled Giving Command Centre.
*
* Three campaigns, three seasons, one dashboard.
* Every number a supporter-care agent needs is on this page.
*/
class ScheduledGivingDashboard extends Page
{
protected static ?string $navigationIcon = 'heroicon-o-calendar-days';
protected static ?string $navigationGroup = 'Seasonal Campaigns';
protected static ?int $navigationSort = 0;
protected static ?string $navigationLabel = 'Campaign Dashboard';
protected static ?string $title = 'Scheduled Giving';
protected static string $view = 'filament.pages.scheduled-giving-dashboard';
/**
* Aggregate stats for every campaign, plus global totals.
* Runs 3 efficient queries (not N+1).
*/
public function getCampaignData(): array
{
$campaigns = ScheduledGivingCampaign::withCount([
'donations',
'donations as active_count' => fn ($q) => $q->where('is_active', true),
'donations as cancelled_count' => fn ($q) => $q->where('is_active', false),
])->get();
$result = [];
foreach ($campaigns as $c) {
$donationIds = $c->donations()->pluck('id');
if ($donationIds->isEmpty()) {
$result[] = [
'campaign' => $c,
'subscribers' => 0,
'active' => 0,
'cancelled' => 0,
'total_payments' => 0,
'paid_payments' => 0,
'pending_payments' => 0,
'failed_payments' => 0,
'collected' => 0,
'pending_amount' => 0,
'avg_per_night' => 0,
'completion_rate' => 0,
'dates' => $c->dates ?? [],
'next_payment' => null,
'last_payment' => null,
];
continue;
}
$payments = DB::table('scheduled_giving_payments')
->whereIn('scheduled_giving_donation_id', $donationIds)
->whereNull('deleted_at')
->selectRaw("
COUNT(*) as total,
SUM(CASE WHEN is_paid = 1 THEN 1 ELSE 0 END) as paid,
SUM(CASE WHEN is_paid = 0 THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN is_paid = 0 AND attempts > 0 THEN 1 ELSE 0 END) as failed,
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) as collected,
SUM(CASE WHEN is_paid = 0 THEN amount ELSE 0 END) as pending_amount,
AVG(CASE WHEN is_paid = 1 THEN amount ELSE NULL END) as avg_amount,
MIN(CASE WHEN is_paid = 0 AND expected_at > NOW() THEN expected_at ELSE NULL END) as next_payment,
MAX(CASE WHEN is_paid = 1 THEN expected_at ELSE NULL END) as last_payment
")
->first();
// Completion: subscribers who paid ALL their payments
$totalNights = count($c->dates ?? []);
$fullyPaid = 0;
if ($totalNights > 0 && $donationIds->isNotEmpty()) {
$ids = $donationIds->implode(',');
$row = DB::selectOne("SELECT COUNT(*) as cnt FROM (SELECT scheduled_giving_donation_id FROM scheduled_giving_payments WHERE scheduled_giving_donation_id IN ({$ids}) AND deleted_at IS NULL GROUP BY scheduled_giving_donation_id HAVING SUM(is_paid) >= {$totalNights}) sub");
$fullyPaid = $row->cnt ?? 0;
}
$result[] = [
'campaign' => $c,
'subscribers' => $c->donations_count,
'active' => $c->active_count,
'cancelled' => $c->cancelled_count,
'total_payments' => (int) $payments->total,
'paid_payments' => (int) $payments->paid,
'pending_payments' => (int) $payments->pending,
'failed_payments' => (int) $payments->failed,
'collected' => ($payments->collected ?? 0) / 100,
'pending_amount' => ($payments->pending_amount ?? 0) / 100,
'avg_per_night' => ($payments->avg_amount ?? 0) / 100,
'completion_rate' => $c->active_count > 0
? round($fullyPaid / $c->active_count * 100, 1)
: 0,
'fully_completed' => $fullyPaid,
'dates' => $c->dates ?? [],
'total_nights' => $totalNights,
'next_payment' => $payments->next_payment,
'last_payment' => $payments->last_payment,
];
}
return $result;
}
/**
* Global totals across all campaigns.
*/
public function getGlobalStats(): array
{
$row = DB::table('scheduled_giving_payments')
->whereNull('deleted_at')
->selectRaw("
COUNT(*) as total,
SUM(CASE WHEN is_paid = 1 THEN 1 ELSE 0 END) as paid,
SUM(CASE WHEN is_paid = 0 AND attempts > 0 THEN 1 ELSE 0 END) as failed,
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) / 100 as collected,
SUM(CASE WHEN is_paid = 0 THEN amount ELSE 0 END) / 100 as pending
")
->first();
$totalSubs = ScheduledGivingDonation::count();
$activeSubs = ScheduledGivingDonation::where('is_active', true)->count();
return [
'total_subscribers' => $totalSubs,
'active_subscribers' => $activeSubs,
'total_payments' => (int) ($row->total ?? 0),
'paid_payments' => (int) ($row->paid ?? 0),
'failed_payments' => (int) ($row->failed ?? 0),
'collected' => (float) ($row->collected ?? 0),
'pending' => (float) ($row->pending ?? 0),
'collection_rate' => $row->total > 0
? round($row->paid / $row->total * 100, 1)
: 0,
];
}
/**
* Recent failed payments needing attention.
*/
public function getFailedPayments(): array
{
return DB::table('scheduled_giving_payments as p')
->join('scheduled_giving_donations as d', 'd.id', '=', 'p.scheduled_giving_donation_id')
->join('scheduled_giving_campaigns as c', 'c.id', '=', 'd.scheduled_giving_campaign_id')
->leftJoin('customers as cu', 'cu.id', '=', 'd.customer_id')
->where('p.is_paid', false)
->where('p.attempts', '>', 0)
->whereNull('p.deleted_at')
->orderByDesc('p.updated_at')
->limit(15)
->get([
'p.id as payment_id',
'p.amount',
'p.expected_at',
'p.attempts',
'd.id as donation_id',
'c.title as campaign',
DB::raw("CONCAT(cu.first_name, ' ', cu.last_name) as donor_name"),
'cu.email as donor_email',
])
->toArray();
}
/**
* Upcoming payments in the next 48 hours.
*/
public function getUpcomingPayments(): array
{
return DB::table('scheduled_giving_payments as p')
->join('scheduled_giving_donations as d', 'd.id', '=', 'p.scheduled_giving_donation_id')
->join('scheduled_giving_campaigns as c', 'c.id', '=', 'd.scheduled_giving_campaign_id')
->where('p.is_paid', false)
->where('p.attempts', 0)
->where('d.is_active', true)
->whereNull('p.deleted_at')
->whereBetween('p.expected_at', [now(), now()->addHours(48)])
->selectRaw("
c.title as campaign,
COUNT(*) as payment_count,
SUM(p.amount) / 100 as total_amount,
MIN(p.expected_at) as earliest
")
->groupBy('c.title')
->orderBy('earliest')
->get()
->toArray();
}
}

184
temp_files/sg/nav_update.py Normal file
View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""
Navigation overhaul:
- Daily: Donors, Donations (with recurring tab)
- Seasonal Campaigns: Campaign Dashboard, Subscribers, Giving Campaigns config
- Fundraising: Review Queue, All Fundraisers
- Setup: unchanged
"""
import os
BASE = '/home/forge/app.charityright.org.uk'
# ── 1. ScheduledGivingDonationResource → Seasonal Campaigns group ──
path = os.path.join(BASE, 'app/Filament/Resources/ScheduledGivingDonationResource.php')
with open(path, 'r') as f:
c = f.read()
# Change nav group
c = c.replace(
"protected static ?string $navigationGroup = 'Daily';",
"protected static ?string $navigationGroup = 'Seasonal Campaigns';"
)
# Change label
c = c.replace(
"protected static ?string $navigationLabel = 'Regular Giving';",
"protected static ?string $navigationLabel = 'Subscribers';"
)
# Change sort
c = c.replace(
"protected static ?int $navigationSort = 3;",
"protected static ?int $navigationSort = 1;"
)
# Change model label
c = c.replace(
"protected static ?string $modelLabel = 'Regular Giving';",
"protected static ?string $modelLabel = 'Scheduled Giving';"
)
c = c.replace(
"protected static ?string $pluralModelLabel = 'Regular Giving';",
"protected static ?string $pluralModelLabel = 'Scheduled Giving';"
)
# Change icon to calendar
c = c.replace(
"protected static ?string $navigationIcon = 'heroicon-o-arrow-path';",
"protected static ?string $navigationIcon = 'heroicon-o-user-group';"
)
with open(path, 'w') as f:
f.write(c)
print('Updated ScheduledGivingDonationResource nav')
# ── 2. ScheduledGivingCampaignResource → Seasonal Campaigns group ──
path = os.path.join(BASE, 'app/Filament/Resources/ScheduledGivingCampaignResource.php')
with open(path, 'r') as f:
c = f.read()
c = c.replace(
"protected static ?string $navigationGroup = 'Fundraising';",
"protected static ?string $navigationGroup = 'Seasonal Campaigns';"
)
c = c.replace(
"protected static ?string $navigationLabel = 'Giving Campaigns';",
"protected static ?string $navigationLabel = 'Campaign Config';"
)
# Add sort if missing
if 'navigationSort' not in c:
c = c.replace(
"protected static ?string $navigationLabel = 'Campaign Config';",
"protected static ?string $navigationLabel = 'Campaign Config';\n\n protected static ?int $navigationSort = 2;"
)
with open(path, 'w') as f:
f.write(c)
print('Updated ScheduledGivingCampaignResource nav')
# ── 3. AdminPanelProvider → Add Seasonal Campaigns group ──
path = os.path.join(BASE, 'app/Providers/Filament/AdminPanelProvider.php')
with open(path, 'r') as f:
c = f.read()
old_groups = """ ->navigationGroups([
// ── Daily Work (always visible, top of sidebar) ──
NavigationGroup::make('Daily')
->collapsible(false),
// ── Fundraising (campaigns, review queue) ──
NavigationGroup::make('Fundraising')
->icon('heroicon-o-megaphone')
->collapsible(),
// ── Setup (rarely touched config) ──
NavigationGroup::make('Setup')
->icon('heroicon-o-cog-6-tooth')
->collapsible()
->collapsed(),
])"""
new_groups = """ ->navigationGroups([
// ── Daily Work (always visible, top of sidebar) ──
NavigationGroup::make('Daily')
->collapsible(false),
// ── Seasonal Campaigns (30 Nights, 10 Days, Night of Power) ──
NavigationGroup::make('Seasonal Campaigns')
->icon('heroicon-o-calendar-days')
->collapsible(),
// ── Fundraising (appeals, review queue) ──
NavigationGroup::make('Fundraising')
->icon('heroicon-o-megaphone')
->collapsible(),
// ── Setup (rarely touched config) ──
NavigationGroup::make('Setup')
->icon('heroicon-o-cog-6-tooth')
->collapsible()
->collapsed(),
])"""
c = c.replace(old_groups, new_groups)
with open(path, 'w') as f:
f.write(c)
print('Updated AdminPanelProvider nav groups')
# ── 4. Update ListScheduledGivingDonations labels ──
path = os.path.join(BASE, 'app/Filament/Resources/ScheduledGivingDonationResource/Pages/ListScheduledGivingDonations.php')
with open(path, 'r') as f:
c = f.read()
c = c.replace(
"return 'Regular Giving';",
"return 'Scheduled Giving Subscribers';"
)
c = c.replace(
'return "{$active} people giving every month.";',
'return "{$active} active subscribers across all campaigns.";'
)
with open(path, 'w') as f:
f.write(c)
print('Updated ListScheduledGivingDonations labels')
# ── 5. Add "Recurring" tab to Donations list ──
# Monthly/weekly donors should be visible under Donations
path = os.path.join(BASE, 'app/Filament/Resources/DonationResource/Pages/ListDonations.php')
with open(path, 'r') as f:
c = f.read()
# Check if 'recurring' tab already exists
if "'recurring'" not in c:
# Find the 'everything' tab and add 'recurring' before it
old_everything = """ 'everything' => Tab::make('Everything')"""
new_recurring = """ 'recurring' => Tab::make('Recurring')
->icon('heroicon-o-arrow-path')
->badge($recurring > 0 ? $recurring : null)
->badgeColor('info')
->modifyQueryUsing(fn (Builder $q) => $q->where('reoccurrence', '!=', -1)
->whereHas('donationConfirmation', fn ($sub) => $sub->whereNotNull('confirmed_at'))),
'everything' => Tab::make('Everything')"""
c = c.replace(old_everything, new_recurring)
# Add $recurring count variable
if '$recurring' not in c:
c = c.replace(
"return [",
"$recurring = Donation::where('reoccurrence', '!=', -1)\n ->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))\n ->count();\n\n return [",
1 # only first occurrence in getTabs
)
with open(path, 'w') as f:
f.write(c)
print('Added Recurring tab to ListDonations')
else:
print('ListDonations already has recurring tab')
print('Navigation update complete!')

View File

@@ -0,0 +1,199 @@
<x-filament-panels::page>
@php
$global = $this->getGlobalStats();
$campaigns = $this->getCampaignData();
$failed = $this->getFailedPayments();
$upcoming = $this->getUpcomingPayments();
@endphp
{{-- ── Global Overview ──────────────────────────────────────── --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<x-filament::section>
<div class="text-center">
<div class="text-3xl font-bold text-primary-600">{{ number_format($global['active_subscribers']) }}</div>
<div class="text-sm text-gray-500 mt-1">Active Subscribers</div>
</div>
</x-filament::section>
<x-filament::section>
<div class="text-center">
<div class="text-3xl font-bold text-success-600">£{{ number_format($global['collected'], 2) }}</div>
<div class="text-sm text-gray-500 mt-1">Total Collected</div>
</div>
</x-filament::section>
<x-filament::section>
<div class="text-center">
<div class="text-3xl font-bold text-warning-600">£{{ number_format($global['pending'], 2) }}</div>
<div class="text-sm text-gray-500 mt-1">Pending Collection</div>
</div>
</x-filament::section>
<x-filament::section>
<div class="text-center">
<div class="text-3xl font-bold {{ $global['collection_rate'] >= 80 ? 'text-success-600' : ($global['collection_rate'] >= 60 ? 'text-warning-600' : 'text-danger-600') }}">
{{ $global['collection_rate'] }}%
</div>
<div class="text-sm text-gray-500 mt-1">Collection Rate</div>
</div>
</x-filament::section>
</div>
{{-- ── Campaign Cards ──────────────────────────────────────── --}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
@foreach ($campaigns as $data)
@php
$c = $data['campaign'];
$isActive = $c->active;
$progressPct = $data['total_payments'] > 0
? round($data['paid_payments'] / $data['total_payments'] * 100)
: 0;
@endphp
<x-filament::section>
<x-slot name="heading">
<div class="flex items-center justify-between">
<span>{{ $c->title }}</span>
@if ($isActive)
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"> Live</span>
@else
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600"> Ended</span>
@endif
</div>
</x-slot>
{{-- Stats grid --}}
<div class="grid grid-cols-2 gap-3 mb-4">
<div>
<div class="text-xs text-gray-500 uppercase tracking-wide">Subscribers</div>
<div class="text-lg font-semibold">{{ number_format($data['active']) }} <span class="text-xs text-gray-400 font-normal">/ {{ number_format($data['subscribers']) }}</span></div>
</div>
<div>
<div class="text-xs text-gray-500 uppercase tracking-wide">Avg / Night</div>
<div class="text-lg font-semibold">£{{ number_format($data['avg_per_night'], 2) }}</div>
</div>
<div>
<div class="text-xs text-gray-500 uppercase tracking-wide">Collected</div>
<div class="text-lg font-semibold text-success-600">£{{ number_format($data['collected'], 2) }}</div>
</div>
<div>
<div class="text-xs text-gray-500 uppercase tracking-wide">Pending</div>
<div class="text-lg font-semibold text-warning-600">£{{ number_format($data['pending_amount'], 2) }}</div>
</div>
</div>
{{-- Payment progress bar --}}
<div class="mb-3">
<div class="flex justify-between text-xs text-gray-500 mb-1">
<span>{{ number_format($data['paid_payments']) }} / {{ number_format($data['total_payments']) }} payments</span>
<span>{{ $progressPct }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div class="h-2.5 rounded-full {{ $progressPct >= 80 ? 'bg-success-500' : ($progressPct >= 50 ? 'bg-warning-500' : 'bg-primary-500') }}"
style="width: {{ $progressPct }}%"></div>
</div>
</div>
{{-- Key metrics --}}
<div class="grid grid-cols-3 gap-2 text-center border-t pt-3">
<div>
<div class="text-xs text-gray-500">Nights</div>
<div class="font-semibold">{{ $data['total_nights'] }}</div>
</div>
<div>
<div class="text-xs text-gray-500">Completed</div>
<div class="font-semibold text-success-600">{{ $data['fully_completed'] ?? 0 }}</div>
</div>
<div>
<div class="text-xs text-gray-500">Failed</div>
<div class="font-semibold {{ $data['failed_payments'] > 0 ? 'text-danger-600' : 'text-gray-400' }}">{{ $data['failed_payments'] }}</div>
</div>
</div>
{{-- Quick links --}}
<div class="flex gap-2 mt-3 pt-3 border-t">
<a href="{{ url('/admin/scheduled-giving-donations?tableFilters[scheduled_giving_campaign_id][value]=' . $c->id) }}"
class="text-xs text-primary-600 hover:underline">View Subscribers </a>
<a href="{{ url('/admin/scheduled-giving-campaigns/' . $c->id . '/edit') }}"
class="text-xs text-gray-500 hover:underline ml-auto">Edit Campaign</a>
</div>
</x-filament::section>
@endforeach
</div>
{{-- ── Upcoming Payments ───────────────────────────────────── --}}
@if (count($upcoming) > 0)
<x-filament::section>
<x-slot name="heading">
<div class="flex items-center gap-2">
<x-heroicon-o-clock class="w-5 h-5 text-primary-500" />
Upcoming Payments (Next 48 Hours)
</div>
</x-slot>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
@foreach ($upcoming as $u)
<div class="flex items-center justify-between p-3 bg-primary-50 rounded-lg">
<div>
<div class="font-semibold">{{ $u->campaign }}</div>
<div class="text-sm text-gray-500">{{ $u->payment_count }} payments</div>
</div>
<div class="text-right">
<div class="font-bold text-primary-600">£{{ number_format($u->total_amount, 2) }}</div>
<div class="text-xs text-gray-500">{{ \Carbon\Carbon::parse($u->earliest)->diffForHumans() }}</div>
</div>
</div>
@endforeach
</div>
</x-filament::section>
@endif
{{-- ── Failed Payments (Needs Attention) ───────────────────── --}}
@if (count($failed) > 0)
<x-filament::section class="mt-6">
<x-slot name="heading">
<div class="flex items-center gap-2">
<x-heroicon-o-exclamation-triangle class="w-5 h-5 text-danger-500" />
Failed Payments Needs Attention ({{ count($failed) }})
</div>
</x-slot>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-gray-500 uppercase border-b">
<th class="pb-2">Donor</th>
<th class="pb-2">Campaign</th>
<th class="pb-2">Amount</th>
<th class="pb-2">Expected</th>
<th class="pb-2">Attempts</th>
<th class="pb-2"></th>
</tr>
</thead>
<tbody>
@foreach ($failed as $f)
<tr class="border-b border-gray-100 hover:bg-gray-50">
<td class="py-2">
<div class="font-medium">{{ $f->donor_name }}</div>
<div class="text-xs text-gray-400">{{ $f->donor_email }}</div>
</td>
<td class="py-2">{{ $f->campaign }}</td>
<td class="py-2 font-semibold">£{{ number_format($f->amount / 100, 2) }}</td>
<td class="py-2 text-gray-500">{{ \Carbon\Carbon::parse($f->expected_at)->format('d M Y') }}</td>
<td class="py-2">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-danger-100 text-danger-800">
{{ $f->attempts }}x failed
</span>
</td>
<td class="py-2">
<a href="{{ url('/admin/scheduled-giving-donations/' . $f->donation_id . '/edit') }}"
class="text-xs text-primary-600 hover:underline">View </a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</x-filament::section>
@endif
</x-filament-panels::page>

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
Redesign the ScheduledGivingDonationResource table columns.
Replace the old columns with campaign-aware, care-focused columns.
"""
import os
BASE = '/home/forge/app.charityright.org.uk'
path = os.path.join(BASE, 'app/Filament/Resources/ScheduledGivingDonationResource.php')
with open(path, 'r') as f:
c = f.read()
old_columns = """ ->columns([
IconColumn::make('donationConfirmation.confirmed_at')
->label('Confirmed ?')
->searchable()
->boolean(),
TextColumn::make('total_amount')
->label('Total')
->numeric()
->money('gbp')
->searchable()
->sortable(),
TextColumn::make('amount_admin')
->label('Admin')
->numeric()
->money('gbp')
->searchable()
->sortable(),
TextColumn::make('customer.name')
->label('Customer')
->description(fn (ScheduledGivingDonation $donation) => $donation->customer?->email)
->sortable()
->searchable(true, function (Builder $query, string $search) {
$query
->whereHas('customer', function (Builder $query) use ($search) {
$query
->where('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}),
TextColumn::make('provider_reference')
->searchable()
->label('Provider Reference'),
TextColumn::make('appeal.name')
->numeric()
->toggleable(isToggledHiddenByDefault: true)
->sortable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])"""
new_columns = """ ->columns([
IconColumn::make('is_active')
->label('')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('danger')
->tooltip(fn (ScheduledGivingDonation $r) => $r->is_active ? 'Active' : 'Cancelled'),
TextColumn::make('customer.name')
->label('Donor')
->description(fn (ScheduledGivingDonation $d) => $d->customer?->email)
->sortable()
->searchable(query: function (Builder $query, string $search) {
$query->whereHas('customer', fn (Builder $q) => $q
->where('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%"));
}),
TextColumn::make('scheduledGivingCampaign.title')
->label('Campaign')
->badge()
->color(fn ($state) => match ($state) {
'30 Nights of Giving' => 'primary',
'10 Days of Giving' => 'success',
'Night of Power' => 'warning',
default => 'gray',
}),
TextColumn::make('total_amount')
->label('Per Night')
->money('gbp', divideBy: 100)
->sortable(),
TextColumn::make('payments_count')
->label('Progress')
->counts('payments')
->formatStateUsing(function ($state, ScheduledGivingDonation $record) {
$paid = $record->payments()->where('is_paid', true)->count();
$total = $state;
if ($total === 0) return 'No payments';
$pct = round($paid / $total * 100);
return "{$paid}/{$total} ({$pct}%)";
})
->color(function (ScheduledGivingDonation $record) {
$total = $record->payments()->count();
if ($total === 0) return 'gray';
$paid = $record->payments()->where('is_paid', true)->count();
$pct = $paid / $total * 100;
return $pct >= 80 ? 'success' : ($pct >= 40 ? 'warning' : 'danger');
})
->badge(),
TextColumn::make('created_at')
->label('Signed Up')
->date('d M Y')
->description(fn (ScheduledGivingDonation $d) => $d->created_at?->diffForHumans())
->sortable(),
TextColumn::make('reference_code')
->label('Ref')
->searchable()
->fontFamily('mono')
->copyable()
->toggleable(isToggledHiddenByDefault: true),
])"""
c = c.replace(old_columns, new_columns)
with open(path, 'w') as f:
f.write(c)
print('Table columns updated')