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
205 lines
8.0 KiB
PHP
205 lines
8.0 KiB
PHP
<?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();
|
|
}
|
|
}
|