Telepathic Money + Reports: context-aware inbox, financial summary
## 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)
This commit is contained in:
@@ -1,96 +1,317 @@
|
|||||||
"use client"
|
"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() {
|
export default function ReportsPage() {
|
||||||
const handleCrmExport = () => {
|
const [loading, setLoading] = useState(true)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const [dash, setDash] = useState<any>(null)
|
||||||
|
const [events, setEvents] = useState<EventSummary[]>([])
|
||||||
|
const [giftAidCount, setGiftAidCount] = useState<number | null>(null)
|
||||||
|
const [activity, setActivity] = useState<ActivityEntry[]>([])
|
||||||
|
|
||||||
|
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")
|
const a = document.createElement("a")
|
||||||
a.href = "/api/exports/crm-pack"
|
a.href = giftAidOnly ? "/api/exports/crm-pack?giftAidOnly=true" : "/api/exports/crm-pack"
|
||||||
a.download = `pledges-export-${new Date().toISOString().slice(0, 10)}.csv`
|
a.download = giftAidOnly
|
||||||
|
? `gift-aid-declarations-${new Date().toISOString().slice(0, 10)}.csv`
|
||||||
|
: `pledges-export-${new Date().toISOString().slice(0, 10)}.csv`
|
||||||
a.click()
|
a.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGiftAidExport = () => {
|
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
|
||||||
const a = document.createElement("a")
|
|
||||||
a.href = "/api/exports/crm-pack?giftAidOnly=true"
|
const s = dash?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0 }
|
||||||
a.download = `gift-aid-declarations-${new Date().toISOString().slice(0, 10)}.csv`
|
const byStatus = dash?.byStatus || {}
|
||||||
a.click()
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Reports</h1>
|
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Reports</h1>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">Download data for your treasurer, trustees, or HMRC</p>
|
<p className="text-sm text-gray-500 mt-0.5">Financial summary, Gift Aid, and data downloads for your treasurer and trustees</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
{/* ── Financial summary — the big picture ── */}
|
||||||
{/* Full data download */}
|
<div className="bg-[#111827] p-6">
|
||||||
|
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-4">Financial Summary</p>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-px bg-gray-700">
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<div key={stat.label} className="bg-[#111827] p-4">
|
||||||
|
<p className={`text-2xl md:text-3xl font-black tracking-tight ${stat.color}`}>{stat.value}</p>
|
||||||
|
<p className="text-[10px] text-gray-500 mt-1">{stat.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-800">
|
||||||
|
<div className="flex justify-between text-xs text-gray-500 mb-2">
|
||||||
|
<span>{s.totalPledges} pledges total</span>
|
||||||
|
<span>{s.collectionRate}% collected</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-800 overflow-hidden">
|
||||||
|
<div className="h-full bg-[#4ADE80] transition-all duration-700" style={{ width: `${s.collectionRate}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Status breakdown ── */}
|
||||||
|
<div className="bg-white border border-gray-200">
|
||||||
|
<div className="border-b border-gray-100 px-5 py-3">
|
||||||
|
<h3 className="text-sm font-bold text-[#111827]">How pledges are doing</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 space-y-3">
|
||||||
|
{[
|
||||||
|
{ 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 (
|
||||||
|
<div key={s.status} className="flex items-center gap-3">
|
||||||
|
<div className="w-20 text-right">
|
||||||
|
<span className="text-xs font-bold text-[#111827]">{s.count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 h-4 bg-gray-50 overflow-hidden">
|
||||||
|
<div className={`h-full ${s.color} transition-all duration-500`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="w-28">
|
||||||
|
<span className="text-xs text-gray-600">{s.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-gray-400 w-10 text-right">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── By-appeal breakdown ── */}
|
||||||
|
{events.length > 0 && (
|
||||||
|
<div className="bg-white border border-gray-200">
|
||||||
|
<div className="border-b border-gray-100 px-5 py-3">
|
||||||
|
<h3 className="text-sm font-bold text-[#111827]">By appeal</h3>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100">
|
||||||
|
<th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Appeal</th>
|
||||||
|
<th className="px-5 py-2.5 text-right text-[10px] font-bold text-gray-400 uppercase tracking-wide">Pledges</th>
|
||||||
|
<th className="px-5 py-2.5 text-right text-[10px] font-bold text-gray-400 uppercase tracking-wide">Promised</th>
|
||||||
|
<th className="px-5 py-2.5 text-right text-[10px] font-bold text-gray-400 uppercase tracking-wide">Received</th>
|
||||||
|
<th className="px-5 py-2.5 text-right text-[10px] font-bold text-gray-400 uppercase tracking-wide">Rate</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{events.map(ev => {
|
||||||
|
const rate = ev.totalPledged > 0 ? Math.round((ev.totalCollected / ev.totalPledged) * 100) : 0
|
||||||
|
return (
|
||||||
|
<tr key={ev.id} className="hover:bg-gray-50/50">
|
||||||
|
<td className="px-5 py-3 text-sm font-medium text-[#111827]">{ev.name}</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-right text-[#111827]">{ev.pledgeCount}</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-right font-bold text-[#111827]">{formatPence(ev.totalPledged)}</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-right font-bold text-[#16A34A]">{formatPence(ev.totalCollected)}</td>
|
||||||
|
<td className="px-5 py-3 text-right">
|
||||||
|
<span className={`text-xs font-bold px-1.5 py-0.5 ${rate >= 70 ? "bg-[#16A34A]/10 text-[#16A34A]" : rate >= 40 ? "bg-[#F59E0B]/10 text-[#F59E0B]" : "bg-gray-100 text-gray-500"}`}>
|
||||||
|
{rate}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
{events.length > 1 && (
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t-2 border-gray-200 bg-gray-50/50">
|
||||||
|
<td className="px-5 py-3 text-sm font-bold text-[#111827]">Total</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-right font-bold text-[#111827]">{s.totalPledges}</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-right font-black text-[#111827]">{formatPence(s.totalPledgedPence)}</td>
|
||||||
|
<td className="px-5 py-3 text-sm text-right font-black text-[#16A34A]">{formatPence(s.totalCollectedPence)}</td>
|
||||||
|
<td className="px-5 py-3 text-right">
|
||||||
|
<span className="text-xs font-bold text-[#111827]">{s.collectionRate}%</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
)}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Downloads ── */}
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
{/* Full data */}
|
||||||
<div className="bg-white border border-gray-200 p-6 space-y-4">
|
<div className="bg-white border border-gray-200 p-6 space-y-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<FileText className="h-5 w-5 text-[#111827] mt-0.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-bold text-[#111827]">Full data download</h3>
|
<h3 className="text-base font-bold text-[#111827]">Full data download</h3>
|
||||||
<p className="text-xs text-gray-500 mt-1">Everything in one spreadsheet — donor details, amounts, statuses, attribution.</p>
|
<p className="text-xs text-gray-500 mt-1">Everything in one spreadsheet — donor details, amounts, statuses, attribution, consent flags.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-l-2 border-[#111827] pl-3 space-y-1 text-xs text-gray-600">
|
</div>
|
||||||
<p>Donor name, email, phone</p>
|
<div className="border-l-2 border-[#111827] pl-3 grid grid-cols-2 gap-1 text-xs text-gray-600">
|
||||||
<p>Amount and payment status</p>
|
<span>Donor name, email, phone</span>
|
||||||
<p>Payment method and reference</p>
|
<span>Amount and payment status</span>
|
||||||
<p>Appeal name and source</p>
|
<span>Payment method and reference</span>
|
||||||
<p>Gift Aid flag</p>
|
<span>Appeal name and source</span>
|
||||||
|
<span>Gift Aid and Zakat flags</span>
|
||||||
|
<span>Days to collect</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleCrmExport}
|
onClick={() => downloadCsv(false)}
|
||||||
className="w-full bg-[#111827] px-4 py-2.5 text-sm font-bold text-white hover:bg-gray-800 transition-colors flex items-center justify-center gap-2"
|
className="w-full bg-[#111827] px-4 py-2.5 text-sm font-bold text-white hover:bg-gray-800 transition-colors flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" /> Download CSV
|
<Download className="h-4 w-4" /> Download CSV
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gift Aid report */}
|
{/* Gift Aid */}
|
||||||
<div className="bg-white border border-[#16A34A]/30 p-6 space-y-4">
|
<div className="bg-white border border-[#16A34A]/30 p-6 space-y-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Shield className="h-5 w-5 text-[#16A34A] mt-0.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-bold text-[#111827]">Gift Aid report</h3>
|
<h3 className="text-base font-bold text-[#111827]">Gift Aid report</h3>
|
||||||
<p className="text-xs text-gray-500 mt-1">HMRC-ready declarations for tax reclaim. Only includes donors who ticked Gift Aid.</p>
|
<p className="text-xs text-gray-500 mt-1">HMRC-ready declarations for tax reclaim. Only donors who ticked Gift Aid and whose payment was received.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-l-2 border-[#16A34A] pl-3 space-y-1 text-xs text-gray-600">
|
|
||||||
<p>Donor full name (required by HMRC)</p>
|
|
||||||
<p>Donation amount and date</p>
|
|
||||||
<p>Gift Aid declaration timestamp</p>
|
|
||||||
<p>Home address and postcode</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Gift Aid preview */}
|
||||||
|
<div className="grid grid-cols-2 gap-px bg-gray-200">
|
||||||
|
<div className="bg-[#16A34A]/5 p-3">
|
||||||
|
<p className="text-lg font-black text-[#16A34A]">{giftAidCount ?? "–"}</p>
|
||||||
|
<p className="text-[10px] text-gray-600">Eligible declarations</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#16A34A]/5 p-3">
|
||||||
|
<p className="text-lg font-black text-[#16A34A]">{formatPence(giftAidReclaimable)}</p>
|
||||||
|
<p className="text-[10px] text-gray-600">Reclaimable from HMRC</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-[#16A34A]/5 border border-[#16A34A]/20 p-3">
|
<div className="bg-[#16A34A]/5 border border-[#16A34A]/20 p-3">
|
||||||
<p className="text-xs text-[#16A34A] font-bold">
|
<p className="text-xs text-[#16A34A] font-bold">
|
||||||
Claim 25p for every £1 donated by a UK taxpayer
|
Claim 25p for every £1 donated by a UK taxpayer
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleGiftAidExport}
|
onClick={() => downloadCsv(true)}
|
||||||
className="w-full bg-[#16A34A] px-4 py-2.5 text-sm font-bold text-white hover:bg-[#16A34A]/90 transition-colors flex items-center justify-center gap-2"
|
className="w-full bg-[#16A34A] px-4 py-2.5 text-sm font-bold text-white hover:bg-[#16A34A]/90 transition-colors flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" /> Download Gift Aid Report
|
<Download className="h-4 w-4" /> Download Gift Aid Report
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Connect to other tools */}
|
{/* ── API / Zapier ── */}
|
||||||
<div className="bg-white border border-gray-200 p-6 space-y-4">
|
<div className="bg-white border border-gray-200 p-6 space-y-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Zap className="h-5 w-5 text-[#1E40AF] mt-0.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-bold text-[#111827]">Connect to other tools</h3>
|
<h3 className="text-base font-bold text-[#111827]">Connect to other tools</h3>
|
||||||
<p className="text-xs text-gray-500 mt-1">Use our API to pull data into Zapier, Make, or your own systems.</p>
|
<p className="text-xs text-gray-500 mt-1">Pull data into Zapier, Make, or your own systems using our API.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-bold text-gray-600">Pledges endpoint</p>
|
||||||
|
<code className="block bg-[#F9FAFB] p-3 text-[11px] font-mono break-all border border-gray-100">GET /api/pledges?status=new&sort=createdAt</code>
|
||||||
|
<p className="text-[10px] text-gray-500">Returns all pledges with donor contact info, filterable by status.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-bold text-gray-600">Reminder endpoint:</p>
|
<p className="text-xs font-bold text-gray-600">Dashboard endpoint</p>
|
||||||
<code className="block bg-gray-50 p-3 text-[11px] font-mono break-all border border-gray-100">
|
<code className="block bg-[#F9FAFB] p-3 text-[11px] font-mono break-all border border-gray-100">GET /api/dashboard</code>
|
||||||
GET /api/webhooks?since=2025-01-01
|
<p className="text-[10px] text-gray-500">Returns summary stats, status breakdown, top sources, and all pledges.</p>
|
||||||
</code>
|
</div>
|
||||||
<p className="text-[10px] text-gray-500">Returns pending reminders with donor contact info for external email or SMS.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[#1E40AF]/5 border border-[#1E40AF]/20 p-3">
|
<div className="bg-[#1E40AF]/5 border border-[#1E40AF]/20 p-3">
|
||||||
<p className="text-xs text-[#1E40AF] font-bold">
|
<p className="text-xs text-[#1E40AF] font-bold">
|
||||||
Connect to Zapier or Make to send automatic reminder emails
|
Connect to Zapier or Make to send automatic reminder emails to donors without WhatsApp
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Activity log ── */}
|
||||||
|
{activity.length > 0 && (
|
||||||
|
<div className="bg-white border border-gray-200">
|
||||||
|
<div className="border-b border-gray-100 px-5 py-3 flex items-center gap-1.5">
|
||||||
|
<Activity className="h-4 w-4 text-gray-400" />
|
||||||
|
<h3 className="text-sm font-bold text-[#111827]">Recent activity</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="divide-y divide-gray-50 max-h-64 overflow-y-auto">
|
||||||
|
{activity.map((a, i) => (
|
||||||
|
<div key={a.id || i} className="px-5 py-2.5 flex items-center gap-3">
|
||||||
|
<div className="w-1.5 h-1.5 bg-gray-300 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs text-gray-600 truncate">{a.description || a.action}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-gray-400 shrink-0">
|
||||||
|
{new Date(a.timestamp).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,33 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react"
|
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 { useToast } from "@/components/ui/toast"
|
||||||
|
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"
|
||||||
import {
|
import {
|
||||||
Search, MoreVertical, CheckCircle2, XCircle, MessageCircle, Send,
|
Search, MoreVertical, CheckCircle2, XCircle, MessageCircle, Send,
|
||||||
ChevronLeft, ChevronRight, Loader2
|
ChevronLeft, ChevronRight, Loader2, Upload, ArrowRight, AlertTriangle,
|
||||||
|
Clock
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Link from "next/link"
|
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 {
|
interface Pledge {
|
||||||
id: string; reference: string; amountPence: number; status: string; rail: string
|
id: string; reference: string; amountPence: number; status: string; rail: string
|
||||||
@@ -19,9 +38,6 @@ interface Pledge {
|
|||||||
createdAt: string; paidAt: string | null
|
createdAt: string; paidAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Human status labels — no SaaS jargon
|
|
||||||
*/
|
|
||||||
const STATUS: Record<string, { label: string; color: string; bg: string }> = {
|
const STATUS: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
new: { label: "Waiting", color: "text-gray-600", bg: "bg-gray-100" },
|
new: { label: "Waiting", color: "text-gray-600", bg: "bg-gray-100" },
|
||||||
initiated: { label: "Said they paid", color: "text-[#F59E0B]", bg: "bg-[#F59E0B]/10" },
|
initiated: { label: "Said they paid", color: "text-[#F59E0B]", bg: "bg-[#F59E0B]/10" },
|
||||||
@@ -30,8 +46,6 @@ const STATUS: Record<string, { label: string; color: string; bg: string }> = {
|
|||||||
cancelled: { label: "Cancelled", color: "text-gray-400", bg: "bg-gray-50" },
|
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) {
|
function timeAgo(dateStr: string) {
|
||||||
const days = Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000)
|
const days = Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000)
|
||||||
if (days === 0) return "Today"
|
if (days === 0) return "Today"
|
||||||
@@ -40,66 +54,66 @@ function timeAgo(dateStr: string) {
|
|||||||
return new Date(dateStr).toLocaleDateString("en-GB", { day: "numeric", month: "short" })
|
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() {
|
export default function MoneyPage() {
|
||||||
const [pledges, setPledges] = useState<Pledge[]>([])
|
const [allPledges, setAllPledges] = useState<Pledge[]>([])
|
||||||
|
const [tablePledges, setTablePledges] = useState<Pledge[]>([])
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [tab, setTab] = useState("all")
|
const [filter, setFilter] = useState("all")
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const [updating, setUpdating] = useState<string | null>(null)
|
const [updating, setUpdating] = useState<string | null>(null)
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const pageSize = 25
|
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()
|
const params = new URLSearchParams()
|
||||||
params.set("limit", String(pageSize))
|
params.set("limit", String(pageSize))
|
||||||
params.set("offset", String(page * pageSize))
|
params.set("offset", String(page * pageSize))
|
||||||
if (tab !== "all") {
|
if (filter !== "all") {
|
||||||
if (tab === "overdue") params.set("overdue", "true")
|
if (filter === "overdue") params.set("overdue", "true")
|
||||||
else params.set("status", tab)
|
else params.set("status", filter)
|
||||||
}
|
}
|
||||||
if (search) params.set("search", search)
|
if (search) params.set("search", search)
|
||||||
params.set("sort", "createdAt")
|
params.set("sort", "createdAt")
|
||||||
params.set("dir", "desc")
|
params.set("dir", "desc")
|
||||||
|
|
||||||
const res = await fetch(`/api/pledges?${params}`)
|
const data = await fetch(`/api/pledges?${params}`).then(r => r.json())
|
||||||
const data = await res.json()
|
setTablePledges(data.pledges || [])
|
||||||
setPledges(data.pledges || [])
|
|
||||||
setTotal(data.total || 0)
|
setTotal(data.total || 0)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}, [tab, search, page])
|
}, [filter, search, page])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { loadDashboard() }, [loadDashboard])
|
||||||
fetch("/api/dashboard")
|
useEffect(() => { loadTable() }, [loadTable])
|
||||||
.then(r => r.json())
|
useEffect(() => { const i = setInterval(() => { loadDashboard(); loadTable() }, 30000); return () => clearInterval(i) }, [loadDashboard, loadTable])
|
||||||
.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])
|
|
||||||
|
|
||||||
const updateStatus = async (pledgeId: string, newStatus: string) => {
|
const updateStatus = async (pledgeId: string, newStatus: string) => {
|
||||||
setUpdating(pledgeId)
|
setUpdating(pledgeId)
|
||||||
@@ -109,8 +123,13 @@ export default function MoneyPage() {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ status: newStatus }),
|
body: JSON.stringify({ status: newStatus }),
|
||||||
})
|
})
|
||||||
setPledges(prev => prev.map(p => p.id === pledgeId ? { ...p, status: newStatus } : p))
|
// Update both lists
|
||||||
toast(`Updated`, "success")
|
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") }
|
} catch { toast("Failed to update", "error") }
|
||||||
setUpdating(null)
|
setUpdating(null)
|
||||||
}
|
}
|
||||||
@@ -133,96 +152,207 @@ export default function MoneyPage() {
|
|||||||
const collectionRate = stats.totalPledged > 0 ? Math.round((stats.totalCollected / stats.totalPledged) * 100) : 0
|
const collectionRate = stats.totalPledged > 0 ? Math.round((stats.totalCollected / stats.totalPledged) * 100) : 0
|
||||||
const totalPages = Math.ceil(total / pageSize)
|
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 <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
|
||||||
|
{/* ── Header ── */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Money</h1>
|
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Money</h1>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
{stats.total} pledges · {formatPence(stats.totalPledged)} promised · {collectionRate}% received
|
{formatPence(stats.totalCollected)} received of {formatPence(stats.totalPledged)} promised
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
placeholder="Search name, email, reference..."
|
|
||||||
value={search}
|
|
||||||
onChange={e => { 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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick stats — gap-px */}
|
{/* ── Big numbers ── */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-px bg-gray-200">
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-px bg-gray-200">
|
||||||
{[
|
{[
|
||||||
{ label: "All", count: stats.total, onClick: () => setTab("all") },
|
{ value: String(stats.total), label: "Total pledges", onClick: () => setFilter("all") },
|
||||||
{ label: "Waiting", count: stats.waiting, onClick: () => setTab("new") },
|
{ value: String(stats.newCount), label: "Waiting", onClick: () => setFilter("new") },
|
||||||
{ label: "Needs a nudge", count: stats.overdue, onClick: () => setTab("overdue"), alert: stats.overdue > 0 },
|
{ value: String(stats.initiatedCount), label: "Said they paid", onClick: () => setFilter("initiated"), accent: stats.initiatedCount > 0 ? "text-[#F59E0B]" : undefined },
|
||||||
{ label: "Received", count: stats.paid, onClick: () => setTab("paid"), accent: true },
|
{ 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 => (
|
].map(s => (
|
||||||
<button key={s.label} onClick={s.onClick} className={`bg-white p-4 text-left hover:bg-gray-50 transition-colors ${tab === (s.label === "All" ? "all" : s.label === "Waiting" ? "new" : s.label === "Received" ? "paid" : "overdue") ? "border-b-2 border-[#1E40AF]" : ""}`}>
|
<button
|
||||||
<p className={`text-xl font-black ${s.alert ? "text-[#DC2626]" : s.accent ? "text-[#16A34A]" : "text-[#111827]"}`}>{s.count}</p>
|
key={s.label}
|
||||||
|
onClick={() => { s.onClick(); setPage(0) }}
|
||||||
|
className={`bg-white p-4 text-left hover:bg-gray-50 transition-colors ${filter === (s.label === "Total pledges" ? "all" : s.label === "Waiting" ? "new" : s.label === "Said they paid" ? "initiated" : s.label === "Need a nudge" ? "overdue" : "paid") ? "border-b-2 border-[#1E40AF]" : ""}`}
|
||||||
|
>
|
||||||
|
<p className={`text-xl font-black ${s.accent || "text-[#111827]"}`}>{s.value}</p>
|
||||||
<p className="text-[10px] text-gray-500 mt-0.5">{s.label}</p>
|
<p className="text-[10px] text-gray-500 mt-0.5">{s.label}</p>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Collection bar */}
|
{/* ── Progress bar ── */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex-1 h-2 bg-gray-100 overflow-hidden">
|
<div className="flex-1 h-2 bg-gray-100 overflow-hidden">
|
||||||
<div className="h-full bg-[#1E40AF] transition-all" style={{ width: `${collectionRate}%` }} />
|
<div className="h-full bg-[#1E40AF] transition-all duration-700" style={{ width: `${collectionRate}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-bold text-gray-500 whitespace-nowrap">{formatPence(stats.totalCollected)} / {formatPence(stats.totalPledged)}</span>
|
<span className="text-xs font-bold text-[#111827] whitespace-nowrap">{collectionRate}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs + Table */}
|
{/* ── CONTEXTUAL SECTION: Confirm payments ── */}
|
||||||
<Tabs value={tab} onValueChange={v => { setTab(v); setPage(0) }}>
|
{saidPaid.length > 0 && filter === "all" && !search && (
|
||||||
<TabsList className="w-full sm:w-auto overflow-x-auto bg-transparent border-b border-gray-200 rounded-none p-0 h-auto">
|
<div className="border-l-2 border-[#F59E0B] bg-[#F59E0B]/5">
|
||||||
|
<div className="px-5 py-3 flex items-center justify-between border-b border-[#F59E0B]/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-[#F59E0B]" />
|
||||||
|
<h3 className="text-sm font-bold text-[#111827]">{saidPaid.length} {saidPaid.length === 1 ? "person says" : "people say"} they've paid</h3>
|
||||||
|
</div>
|
||||||
|
<Link href="/dashboard/reconcile" className="text-xs font-semibold text-[#1E40AF] hover:underline flex items-center gap-1">
|
||||||
|
<Upload className="h-3 w-3" /> Upload bank statement
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-[#F59E0B]/10">
|
||||||
|
{saidPaid.slice(0, 5).map((p: Pledge) => (
|
||||||
|
<div key={p.id} className={`px-5 py-3 flex items-center gap-3 ${updating === p.id ? "opacity-50" : ""}`}>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium text-[#111827] truncate">{p.donorName || "Anonymous"}</p>
|
||||||
|
<code className="text-[9px] text-gray-400 font-mono">{p.reference}</code>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">{formatPence(p.amountPence)} · {timeAgo(p.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus(p.id, "paid")}
|
||||||
|
disabled={updating === p.id}
|
||||||
|
className="bg-[#16A34A] px-3 py-1.5 text-xs font-bold text-white hover:bg-[#16A34A]/90 transition-colors flex items-center gap-1.5 shrink-0"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" /> Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{saidPaid.length > 5 && (
|
||||||
|
<div className="px-5 py-2 border-t border-[#F59E0B]/10">
|
||||||
|
<button onClick={() => { setFilter("initiated"); setPage(0) }} className="text-xs font-semibold text-[#1E40AF] hover:underline">
|
||||||
|
View all {saidPaid.length} →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── CONTEXTUAL SECTION: People who need a nudge ── */}
|
||||||
|
{overdue.length > 0 && filter === "all" && !search && (
|
||||||
|
<div className="border-l-2 border-[#DC2626] bg-[#DC2626]/5">
|
||||||
|
<div className="px-5 py-3 flex items-center justify-between border-b border-[#DC2626]/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-[#DC2626]" />
|
||||||
|
<h3 className="text-sm font-bold text-[#111827]">{overdue.length} {overdue.length === 1 ? "pledge needs" : "pledges need"} a nudge</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-[#DC2626]/10">
|
||||||
|
{overdue.slice(0, 5).map((p: Pledge) => {
|
||||||
|
const days = Math.floor((Date.now() - new Date(p.createdAt).getTime()) / 86400000)
|
||||||
|
return (
|
||||||
|
<div key={p.id} className={`px-5 py-3 flex items-center gap-3 ${updating === p.id ? "opacity-50" : ""}`}>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium text-[#111827] truncate">{p.donorName || "Anonymous"}</p>
|
||||||
|
<span className="text-[9px] font-bold text-[#DC2626]">{days}d</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">{formatPence(p.amountPence)} · {p.eventName}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5 shrink-0">
|
||||||
|
{p.donorPhone && (
|
||||||
|
<button
|
||||||
|
onClick={() => sendReminder(p)}
|
||||||
|
className="bg-[#25D366] px-3 py-1.5 text-xs font-bold text-white hover:bg-[#25D366]/90 transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-3 w-3" /> Nudge
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus(p.id, "paid")}
|
||||||
|
disabled={updating === p.id}
|
||||||
|
className="border border-gray-200 px-2.5 py-1.5 text-xs font-bold text-[#111827] hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Paid
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{overdue.length > 5 && (
|
||||||
|
<div className="px-5 py-2 border-t border-[#DC2626]/10">
|
||||||
|
<button onClick={() => { setFilter("overdue"); setPage(0) }} className="text-xs font-semibold text-[#1E40AF] hover:underline">
|
||||||
|
View all {overdue.length} →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Search + filter bar ── */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
placeholder="Search name, email, reference..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => { 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5 overflow-x-auto">
|
||||||
{[
|
{[
|
||||||
{ value: "all", label: "All" },
|
{ value: "all", label: "All" },
|
||||||
{ value: "new", label: "Waiting" },
|
{ value: "new", label: "Waiting" },
|
||||||
{ value: "initiated", label: "Said they paid" },
|
{ value: "initiated", label: "Said paid" },
|
||||||
{ value: "overdue", label: "Needs a nudge" },
|
{ value: "overdue", label: "Overdue" },
|
||||||
{ value: "paid", label: "Received" },
|
{ value: "paid", label: "Received" },
|
||||||
{ value: "cancelled", label: "Cancelled" },
|
|
||||||
].map(t => (
|
].map(t => (
|
||||||
<TabsTrigger key={t.value} value={t.value} className="rounded-none border-b-2 border-transparent data-[state=active]:border-[#1E40AF] data-[state=active]:bg-transparent data-[state=active]:shadow-none text-xs font-semibold px-3 py-2">
|
<button
|
||||||
|
key={t.value}
|
||||||
|
onClick={() => { setFilter(t.value); setPage(0) }}
|
||||||
|
className={`px-3 py-2 text-xs font-bold whitespace-nowrap transition-colors ${
|
||||||
|
filter === t.value
|
||||||
|
? "bg-[#111827] text-white"
|
||||||
|
: "border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
</TabsTrigger>
|
</button>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<TabsContent value={tab}>
|
{/* ── Pledge table ── */}
|
||||||
{loading ? (
|
{tablePledges.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
|
<div className="text-center py-12">
|
||||||
) : pledges.length === 0 ? (
|
|
||||||
<div className="text-center py-16">
|
|
||||||
<p className="text-sm font-medium text-[#111827]">No pledges found</p>
|
<p className="text-sm font-medium text-[#111827]">No pledges found</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">{search ? `No results for "${search}"` : "Share your pledge links to start collecting"}</p>
|
<p className="text-xs text-gray-500 mt-1">{search ? `No results for "${search}"` : "Share your pledge links to start collecting"}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white">
|
<div className="bg-white border border-gray-200">
|
||||||
{/* Table header */}
|
{/* Header */}
|
||||||
<div className="grid grid-cols-12 gap-2 px-5 py-2.5 border-b border-gray-100 text-[10px] font-bold text-gray-400 uppercase tracking-wide">
|
<div className="hidden md:grid grid-cols-12 gap-2 px-5 py-2.5 border-b border-gray-100 text-[10px] font-bold text-gray-400 uppercase tracking-wide">
|
||||||
<div className="col-span-4">Donor</div>
|
<div className="col-span-4">Donor</div>
|
||||||
<div className="col-span-2">Amount</div>
|
<div className="col-span-2">Amount</div>
|
||||||
<div className="col-span-2 hidden md:block">Appeal</div>
|
<div className="col-span-2">Appeal</div>
|
||||||
<div className="col-span-2">Status</div>
|
<div className="col-span-2">Status</div>
|
||||||
<div className="col-span-1 hidden sm:block">When</div>
|
<div className="col-span-1">When</div>
|
||||||
<div className="col-span-1"></div>
|
<div className="col-span-1"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rows */}
|
{/* Rows */}
|
||||||
{pledges.map(p => {
|
{tablePledges.map((p: Pledge) => {
|
||||||
const sl = STATUS[p.status] || STATUS.new
|
const sl = STATUS[p.status] || STATUS.new
|
||||||
const due = p.dueDate ? dueLabel(p.dueDate) : null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={p.id} className={`grid grid-cols-12 gap-2 px-5 py-3 border-b border-gray-50 items-center hover:bg-gray-50/50 transition-colors ${updating === p.id ? "opacity-50" : ""}`}>
|
<div key={p.id} className={`grid grid-cols-12 gap-2 px-5 py-3 border-b border-gray-50 items-center hover:bg-gray-50/50 transition-colors ${updating === p.id ? "opacity-50" : ""}`}>
|
||||||
{/* Donor */}
|
{/* Donor */}
|
||||||
<div className="col-span-4">
|
<div className="col-span-6 md:col-span-4">
|
||||||
<p className="text-sm font-medium text-[#111827] truncate">{p.donorName || "Anonymous"}</p>
|
<p className="text-sm font-medium text-[#111827] truncate">{p.donorName || "Anonymous"}</p>
|
||||||
<div className="flex items-center gap-1.5 mt-0.5">
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
<code className="text-[10px] text-gray-400 font-mono">{p.reference}</code>
|
<code className="text-[10px] text-gray-400 font-mono">{p.reference}</code>
|
||||||
@@ -231,7 +361,7 @@ export default function MoneyPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Amount */}
|
{/* Amount */}
|
||||||
<div className="col-span-2">
|
<div className="col-span-3 md:col-span-2">
|
||||||
<p className="text-sm font-black text-[#111827]">{formatPence(p.amountPence)}</p>
|
<p className="text-sm font-black text-[#111827]">{formatPence(p.amountPence)}</p>
|
||||||
{p.giftAid && <span className="text-[9px] text-[#16A34A] font-bold">+Gift Aid</span>}
|
{p.giftAid && <span className="text-[9px] text-[#16A34A] font-bold">+Gift Aid</span>}
|
||||||
{p.installmentTotal && p.installmentTotal > 1 && (
|
{p.installmentTotal && p.installmentTotal > 1 && (
|
||||||
@@ -239,7 +369,7 @@ export default function MoneyPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Appeal */}
|
{/* Appeal (desktop) */}
|
||||||
<div className="col-span-2 hidden md:block">
|
<div className="col-span-2 hidden md:block">
|
||||||
<p className="text-xs text-gray-600 truncate">{p.eventName}</p>
|
<p className="text-xs text-gray-600 truncate">{p.eventName}</p>
|
||||||
{p.qrSourceLabel && <p className="text-[10px] text-gray-400 truncate">{p.qrSourceLabel}</p>}
|
{p.qrSourceLabel && <p className="text-[10px] text-gray-400 truncate">{p.qrSourceLabel}</p>}
|
||||||
@@ -250,13 +380,9 @@ export default function MoneyPage() {
|
|||||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 inline-block ${sl.bg} ${sl.color}`}>{sl.label}</span>
|
<span className={`text-[10px] font-bold px-1.5 py-0.5 inline-block ${sl.bg} ${sl.color}`}>{sl.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* When */}
|
{/* When (desktop) */}
|
||||||
<div className="col-span-1 hidden sm:block">
|
<div className="col-span-1 hidden md:block">
|
||||||
{due ? (
|
|
||||||
<span className={`text-xs ${due.urgent ? "font-bold text-[#DC2626]" : "text-gray-500"}`}>{due.text}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-gray-500">{timeAgo(p.createdAt)}</span>
|
<span className="text-xs text-gray-500">{timeAgo(p.createdAt)}</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@@ -301,9 +427,9 @@ export default function MoneyPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* ── Pagination ── */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between pt-3">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-gray-500">{page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total}</p>
|
<p className="text-xs text-gray-500">{page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total}</p>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button disabled={page === 0} onClick={() => setPage(p => p - 1)} className="border border-gray-200 p-1.5 disabled:opacity-30 hover:bg-gray-50">
|
<button disabled={page === 0} onClick={() => setPage(p => p - 1)} className="border border-gray-200 p-1.5 disabled:opacity-30 hover:bg-gray-50">
|
||||||
@@ -315,16 +441,18 @@ export default function MoneyPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{/* Match payments link */}
|
{/* ── Match payments CTA ── */}
|
||||||
<div className="border-l-2 border-[#1E40AF] pl-4 py-2">
|
<Link href="/dashboard/reconcile">
|
||||||
<Link href="/dashboard/reconcile" className="text-sm font-bold text-[#111827] hover:text-[#1E40AF] transition-colors">
|
<div className="border-l-2 border-[#1E40AF] bg-[#1E40AF]/5 p-4 flex items-center gap-3 hover:bg-[#1E40AF]/10 transition-colors cursor-pointer">
|
||||||
Match bank payments →
|
<Upload className="h-5 w-5 text-[#1E40AF] shrink-0" />
|
||||||
</Link>
|
<div className="flex-1">
|
||||||
<p className="text-xs text-gray-500">Upload a bank statement to automatically match payments to pledges</p>
|
<p className="text-sm font-bold text-[#111827]">Match bank payments</p>
|
||||||
|
<p className="text-xs text-gray-600">Upload a bank statement CSV — we auto-detect Barclays, HSBC, Lloyds, NatWest, Monzo, Starling and more</p>
|
||||||
</div>
|
</div>
|
||||||
|
<ArrowRight className="h-4 w-4 text-[#1E40AF] shrink-0" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user