From e852250ce0481ad78b06824a087a378886c1eed7 Mon Sep 17 00:00:00 2001 From: Omair Saleh Date: Thu, 5 Mar 2026 03:35:08 +0800 Subject: [PATCH] Landing page philosophy across ALL dashboard pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/app/dashboard/exports/page.tsx | 271 +++++++++------ .../src/app/dashboard/page.tsx | 180 +++++++--- .../src/app/dashboard/pledges/page.tsx | 318 +++++++++++------- .../src/app/dashboard/settings/page.tsx | 87 ++++- .../sg/ListScheduledGivingDonations.php | 96 ++++++ temp_files/sg/ScheduledGivingDashboard.php | 204 +++++++++++ temp_files/sg/nav_update.py | 184 ++++++++++ .../sg/scheduled-giving-dashboard.blade.php | 199 +++++++++++ temp_files/sg/table_update.py | 141 ++++++++ 9 files changed, 1402 insertions(+), 278 deletions(-) create mode 100644 temp_files/sg/ListScheduledGivingDonations.php create mode 100644 temp_files/sg/ScheduledGivingDashboard.php create mode 100644 temp_files/sg/nav_update.py create mode 100644 temp_files/sg/scheduled-giving-dashboard.blade.php create mode 100644 temp_files/sg/table_update.py diff --git a/pledge-now-pay-later/src/app/dashboard/exports/page.tsx b/pledge-now-pay-later/src/app/dashboard/exports/page.tsx index e13127b..0a0ef36 100644 --- a/pledge-now-pay-later/src/app/dashboard/exports/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/exports/page.tsx @@ -219,120 +219,171 @@ export default function ReportsPage() { )} - {/* ── Downloads ── */} -
- {/* Full data */} -
-
- -
-

Full data download

-

Everything in one spreadsheet — donor details, amounts, statuses, attribution, consent flags.

-
-
-
- Donor name, email, phone - Amount and payment status - Payment method and reference - Appeal name and source - Gift Aid and Zakat flags - Days to collect -
- -
+ {/* ━━ TWO-COLUMN: Downloads left, Education right ━━━━━━ */} +
- {/* Gift Aid */} -
-
- -
-

Gift Aid report

-

HMRC-ready declarations for tax reclaim. Only donors who ticked Gift Aid and whose payment was received.

-
-
- - {/* Gift Aid preview */} -
-
-

{giftAidCount ?? "–"}

-

Eligible declarations

-
-
-

{formatPence(giftAidReclaimable)}

-

Reclaimable from HMRC

-
-
- -
-

- Claim 25p for every £1 donated by a UK taxpayer -

-
- - -
-
- - {/* ── API / Zapier ── */} -
-
- -
-

Connect to other tools

-

Pull data into Zapier, Make, or your own systems using our API.

-
-
-
-
-

Pledges endpoint

- GET /api/pledges?status=new&sort=createdAt -

Returns all pledges with donor contact info, filterable by status.

-
-
-

Dashboard endpoint

- GET /api/dashboard -

Returns summary stats, status breakdown, top sources, and all pledges.

-
-
-
-

- Connect to Zapier or Make to send automatic reminder emails to donors without WhatsApp -

-
-
- - {/* ── Activity log ── */} - {activity.length > 0 && ( -
-
- -

Recent activity

-
-
- {activity.map((a, i) => ( -
-
-
-

{a.description || a.action}

-
- - {new Date(a.timestamp).toLocaleDateString("en-GB", { day: "numeric", month: "short" })} - + {/* LEFT: Downloads + Data */} +
+ {/* Full data */} +
+
+ +
+

Full data download

+

Everything in one spreadsheet — donor details, amounts, statuses, attribution, consent flags.

- ))} +
+
+ Donor name, email, phone + Amount and payment status + Payment method and reference + Appeal name and source + Gift Aid and Zakat flags + Days to collect +
+ +
+ + {/* Gift Aid */} +
+
+ +
+

Gift Aid report

+

HMRC-ready declarations for tax reclaim. Only donors who ticked Gift Aid and whose payment was received.

+
+
+ + {/* Gift Aid preview */} +
+
+

{giftAidCount ?? "–"}

+

Eligible declarations

+
+
+

{formatPence(giftAidReclaimable)}

+

Reclaimable from HMRC

+
+
+ + +
+ + {/* API / Zapier */} +
+
+ +
+

Connect to other tools

+

Pull data into Zapier, Make, or your own systems using our API.

+
+
+
+
+

Pledges endpoint

+ GET /api/pledges?status=new&sort=createdAt +
+
+

Dashboard endpoint

+ GET /api/dashboard +
+
- )} + + {/* RIGHT: Education + Context */} +
+ + {/* What your treasurer needs */} +
+
+

