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
289 lines
12 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|