From c43404694efe5b377c902e1e63d3ec19a1d6be06 Mon Sep 17 00:00:00 2001 From: Omair Saleh Date: Wed, 4 Mar 2026 21:22:03 +0800 Subject: [PATCH] Telepathic Money + Reports: context-aware inbox, financial summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Money page (/dashboard/money) — context-aware inbox The key insight: Aaisha's #1 Money question changes over time. Day 1: 'Did anyone pledge?' → Recent section Day 3: 'Are they paying?' → Confirm section Day 10: 'Who hasn't paid?' → Nudge section Day 30: 'Give me the spreadsheet' → she goes to Reports Changes: - Contextual 'Confirm these payments' section (amber) Shows when there are 'said they paid' pledges One-click green 'Confirm' button on each row Links to bank statement upload Only appears on 'all' filter (not when already filtering) - Contextual 'These people need a nudge' section (red) Shows when there are overdue pledges One-click green 'Nudge' WhatsApp button + 'Paid' quick button Shows days since pledge for urgency - Stats bar redesigned: 5 clickable stat cells (gap-px) Each acts as a filter toggle with active underline Color-coded: amber for 'said paid', red for overdue, green for received - Filter pills replace shadcn Tabs (smaller, more buttons fit) Pill buttons instead of tab strip — works better on mobile - Table kept for Fatima (power user who scans everything) Same columns, actions, pagination as before - Match payments CTA promoted: full-width card with icon + description No longer a text link hidden at the bottom ## Reports page (/dashboard/reports) — Fatima's dashboard The key insight: Fatima (treasurer) logs in monthly. She should NOT need to visit any other page. Changes: - Financial summary hero (dark section) Total promised, total received, outstanding, collection rate Progress bar with percentage Same visual language as leaderboard hero - Status breakdown with visual bars Horizontal bars showing distribution: paid/waiting/initiated/overdue Percentage labels - Per-appeal breakdown table Each appeal: pledges, promised, received, collection rate Total row at bottom for multi-appeal orgs Rate color-coded: green ≥70%, amber ≥40%, gray below - Gift Aid section with PREVIEW Shows number of eligible declarations + reclaimable amount before downloading — Fatima can see if it's worth running '25p for every £1' callout - Downloads: Full CSV + Gift Aid CSV Same download functionality, better presentation - API/Zapier section redesigned Two endpoint examples (pledges + dashboard) Clearer documentation for Zapier/Make integration - Activity log section Shows recent system activity (audit trail) Scrollable, max 20 entries 2 pages rewritten (~38k bytes) --- .../src/app/dashboard/exports/page.tsx | 319 ++++++++-- .../src/app/dashboard/pledges/page.tsx | 550 +++++++++++------- 2 files changed, 609 insertions(+), 260 deletions(-) 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 6732b81..b065566 100644 --- a/pledge-now-pay-later/src/app/dashboard/exports/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/exports/page.tsx @@ -1,96 +1,317 @@ "use client" -import { Download } from "lucide-react" +import { useState, useEffect, useCallback } from "react" +import { formatPence } from "@/lib/utils" +import { Download, Loader2, FileText, Shield, Zap, Activity } from "lucide-react" + +/** + * /dashboard/reports — "My treasurer needs numbers" + * + * This is Fatima's dashboard. She logs in once a month and thinks: + * + * 1. "How much have we raised?" → Financial summary at top + * 2. "What's the breakdown by appeal?" → Per-appeal table + * 3. "How much Gift Aid can we claim?" → Gift Aid section with preview + * 4. "Give me the spreadsheet" → Download buttons + * 5. "What's been happening?" → Activity log + * + * She should NOT need to visit any other page. + * Everything a treasurer needs is here. + */ + +interface EventSummary { + id: string; name: string; pledgeCount: number + totalPledged: number; totalCollected: number +} + +interface ActivityEntry { + id: string; action: string; description: string + timestamp: string; entityType?: string +} export default function ReportsPage() { - const handleCrmExport = () => { + const [loading, setLoading] = useState(true) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [dash, setDash] = useState(null) + const [events, setEvents] = useState([]) + const [giftAidCount, setGiftAidCount] = useState(null) + const [activity, setActivity] = useState([]) + + const load = useCallback(async () => { + try { + const [dashRes, evRes, actRes] = await Promise.all([ + fetch("/api/dashboard").then(r => r.json()), + fetch("/api/events").then(r => r.json()), + fetch("/api/activity?limit=20").then(r => r.json()).catch(() => ({ entries: [] })), + ]) + if (dashRes.summary) setDash(dashRes) + if (Array.isArray(evRes)) setEvents(evRes) + if (actRes.entries) setActivity(actRes.entries) + + // Count Gift Aid pledges + const giftAidPledges = (dashRes.pledges || []).filter((p: { giftAid: boolean; status: string }) => p.giftAid && p.status === "paid") + setGiftAidCount(giftAidPledges.length) + } catch { /* */ } + setLoading(false) + }, []) + + useEffect(() => { load() }, [load]) + + const downloadCsv = (giftAidOnly = false) => { const a = document.createElement("a") - a.href = "/api/exports/crm-pack" - a.download = `pledges-export-${new Date().toISOString().slice(0, 10)}.csv` + a.href = giftAidOnly ? "/api/exports/crm-pack?giftAidOnly=true" : "/api/exports/crm-pack" + a.download = giftAidOnly + ? `gift-aid-declarations-${new Date().toISOString().slice(0, 10)}.csv` + : `pledges-export-${new Date().toISOString().slice(0, 10)}.csv` a.click() } - const handleGiftAidExport = () => { - const a = document.createElement("a") - a.href = "/api/exports/crm-pack?giftAidOnly=true" - a.download = `gift-aid-declarations-${new Date().toISOString().slice(0, 10)}.csv` - a.click() - } + if (loading) return
+ + const s = dash?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0 } + const byStatus = dash?.byStatus || {} + const outstanding = s.totalPledgedPence - s.totalCollectedPence + const giftAidPledges = (dash?.pledges || []).filter((p: { giftAid: boolean; status: string }) => p.giftAid && p.status === "paid") + const giftAidTotal = giftAidPledges.reduce((sum: number, p: { amountPence: number }) => sum + p.amountPence, 0) + const giftAidReclaimable = Math.round(giftAidTotal * 0.25) return (
+ + {/* ── Header ── */}

Reports

-

Download data for your treasurer, trustees, or HMRC

+

Financial summary, Gift Aid, and data downloads for your treasurer and trustees

-
- {/* Full data download */} -
-
-

Full data download

-

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

+ {/* ── Financial summary — the big picture ── */} +
+

Financial Summary

+
+ {[ + { value: formatPence(s.totalPledgedPence), label: "Total promised", color: "text-white" }, + { value: formatPence(s.totalCollectedPence), label: "Total received", color: "text-[#4ADE80]" }, + { value: formatPence(outstanding), label: "Still outstanding", color: outstanding > 0 ? "text-[#FBBF24]" : "text-white" }, + { value: `${s.collectionRate}%`, label: "Collection rate", color: s.collectionRate >= 70 ? "text-[#4ADE80]" : "text-[#FBBF24]" }, + ].map(stat => ( +
+

{stat.value}

+

{stat.label}

+
+ ))} +
+ + {/* Progress bar */} +
+
+ {s.totalPledges} pledges total + {s.collectionRate}% collected
-
-

Donor name, email, phone

-

Amount and payment status

-

Payment method and reference

-

Appeal name and source

-

Gift Aid flag

+
+
+
+
+
+ + {/* ── Status breakdown ── */} +
+
+

How pledges are doing

+
+
+ {[ + { status: "paid", label: "Received ✓", count: byStatus.paid || 0, color: "bg-[#16A34A]" }, + { status: "new", label: "Waiting", count: byStatus.new || 0, color: "bg-gray-400" }, + { status: "initiated", label: "Said they paid", count: byStatus.initiated || 0, color: "bg-[#F59E0B]" }, + { status: "overdue", label: "Needs a nudge", count: byStatus.overdue || 0, color: "bg-[#DC2626]" }, + { status: "cancelled", label: "Cancelled", count: byStatus.cancelled || 0, color: "bg-gray-200" }, + ].filter(s => s.count > 0).map(s => { + const pct = dash?.summary?.totalPledges > 0 ? Math.round((s.count / dash.summary.totalPledges) * 100) : 0 + return ( +
+
+ {s.count} +
+
+
+
+
+ {s.label} +
+ {pct}% +
+ ) + })} +
+
+ + {/* ── By-appeal breakdown ── */} + {events.length > 0 && ( +
+
+

By appeal

+
+
+ + + + + + + + + + + + {events.map(ev => { + const rate = ev.totalPledged > 0 ? Math.round((ev.totalCollected / ev.totalPledged) * 100) : 0 + return ( + + + + + + + + ) + })} + + {events.length > 1 && ( + + + + + + + + + + )} +
AppealPledgesPromisedReceivedRate
{ev.name}{ev.pledgeCount}{formatPence(ev.totalPledged)}{formatPence(ev.totalCollected)} + = 70 ? "bg-[#16A34A]/10 text-[#16A34A]" : rate >= 40 ? "bg-[#F59E0B]/10 text-[#F59E0B]" : "bg-gray-100 text-gray-500"}`}> + {rate}% + +
Total{s.totalPledges}{formatPence(s.totalPledgedPence)}{formatPence(s.totalCollectedPence)} + {s.collectionRate}% +
+
+
+ )} + + {/* ── 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
- {/* Gift Aid report */} + {/* Gift Aid */}
-
-

Gift Aid report

-

HMRC-ready declarations for tax reclaim. Only includes donors who ticked Gift Aid.

+
+ +
+

Gift Aid report

+

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

+
-
-

Donor full name (required by HMRC)

-

Donation amount and date

-

Gift Aid declaration timestamp

-

Home address and postcode

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

{giftAidCount ?? "–"}

+

Eligible declarations

+
+
+

{formatPence(giftAidReclaimable)}

+

Reclaimable from HMRC

+
+

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

+
+
- {/* Connect to other tools */} -
+ {/* ── API / Zapier ── */} +
+
+

Connect to other tools

-

Use our API to pull data into Zapier, Make, or your own systems.

-
-
-

Reminder endpoint:

- - GET /api/webhooks?since=2025-01-01 - -

Returns pending reminders with donor contact info for external email or SMS.

-
-
-

- Connect to Zapier or Make to send automatic reminder emails -

+

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" })} + +
+ ))} +
+
+ )}
) } 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 6909dac..821c330 100644 --- a/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx @@ -1,14 +1,33 @@ "use client" import { useState, useEffect, useCallback } from "react" -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" -import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu" import { useToast } from "@/components/ui/toast" +import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu" import { Search, MoreVertical, CheckCircle2, XCircle, MessageCircle, Send, - ChevronLeft, ChevronRight, Loader2 + ChevronLeft, ChevronRight, Loader2, Upload, ArrowRight, AlertTriangle, + Clock } from "lucide-react" import Link from "next/link" +import { formatPence } from "@/lib/utils" + +/** + * /dashboard/money — "Where's the money?" + * + * Redesigned as a context-aware inbox. Instead of a flat table, + * the page surfaces what needs attention RIGHT NOW: + * + * 1. "Confirm these payments" — when people say they've paid + * 2. "These people need a nudge" — when pledges are overdue + * 3. Recent pledges — what just came in + * 4. Full table — for Fatima who wants to scan everything + * + * The key insight: Aaisha's #1 Money question changes over time. + * Day 1: "Did anyone pledge?" → Recent section + * Day 3: "Are they paying?" → Confirm section + * Day 10: "Who hasn't paid?" → Nudge section + * Day 30: "Give me the spreadsheet" → she goes to Reports + */ interface Pledge { id: string; reference: string; amountPence: number; status: string; rail: string @@ -19,9 +38,6 @@ interface Pledge { createdAt: string; paidAt: string | null } -/** - * Human status labels — no SaaS jargon - */ const STATUS: Record = { new: { label: "Waiting", color: "text-gray-600", bg: "bg-gray-100" }, initiated: { label: "Said they paid", color: "text-[#F59E0B]", bg: "bg-[#F59E0B]/10" }, @@ -30,8 +46,6 @@ const STATUS: Record = { cancelled: { label: "Cancelled", color: "text-gray-400", bg: "bg-gray-50" }, } -const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}` - function timeAgo(dateStr: string) { const days = Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000) if (days === 0) return "Today" @@ -40,66 +54,66 @@ function timeAgo(dateStr: string) { return new Date(dateStr).toLocaleDateString("en-GB", { day: "numeric", month: "short" }) } -function dueLabel(dueDate: string) { - const days = Math.ceil((new Date(dueDate).getTime() - Date.now()) / 86400000) - if (days < 0) return { text: `${Math.abs(days)}d overdue`, urgent: true } - if (days === 0) return { text: "Due today", urgent: true } - if (days === 1) return { text: "Due tomorrow", urgent: false } - if (days <= 7) return { text: `Due in ${days}d`, urgent: false } - return { text: new Date(dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "short" }), urgent: false } -} - export default function MoneyPage() { - const [pledges, setPledges] = useState([]) + const [allPledges, setAllPledges] = useState([]) + const [tablePledges, setTablePledges] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) - const [tab, setTab] = useState("all") + const [filter, setFilter] = useState("all") const [search, setSearch] = useState("") const [page, setPage] = useState(0) const [updating, setUpdating] = useState(null) const { toast } = useToast() const pageSize = 25 - const [stats, setStats] = useState({ total: 0, waiting: 0, overdue: 0, paid: 0, totalPledged: 0, totalCollected: 0 }) + const [stats, setStats] = useState({ + total: 0, newCount: 0, initiatedCount: 0, overdueCount: 0, paidCount: 0, + cancelledCount: 0, totalPledged: 0, totalCollected: 0 + }) - const fetchPledges = useCallback(async () => { + // Load top-level data (for contextual sections) + const loadDashboard = useCallback(async () => { + try { + const data = await fetch("/api/dashboard").then(r => r.json()) + if (data.summary) { + setStats({ + total: data.summary.totalPledges, + newCount: data.byStatus?.new || 0, + initiatedCount: data.byStatus?.initiated || 0, + overdueCount: data.byStatus?.overdue || 0, + paidCount: data.byStatus?.paid || 0, + cancelledCount: data.byStatus?.cancelled || 0, + totalPledged: data.summary.totalPledgedPence, + totalCollected: data.summary.totalCollectedPence, + }) + // Keep full list for contextual sections + if (data.pledges) setAllPledges(data.pledges) + } + } catch { /* */ } + }, []) + + // Load paginated table data + const loadTable = useCallback(async () => { const params = new URLSearchParams() params.set("limit", String(pageSize)) params.set("offset", String(page * pageSize)) - if (tab !== "all") { - if (tab === "overdue") params.set("overdue", "true") - else params.set("status", tab) + if (filter !== "all") { + if (filter === "overdue") params.set("overdue", "true") + else params.set("status", filter) } if (search) params.set("search", search) params.set("sort", "createdAt") params.set("dir", "desc") - const res = await fetch(`/api/pledges?${params}`) - const data = await res.json() - setPledges(data.pledges || []) + const data = await fetch(`/api/pledges?${params}`).then(r => r.json()) + setTablePledges(data.pledges || []) setTotal(data.total || 0) setLoading(false) - }, [tab, search, page]) + }, [filter, search, page]) - useEffect(() => { - fetch("/api/dashboard") - .then(r => r.json()) - .then(data => { - if (data.summary) { - setStats({ - total: data.summary.totalPledges, - waiting: (data.byStatus?.new || 0) + (data.byStatus?.initiated || 0), - overdue: data.byStatus?.overdue || 0, - paid: data.byStatus?.paid || 0, - totalPledged: data.summary.totalPledgedPence, - totalCollected: data.summary.totalCollectedPence, - }) - } - }).catch(() => {}) - }, []) - - useEffect(() => { fetchPledges() }, [fetchPledges]) - useEffect(() => { const i = setInterval(fetchPledges, 30000); return () => clearInterval(i) }, [fetchPledges]) + useEffect(() => { loadDashboard() }, [loadDashboard]) + useEffect(() => { loadTable() }, [loadTable]) + useEffect(() => { const i = setInterval(() => { loadDashboard(); loadTable() }, 30000); return () => clearInterval(i) }, [loadDashboard, loadTable]) const updateStatus = async (pledgeId: string, newStatus: string) => { setUpdating(pledgeId) @@ -109,8 +123,13 @@ export default function MoneyPage() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status: newStatus }), }) - setPledges(prev => prev.map(p => p.id === pledgeId ? { ...p, status: newStatus } : p)) - toast(`Updated`, "success") + // Update both lists + const updater = (p: Pledge) => p.id === pledgeId ? { ...p, status: newStatus } : p + setAllPledges(prev => prev.map(updater)) + setTablePledges(prev => prev.map(updater)) + toast("Updated", "success") + // Refresh stats + loadDashboard() } catch { toast("Failed to update", "error") } setUpdating(null) } @@ -133,198 +152,307 @@ export default function MoneyPage() { const collectionRate = stats.totalPledged > 0 ? Math.round((stats.totalCollected / stats.totalPledged) * 100) : 0 const totalPages = Math.ceil(total / pageSize) + // Contextual sections data + const saidPaid = allPledges.filter((p: Pledge) => p.status === "initiated") + const overdue = allPledges.filter((p: Pledge) => p.status === "overdue") + + if (loading) return
+ return (
- {/* Header */} + + {/* ── Header ── */}

Money

- {stats.total} pledges · {formatPence(stats.totalPledged)} promised · {collectionRate}% received + {formatPence(stats.totalCollected)} received of {formatPence(stats.totalPledged)} promised

-
- - { setSearch(e.target.value); setPage(0) }} - className="pl-9 w-64 h-9 border border-gray-200 text-sm focus:border-[#1E40AF] focus:ring-1 focus:ring-[#1E40AF]/20 outline-none transition-colors" - /> -
- {/* Quick stats — gap-px */} -
+ {/* ── Big numbers ── */} +
{[ - { label: "All", count: stats.total, onClick: () => setTab("all") }, - { label: "Waiting", count: stats.waiting, onClick: () => setTab("new") }, - { label: "Needs a nudge", count: stats.overdue, onClick: () => setTab("overdue"), alert: stats.overdue > 0 }, - { label: "Received", count: stats.paid, onClick: () => setTab("paid"), accent: true }, + { value: String(stats.total), label: "Total pledges", onClick: () => setFilter("all") }, + { value: String(stats.newCount), label: "Waiting", onClick: () => setFilter("new") }, + { value: String(stats.initiatedCount), label: "Said they paid", onClick: () => setFilter("initiated"), accent: stats.initiatedCount > 0 ? "text-[#F59E0B]" : undefined }, + { value: String(stats.overdueCount), label: "Need a nudge", onClick: () => setFilter("overdue"), accent: stats.overdueCount > 0 ? "text-[#DC2626]" : undefined }, + { value: String(stats.paidCount), label: "Received ✓", onClick: () => setFilter("paid"), accent: "text-[#16A34A]" }, ].map(s => ( - ))}
- {/* Collection bar */} + {/* ── Progress bar ── */}
-
+
- {formatPence(stats.totalCollected)} / {formatPence(stats.totalPledged)} + {collectionRate}%
- {/* Tabs + Table */} - { setTab(v); setPage(0) }}> - + {/* ── CONTEXTUAL SECTION: Confirm payments ── */} + {saidPaid.length > 0 && filter === "all" && !search && ( +
+
+
+ +

{saidPaid.length} {saidPaid.length === 1 ? "person says" : "people say"} they've paid

+
+ + Upload bank statement + +
+
+ {saidPaid.slice(0, 5).map((p: Pledge) => ( +
+
+
+

{p.donorName || "Anonymous"}

+ {p.reference} +
+

{formatPence(p.amountPence)} · {timeAgo(p.createdAt)}

+
+ +
+ ))} +
+ {saidPaid.length > 5 && ( +
+ +
+ )} +
+ )} + + {/* ── CONTEXTUAL SECTION: People who need a nudge ── */} + {overdue.length > 0 && filter === "all" && !search && ( +
+
+
+ +

{overdue.length} {overdue.length === 1 ? "pledge needs" : "pledges need"} a nudge

+
+
+
+ {overdue.slice(0, 5).map((p: Pledge) => { + const days = Math.floor((Date.now() - new Date(p.createdAt).getTime()) / 86400000) + return ( +
+
+
+

{p.donorName || "Anonymous"}

+ {days}d +
+

{formatPence(p.amountPence)} · {p.eventName}

+
+
+ {p.donorPhone && ( + + )} + +
+
+ ) + })} +
+ {overdue.length > 5 && ( +
+ +
+ )} +
+ )} + + {/* ── Search + filter bar ── */} +
+
+ + { 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 they paid" }, - { value: "overdue", label: "Needs a nudge" }, + { value: "initiated", label: "Said paid" }, + { value: "overdue", label: "Overdue" }, { value: "paid", label: "Received" }, - { value: "cancelled", label: "Cancelled" }, ].map(t => ( - + ))} - - - - {loading ? ( -
- ) : pledges.length === 0 ? ( -
-

No pledges found

-

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

-
- ) : ( -
- {/* Table header */} -
-
Donor
-
Amount
-
Appeal
-
Status
-
When
-
-
- - {/* Rows */} - {pledges.map(p => { - const sl = STATUS[p.status] || STATUS.new - const due = p.dueDate ? dueLabel(p.dueDate) : null - - return ( -
- {/* Donor */} -
-

{p.donorName || "Anonymous"}

-
- {p.reference} - {p.donorPhone && } -
-
- - {/* Amount */} -
-

{formatPence(p.amountPence)}

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

{p.installmentNumber}/{p.installmentTotal}

- )} -
- - {/* Appeal */} -
-

{p.eventName}

- {p.qrSourceLabel &&

{p.qrSourceLabel}

} -
- - {/* Status */} -
- {sl.label} -
- - {/* When */} -
- {due ? ( - {due.text} - ) : ( - {timeAgo(p.createdAt)} - )} -
- - {/* Actions */} -
- - - - - - {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}

-
- - -
-
- )} -
- - - {/* Match payments link */} -
- - Match bank payments → - -

Upload a bank statement to automatically match payments to pledges

+
+ + {/* ── Pledge table ── */} + {tablePledges.length === 0 ? ( +
+

No pledges found

+

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

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

{p.donorName || "Anonymous"}

+
+ {p.reference} + {p.donorPhone && } +
+
+ + {/* Amount */} +
+

{formatPence(p.amountPence)}

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

{p.installmentNumber}/{p.installmentTotal}

+ )} +
+ + {/* Appeal (desktop) */} +
+

{p.eventName}

+ {p.qrSourceLabel &&

{p.qrSourceLabel}

} +
+ + {/* Status */} +
+ {sl.label} +
+ + {/* When (desktop) */} +
+ {timeAgo(p.createdAt)} +
+ + {/* Actions */} +
+ + + + + + {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}

+
+ + +
+
+ )} + + {/* ── Match payments CTA ── */} + +
+ +
+

Match bank payments

+

Upload a bank statement CSV — we auto-detect Barclays, HSBC, Lloyds, NatWest, Monzo, Starling and more

+
+ +
+
) }