For your treasurer

+
+
+ {[ + { n: "01", title: "Download the full data CSV", desc: "Every pledge with donor details, amounts, references, and status. Open in Excel or Google Sheets." }, + { n: "02", title: "Download the Gift Aid report", desc: "Only confirmed payments from UK taxpayers who ticked Gift Aid. Ready to submit to HMRC." }, + { n: "03", title: "Submit to HMRC online", desc: "Upload the Gift Aid CSV at gov.uk/claim-gift-aid. HMRC sends the money to your charity account." }, + ].map(s => ( +
+ {s.n} +
+

{s.title}

+

{s.desc}

+
+
+ ))} +
+
+ + {/* Understanding Gift Aid */} +
+

Understanding Gift Aid

+
+ {[ + { q: "What is it?", a: "HMRC gives your charity an extra 25p for every £1 donated by a UK taxpayer." }, + { q: "Who's eligible?", a: "Any donor who ticks 'I am a UK taxpayer' when they pledge. They must have paid enough income/capital gains tax." }, + { q: "How much can we claim?", a: `You have ${giftAidCount ?? 0} eligible declarations worth ${formatPence(giftAidReclaimable)} in Gift Aid.` }, + { q: "When to claim?", a: "You can claim anytime. Most charities do it quarterly or after each event." }, + ].map(item => ( +
+

{item.q}

+

{item.a}

+
+ ))} +
+
+ + {/* Understanding your collection rate */} +
+

Understanding your numbers

+
+

+ Collection rate — what percentage of promised money has actually arrived. 70%+ is excellent for pledge-based fundraising. +

+

+ Still outstanding — money promised but not yet received. Reminders are sent automatically for these. +

+

+ Per-appeal breakdown — compare which events or campaigns collected best. Use this to plan your next fundraiser. +

