Files
calvana/temp_files/fix2/ScheduledGivingDashboard.php
Omair Saleh c11bf4bea7 Fundamental Collect redesign: unified creation, payment clarity, widget embed
THE CORE PROBLEM:
Users didn't understand the appeal→link hierarchy.
Payment method was hidden inside appeal creation.
The widget was a separate, undiscoverable concept.
External platforms (JustGiving, LaunchGood) felt disconnected.

THE FIX:

1. ONE CREATION FLOW for everything:
   Step 1: 'What are you raising for?' → creates the appeal
   Step 2: 'How will donors pay?' → 3 big clear cards:
     - Bank transfer (most popular, free)
     - External platform (JustGiving, LaunchGood, etc.)
     - Card payment (Stripe)
   Step 3: 'Name your link' → shows summary, creates both

2. PAYMENT METHOD VISIBLE ON EVERY LINK:
   Each link card shows a badge: 'Bank' or 'JustGiving' etc.
   External links show 'After pledging, donors are sent to...'
   No confusion about how money flows.

3. WIDGET IS A SHARING TAB, NOT A SEPARATE CONCEPT:
   Every link card expands to show 3 tabs:
     - Link (copy URL, WhatsApp, email, share)
     - QR Code (download PNG for printing)
     - Website Widget (iframe embed code with copy button)
   The widget is just another way to share the same link.

4. FLAT LINK LIST (not appeal→link hierarchy):
   All links shown in one flat list
   Appeal name shown as subtitle when multiple appeals exist
   'New link' adds to existing appeal
   'New appeal' uses the full 3-step wizard

5. EDUCATIONAL RIGHT COLUMN:
   'How it works' 5-step guide
   'Which payment method should I choose?' comparison
   'Can I mix payment methods?' FAQ
   'What's an appeal?' explanation (demystifies the concept)
   Leaderboard when 3+ links have pledges
2026-03-05 04:00:14 +08:00

289 lines
12 KiB
PHP