+
+
+ + {/* Activity log */} + {activity.length > 0 && ( +
+
+ +

Recent activity

+
+
+ {activity.map((a, i) => ( +
+
+
+

{a.description || a.action}

+
+ + {new Date(a.timestamp).toLocaleDateString("en-GB", { day: "numeric", month: "short" })} + +
+ ))} +
+
+ )} +
+
) } diff --git a/pledge-now-pay-later/src/app/dashboard/page.tsx b/pledge-now-pay-later/src/app/dashboard/page.tsx index 49b63b3..a601537 100644 --- a/pledge-now-pay-later/src/app/dashboard/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/page.tsx @@ -235,10 +235,46 @@ export default function DashboardPage() {
)} - {/* ── Has pledges: Stats + Feed ── */} + {/* ── Empty state: educational guidance ── */} + {isEmpty && !pledgeLink && ( +
+
+

Share your pledge link to get started

+

+ Go to Collect to + create an appeal and get your pledge link. +

+
+ +
+
+

How it works

+
+
+ {[ + { n: "01", title: "Create a pledge link", desc: "Give it a name like 'Table 5' or 'Ramadan 2026'. Print the QR or share the link." }, + { n: "02", title: "Donors pledge in 60 seconds", desc: "Name, phone, amount, Gift Aid — done. No account, no app download." }, + { n: "03", title: "They get your bank details", desc: "Instantly via WhatsApp. With a unique reference so you can match their payment." }, + { n: "04", title: "Reminders go out automatically", desc: "Day 2, day 7, day 14. Warm and never pushy. They stop when the donor pays." }, + { n: "05", title: "Upload your bank statement", desc: "We match payments to pledges automatically. No spreadsheet cross-referencing." }, + ].map(s => ( +
+ {s.n} +
+

{s.title}

+

{s.desc}

+
+
+ ))} +
+
+
+ )} + + {/* ── Has pledges: Stats + Feed + Education ── */} {!isEmpty && ( <> - {/* Stats — dark inversion like landing page hero */} + {/* Stats — gap-px grid */}
{[ { value: String(s.totalPledges), label: "Pledges" }, @@ -282,9 +318,9 @@ export default function DashboardPage() { )} -
- {/* LEFT column */} -
+
+ {/* LEFT column: Data */} +
{/* Needs attention */} {needsAttention.length > 0 && (
@@ -312,47 +348,7 @@ export default function DashboardPage() {
)} - {/* How pledges are doing */} -
-
-

How pledges are doing

-
-
- {Object.entries(byStatus).map(([status, count]) => { - const sl = STATUS_LABELS[status] || STATUS_LABELS.new - return ( -
- {sl.label} - {count as number} -
- ) - })} -
-
- - {/* Where pledges come from */} - {topSources.length > 0 && ( -
-
-

Where pledges come from

-
-
- {topSources.slice(0, 5).map((src: { label: string; count: number; amount: number }, i: number) => ( -
-
- {i + 1} - {src.label} -
- {formatPence(src.amount)} -
- ))} -
-
- )} -
- - {/* RIGHT column: Recent pledges */} -
+ {/* Recent pledges */}

Recent pledges

@@ -396,6 +392,98 @@ export default function DashboardPage() {
+ + {/* RIGHT column: Status + Sources + Guidance */} +
+ {/* How pledges are doing */} +
+
+

How pledges are doing

+
+
+ {Object.entries(byStatus).map(([status, count]) => { + const sl = STATUS_LABELS[status] || STATUS_LABELS.new + return ( +
+ {sl.label} + {count as number} +
+ ) + })} +
+
+ + {/* Where pledges come from */} + {topSources.length > 0 && ( +
+
+

Where pledges come from

+
+
+ {topSources.slice(0, 5).map((src: { label: string; count: number; amount: number }, i: number) => ( +
+
+ {i + 1} + {src.label} +
+ {formatPence(src.amount)} +
+ ))} +
+
+ )} + + {/* What to do next — contextual guidance */} +
+

What to do next

+
+ {s.collectionRate < 100 && (byStatus.initiated || 0) > 0 && ( + + +

+ Upload your bank statement to confirm {byStatus.initiated} {byStatus.initiated === 1 ? "payment" : "payments"} automatically +

+ + )} + {s.collectionRate < 50 && ( + + +

+ Share your link more widely — WhatsApp groups, social media, or print the QR +

+ + )} + + +

+ Check your messages — see what donors receive and improve wording +

+ + + +

+ Download for your treasurer — Gift Aid report, full pledge data, HMRC-ready CSV +

+ +
+
+ + {/* Understanding statuses */} +
+

What the statuses mean

+ {[ + { label: "Waiting", desc: "Pledged but hasn't paid yet — reminders are being sent" }, + { label: "Said they paid", desc: "Donor replied PAID — upload bank statement to confirm" }, + { label: "Received ✓", desc: "Payment confirmed in your bank account" }, + { label: "Needs a nudge", desc: "It's been a while — you can send a manual reminder" }, + ].map(s => ( +
+ +

{s.label} — {s.desc}

+
+ ))} +
+
)} diff --git a/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx b/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx index 12f9846..bb52ace 100644 --- a/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx @@ -583,134 +583,212 @@ export default function MoneyPage() {
)} - {/* ── Search + filter ── */} -
-
- - { setSearch(e.target.value); setPage(0) }} - className="pl-9 w-full h-10 border-2 border-gray-200 text-sm focus:border-[#1E40AF] outline-none transition-colors" - /> -
-
- {[ - { value: "all", label: "All" }, - { value: "new", label: "Waiting" }, - { value: "initiated", label: "Said paid" }, - { value: "overdue", label: "Overdue" }, - { value: "paid", label: "Received" }, - ].map(t => ( - - ))} -
-
+ {/* ━━ TWO-COLUMN: Pledges left, Education right ━━━━━━━━ */} +
- {/* ── Pledge table ── */} - {tablePledges.length === 0 ? ( -
-

No pledges found

-

{search ? `No results for "${search}"` : "Share your pledge links to start collecting"}

-
- ) : ( -
-
-
Donor
-
Amount
-
Appeal
-
Status
-
When
-
+ {/* LEFT: Search + Table */} +
+ {/* Search + filter */} +
+
+ + { setSearch(e.target.value); setPage(0) }} + className="pl-9 w-full h-10 border-2 border-gray-200 text-sm focus:border-[#1E40AF] outline-none transition-colors" + /> +
+
+ {[ + { value: "all", label: "All" }, + { value: "new", label: "Waiting" }, + { value: "initiated", label: "Said paid" }, + { value: "overdue", label: "Overdue" }, + { value: "paid", label: "Received" }, + ].map(t => ( + + ))} +
- {tablePledges.map((p: Pledge) => { - const sl = STATUS[p.status] || STATUS.new - return ( -
-
-

{p.donorName || "Anonymous"}

-
- {p.reference} - {p.donorPhone && } + {/* Pledge table */} + {tablePledges.length === 0 ? ( +
+

No pledges found

+

{search ? `No results for "${search}"` : "Share your pledge links to start collecting"}

+
+ ) : ( +
+
+
Donor
+
Amount
+
Appeal
+
Status
+
When
+
+
+ + {tablePledges.map((p: Pledge) => { + const sl = STATUS[p.status] || STATUS.new + return ( +
+
+

{p.donorName || "Anonymous"}

+
+ {p.reference} + {p.donorPhone && } +
+
+
+

{formatPence(p.amountPence)}

+ {p.giftAid && +Gift Aid} + {p.installmentTotal && p.installmentTotal > 1 && ( +

{p.installmentNumber}/{p.installmentTotal}

+ )} +
+
+

{p.eventName}

+ {p.qrSourceLabel &&

{p.qrSourceLabel}

} +
+
+ {sl.label} +
+
+ {timeAgo(p.createdAt)} +
+
+ + + + + + {p.status !== "paid" && ( + updateStatus(p.id, "paid")}> + Mark as received + + )} + {p.status !== "initiated" && p.status !== "paid" && ( + updateStatus(p.id, "initiated")}> + Mark as "said they paid" + + )} + {p.donorPhone && p.status !== "paid" && ( + <> + + sendReminder(p)}> + Send WhatsApp reminder + + + )} + {p.status !== "cancelled" && p.status !== "paid" && ( + <> + + updateStatus(p.id, "cancelled")}> + Cancel pledge + + + )} + + +
+
+ ) + })} +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+

{page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total}

+
+ + +
+
+ )} +
+ + {/* RIGHT: Education + Context */} +
+ + {/* How matching works */} +
+
+

How matching works

+
+
+ {[ + { n: "01", title: "Donor pledges", desc: "They receive your bank details with a unique reference like PNPL-A2F4-50." }, + { n: "02", title: "They pay you", desc: "Bank transfer, JustGiving, card — using the reference you gave them." }, + { n: "03", title: "Upload your statement", desc: "Download a CSV from your bank. Drop it above — we read it in seconds." }, + { n: "04", title: "We match automatically", desc: "References, amounts, and dates — matched to the right pledge. No spreadsheet work." }, + ].map(s => ( +
+ {s.n} +
+

{s.title}

+

{s.desc}

-
-

{formatPence(p.amountPence)}

- {p.giftAid && +Gift Aid} - {p.installmentTotal && p.installmentTotal > 1 && ( -

{p.installmentNumber}/{p.installmentTotal}

- )} -
-
-

{p.eventName}

- {p.qrSourceLabel &&

{p.qrSourceLabel}

} -
-
- {sl.label} -
-
- {timeAgo(p.createdAt)} -
-
- - - - - - {p.status !== "paid" && ( - updateStatus(p.id, "paid")}> - Mark as received - - )} - {p.status !== "initiated" && p.status !== "paid" && ( - updateStatus(p.id, "initiated")}> - Mark as "said they paid" - - )} - {p.donorPhone && p.status !== "paid" && ( - <> - - sendReminder(p)}> - Send WhatsApp reminder - - - )} - {p.status !== "cancelled" && p.status !== "paid" && ( - <> - - updateStatus(p.id, "cancelled")}> - Cancel pledge - - - )} - - -
-
- ) - })} -
- )} + ))} +
+
- {/* Pagination */} - {totalPages > 1 && ( -
-

{page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total}

-
- - + {/* What the statuses mean */} +
+

What the statuses mean

+
+ {[ + { label: "Waiting", color: "bg-gray-400", desc: "Pledged but hasn't paid yet. Reminders are being sent automatically." }, + { label: "Said they paid", color: "bg-[#F59E0B]", desc: "Donor replied PAID via WhatsApp. Upload a bank statement to confirm." }, + { label: "Received ✓", color: "bg-[#16A34A]", desc: "Payment confirmed — either matched from bank statement or manually marked." }, + { label: "Needs a nudge", color: "bg-[#DC2626]", desc: "It's been a while with no payment. Consider a personal message." }, + ].map(s => ( +
+ +
+

{s.label}

+

{s.desc}

+
+
+ ))} +
+
+ + {/* Tips */} +
+

Tips for better collection

+

Upload your bank statement weekly — the sooner you confirm, the sooner reminders stop.

+

Donors who say they've paid usually have — but always verify with your bank.

+

If someone asks "where do I pay?" — they can reply HELP to any WhatsApp message.

+
+ + {/* Quick actions */} +
+

Quick actions

+
+ + +
- )} +
) } diff --git a/pledge-now-pay-later/src/app/dashboard/settings/page.tsx b/pledge-now-pay-later/src/app/dashboard/settings/page.tsx index d3a2e6c..c073ec9 100644 --- a/pledge-now-pay-later/src/app/dashboard/settings/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/settings/page.tsx @@ -186,7 +186,7 @@ export default function SettingsPage() { : `${totalCount - doneCount} thing${totalCount - doneCount > 1 ? "s" : ""} left before you go live.` return ( -
+
{/* ── Header — human progress, not a form page ── */}
@@ -210,7 +210,11 @@ export default function SettingsPage() { {error &&
{error}
} - {/* ── The Checklist ── */} + {/* ━━ TWO-COLUMN: Checklist left, Education right ━━━━━━ */} +
+ + {/* LEFT: The Checklist */} +
{/* ▸ WhatsApp ─────────────────────────── */} @@ -501,6 +505,85 @@ export default function SettingsPage() {
+
+ + {/* RIGHT: Education + Context */} +
+ + {/* What each setting does */} +
+
+

What you're setting up

+
+
+ {[ + { n: "01", title: "WhatsApp", desc: "Scan a QR code to connect your phone. Donors get receipts and reminders automatically. They can reply PAID, HELP, or CANCEL.", essential: true }, + { n: "02", title: "Bank account", desc: "Your sort code and account number. Shown to donors after they pledge so they know where to send money.", essential: true }, + { n: "03", title: "Your charity", desc: "Name and brand colour shown on pledge pages. Donors see this when they tap your link.", essential: true }, + { n: "04", title: "Card payments", desc: "Connect Stripe to let donors pay by Visa, Mastercard, or Apple Pay. Money goes straight to your account.", essential: false }, + { n: "05", title: "Team", desc: "Invite community leaders and volunteers. They get their own pledge links and can see their own results.", essential: false }, + ].map(s => ( +
+ {s.n} +
+

+ {s.title} + {s.essential && Required} +

+

{s.desc}

+
+
+ ))} +
+
+ + {/* Privacy & data */} +
+

Privacy & data

+
+

+ Your data stays yours. We never access your Stripe account, bank details, or WhatsApp messages. Everything is stored encrypted. +

+

+ GDPR compliant. Donor consent is recorded at pledge time. You can export or delete all data anytime. +

+

+ No vendor lock-in. Download your full data as CSV from Reports. Your donors, your data, always. +

+
+
+ + {/* Common questions */} +
+

Common questions

+
+ {[ + { q: "Do I need Stripe?", a: "No — most charities use bank transfer only. Stripe is optional for orgs that want card payments." }, + { q: "Can I change my bank details later?", a: "Yes. New pledges will show the updated details. Existing pledges keep the original reference." }, + { q: "What happens if WhatsApp disconnects?", a: "Reminders pause until you reconnect. Come back here, scan the QR again. It takes 30 seconds." }, + { q: "Can volunteers see financial data?", a: "No. Volunteers only see their own link performance. Admins see everything." }, + ].map(item => ( +
+

{item.q}

+

{item.a}

+
+ ))} +
+
+ + {/* Need help? */} +
+

Need help setting up?

+

+ Our team can walk you through the setup in 15 minutes. Free, no strings attached. +

+ + Get in touch → + +
+
+ +
) } diff --git a/temp_files/sg/ListScheduledGivingDonations.php b/temp_files/sg/ListScheduledGivingDonations.php new file mode 100644 index 0000000..ed09505 --- /dev/null +++ b/temp_files/sg/ListScheduledGivingDonations.php @@ -0,0 +1,96 @@ +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'; + } +} diff --git a/temp_files/sg/ScheduledGivingDashboard.php b/temp_files/sg/ScheduledGivingDashboard.php new file mode 100644 index 0000000..be60504 --- /dev/null +++ b/temp_files/sg/ScheduledGivingDashboard.php @@ -0,0 +1,204 @@ + 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(); + } +} diff --git a/temp_files/sg/nav_update.py b/temp_files/sg/nav_update.py new file mode 100644 index 0000000..b359220 --- /dev/null +++ b/temp_files/sg/nav_update.py @@ -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!') diff --git a/temp_files/sg/scheduled-giving-dashboard.blade.php b/temp_files/sg/scheduled-giving-dashboard.blade.php new file mode 100644 index 0000000..4af0184 --- /dev/null +++ b/temp_files/sg/scheduled-giving-dashboard.blade.php @@ -0,0 +1,199 @@ + + @php + $global = $this->getGlobalStats(); + $campaigns = $this->getCampaignData(); + $failed = $this->getFailedPayments(); + $upcoming = $this->getUpcomingPayments(); + @endphp + + {{-- ── Global Overview ──────────────────────────────────────── --}} +
+ +
+
{{ number_format($global['active_subscribers']) }}
+
Active Subscribers
+
+
+ + +
+
£{{ number_format($global['collected'], 2) }}
+
Total Collected
+
+
+ + +
+
£{{ number_format($global['pending'], 2) }}
+
Pending Collection
+
+
+ + +
+
+ {{ $global['collection_rate'] }}% +
+
Collection Rate
+
+
+
+ + {{-- ── Campaign Cards ──────────────────────────────────────── --}} +
+ @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 + + + +
+ {{ $c->title }} + @if ($isActive) + ● Live + @else + ○ Ended + @endif +
+
+ + {{-- Stats grid --}} +
+
+
Subscribers
+
{{ number_format($data['active']) }} / {{ number_format($data['subscribers']) }}
+
+
+
Avg / Night
+
£{{ number_format($data['avg_per_night'], 2) }}
+
+
+
Collected
+
£{{ number_format($data['collected'], 2) }}
+
+
+
Pending
+
£{{ number_format($data['pending_amount'], 2) }}
+
+
+ + {{-- Payment progress bar --}} +
+
+ {{ number_format($data['paid_payments']) }} / {{ number_format($data['total_payments']) }} payments + {{ $progressPct }}% +
+
+
+
+
+ + {{-- Key metrics --}} +
+
+
Nights
+
{{ $data['total_nights'] }}
+
+
+
Completed
+
{{ $data['fully_completed'] ?? 0 }}
+
+
+
Failed
+
{{ $data['failed_payments'] }}
+
+
+ + {{-- Quick links --}} + +
+ @endforeach +
+ + {{-- ── Upcoming Payments ───────────────────────────────────── --}} + @if (count($upcoming) > 0) + + +
+ + Upcoming Payments (Next 48 Hours) +
+
+ +
+ @foreach ($upcoming as $u) +
+
+
{{ $u->campaign }}
+
{{ $u->payment_count }} payments
+
+
+
£{{ number_format($u->total_amount, 2) }}
+
{{ \Carbon\Carbon::parse($u->earliest)->diffForHumans() }}
+
+
+ @endforeach +
+
+ @endif + + {{-- ── Failed Payments (Needs Attention) ───────────────────── --}} + @if (count($failed) > 0) + + +
+ + Failed Payments — Needs Attention ({{ count($failed) }}) +
+
+ +
+ + + + + + + + + + + + + @foreach ($failed as $f) + + + + + + + + + @endforeach + +
DonorCampaignAmountExpectedAttempts
+
{{ $f->donor_name }}
+
{{ $f->donor_email }}
+
{{ $f->campaign }}£{{ number_format($f->amount / 100, 2) }}{{ \Carbon\Carbon::parse($f->expected_at)->format('d M Y') }} + + {{ $f->attempts }}x failed + + + View → +
+
+
+ @endif +
diff --git a/temp_files/sg/table_update.py b/temp_files/sg/table_update.py new file mode 100644 index 0000000..9066b09 --- /dev/null +++ b/temp_files/sg/table_update.py @@ -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')