<?php
namespace App\Filament\Pages;
use App\Models\ScheduledGivingCampaign;
use App\Models\ScheduledGivingDonation;
use Filament\Pages\Page;
use Illuminate\Support\Facades\DB;
/**
* Scheduled Giving Command Centre.
*
* Campaigns are seasonal (Ramadan). Each subscription belongs to a specific year.
* "Current season" = has future payments. "Expired" = all payments in the past.
* Only shows REAL subscribers (has customer + payments + amount > 0 + not soft-deleted).
*/
class ScheduledGivingDashboard extends Page
{
protected static ?string $navigationIcon = 'heroicon-o-calendar-days';
protected static ?string $navigationGroup = 'Giving';
protected static ?int $navigationSort = 0;
protected static ?string $navigationLabel = 'Dashboard';
protected static ?string $title = 'Scheduled Giving';
protected static string $view = 'filament.pages.scheduled-giving-dashboard';
/** Real subscriber IDs (has customer + payments + amount > 0 + not soft-deleted) */
private function realSubscriberIds(?int $campaignId = null)
{
$q = DB::table('scheduled_giving_donations as d')
->whereNotNull('d.customer_id')
->where('d.total_amount', '>', 0)
->whereNull('d.deleted_at')
->whereExists(function ($sub) {
$sub->select(DB::raw(1))
->from('scheduled_giving_payments as p')
->whereColumn('p.scheduled_giving_donation_id', 'd.id')
->whereNull('p.deleted_at');
});
if ($campaignId) {
$q->where('d.scheduled_giving_campaign_id', $campaignId);
}
return $q->pluck('d.id');
}
/** IDs with at least one future payment = current season */
private function currentSeasonIds(?int $campaignId = null)
{
$realIds = $this->realSubscriberIds($campaignId);
if ($realIds->isEmpty()) return collect();
return DB::table('scheduled_giving_donations as d')
->whereIn('d.id', $realIds)
->whereExists(function ($sub) {
$sub->select(DB::raw(1))
->from('scheduled_giving_payments as p')
->whereColumn('p.scheduled_giving_donation_id', 'd.id')
->whereNull('p.deleted_at')
->whereRaw('p.expected_at > NOW()');
})
->pluck('d.id');
}
public function getCampaignData(): array
{
$campaigns = ScheduledGivingCampaign::all();
$result = [];
foreach ($campaigns as $c) {
$realIds = $this->realSubscriberIds($c->id);
if ($realIds->isEmpty()) {
$result[] = $this->emptyCampaign($c);
continue;
}
$currentIds = $this->currentSeasonIds($c->id);
$expiredIds = $realIds->diff($currentIds);
// Current season payment stats
$currentPayments = null;
if ($currentIds->isNotEmpty()) {
$currentPayments = DB::table('scheduled_giving_payments')
->whereIn('scheduled_giving_donation_id', $currentIds)
->whereNull('deleted_at')
->selectRaw("
COUNT(*) as total,
SUM(is_paid = 1) as paid,
SUM(is_paid = 0) as pending,
SUM(is_paid = 0 AND attempts > 0) 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
")
->first();
}
// All-time payment stats (for totals)
$allPayments = DB::table('scheduled_giving_payments')
->whereIn('scheduled_giving_donation_id', $realIds)
->whereNull('deleted_at')
->selectRaw("
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) as collected,
SUM(CASE WHEN is_paid = 1 THEN 1 ELSE 0 END) as paid,
COUNT(*) as total
")
->first();
// Completion for current season
$totalNights = count($c->dates ?? []);
$fullyPaid = 0;
if ($totalNights > 0 && $currentIds->isNotEmpty()) {
$ids = $currentIds->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,
'all_time_subscribers' => $realIds->count(),
'all_time_collected' => ($allPayments->collected ?? 0) / 100,
'all_time_payments' => (int) ($allPayments->total ?? 0),
'all_time_paid' => (int) ($allPayments->paid ?? 0),
// Current season
'current_subscribers' => $currentIds->count(),
'expired_subscribers' => $expiredIds->count(),
'current_payments' => (int) ($currentPayments->total ?? 0),
'current_paid' => (int) ($currentPayments->paid ?? 0),
'current_pending' => (int) ($currentPayments->pending ?? 0),
'current_failed' => (int) ($currentPayments->failed ?? 0),
'current_collected' => ($currentPayments->collected ?? 0) / 100,
'current_pending_amount' => ($currentPayments->pending_amount ?? 0) / 100,
'avg_per_night' => ($currentPayments->avg_amount ?? 0) / 100,
'fully_completed' => $fullyPaid,
'dates' => $c->dates ?? [],
'total_nights' => $totalNights,
'next_payment' => $currentPayments->next_payment ?? null,
];
}
return $result;
}
public function getGlobalStats(): array
{
$realIds = $this->realSubscriberIds();
$currentIds = $this->currentSeasonIds();
$allTime = DB::table('scheduled_giving_payments')
->whereIn('scheduled_giving_donation_id', $realIds)
->whereNull('deleted_at')
->selectRaw("
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) / 100 as collected,
SUM(CASE WHEN is_paid = 1 THEN 1 ELSE 0 END) as paid,
COUNT(*) as total
")
->first();
$currentStats = null;
if ($currentIds->isNotEmpty()) {
$currentStats = DB::table('scheduled_giving_payments')
->whereIn('scheduled_giving_donation_id', $currentIds)
->whereNull('deleted_at')
->selectRaw("
SUM(is_paid = 1) as paid,
SUM(is_paid = 0 AND attempts > 0) 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,
COUNT(*) as total
")
->first();
}
return [
'total_subscribers' => $realIds->count(),
'current_subscribers' => $currentIds->count(),
'expired_subscribers' => $realIds->count() - $currentIds->count(),
'all_time_collected' => (float) ($allTime->collected ?? 0),
'current_collected' => (float) ($currentStats->collected ?? 0),
'current_pending' => (float) ($currentStats->pending ?? 0),
'current_failed' => (int) ($currentStats->failed ?? 0),
'collection_rate' => ($currentStats->total ?? 0) > 0
? round($currentStats->paid / $currentStats->total * 100, 1)
: 0,
];
}
public function getFailedPayments(): array
{
$currentIds = $this->currentSeasonIds();
if ($currentIds->isEmpty()) return [];
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')
->whereIn('d.id', $currentIds)
->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();
}
public function getUpcomingPayments(): array
{
$currentIds = $this->currentSeasonIds();
if ($currentIds->isEmpty()) return [];
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')
->whereIn('d.id', $currentIds)
->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();
}
public function getDataQuality(): array
{
$total = DB::table('scheduled_giving_donations')->count();
$softDeleted = DB::table('scheduled_giving_donations')->whereNotNull('deleted_at')->count();
$noCustomer = DB::table('scheduled_giving_donations')->whereNull('customer_id')->whereNull('deleted_at')->count();
$noPayments = DB::table('scheduled_giving_donations as d')
->whereNull('d.deleted_at')
->whereNotNull('d.customer_id')
->whereNotExists(function ($q) {
$q->select(DB::raw(1))
->from('scheduled_giving_payments as p')
->whereColumn('p.scheduled_giving_donation_id', 'd.id')
->whereNull('p.deleted_at');
})->count();
$zeroAmount = DB::table('scheduled_giving_donations')
->whereNull('deleted_at')
->where('total_amount', '<=', 0)
->count();
return [
'total_records' => $total,
'soft_deleted' => $softDeleted,
'no_customer' => $noCustomer,
'no_payments' => $noPayments,
'zero_amount' => $zeroAmount,
];
}
private function emptyCampaign($c): array
{
return [
'campaign' => $c,
'all_time_subscribers' => 0, 'all_time_collected' => 0, 'all_time_payments' => 0, 'all_time_paid' => 0,
'current_subscribers' => 0, 'expired_subscribers' => 0,
'current_payments' => 0, 'current_paid' => 0, 'current_pending' => 0, 'current_failed' => 0,
'current_collected' => 0, 'current_pending_amount' => 0, 'avg_per_night' => 0,
'fully_completed' => 0, 'dates' => $c->dates ?? [], 'total_nights' => count($c->dates ?? []),
'next_payment' => null,
];
}
}