Landing page philosophy across ALL dashboard pages
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
This commit is contained in:
@@ -219,120 +219,171 @@ export default function ReportsPage() {
|
||||
</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="flex items-start gap-3">
|
||||
<FileText className="h-5 w-5 text-[#111827] mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<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, consent flags.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-l-2 border-[#111827] pl-3 grid grid-cols-2 gap-1 text-xs text-gray-600">
|
||||
<span>Donor name, email, phone</span>
|
||||
<span>Amount and payment status</span>
|
||||
<span>Payment method and reference</span>
|
||||
<span>Appeal name and source</span>
|
||||
<span>Gift Aid and Zakat flags</span>
|
||||
<span>Days to collect</span>
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Download CSV
|
||||
</button>
|
||||
</div>
|
||||
{/* ━━ TWO-COLUMN: Downloads left, Education right ━━━━━━ */}
|
||||
<div className="grid lg:grid-cols-12 gap-6">
|
||||
|
||||
{/* Gift Aid */}
|
||||
<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>
|
||||
<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 donors who ticked Gift Aid and whose payment was received.</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">
|
||||
<p className="text-xs text-[#16A34A] font-bold">
|
||||
Claim 25p for every £1 donated by a UK taxpayer
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Download Gift Aid Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── API / Zapier ── */}
|
||||
<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>
|
||||
<h3 className="text-base font-bold text-[#111827]">Connect to other tools</h3>
|
||||
<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 className="space-y-2">
|
||||
<p className="text-xs font-bold text-gray-600">Dashboard endpoint</p>
|
||||
<code className="block bg-[#F9FAFB] p-3 text-[11px] font-mono break-all border border-gray-100">GET /api/dashboard</code>
|
||||
<p className="text-[10px] text-gray-500">Returns summary stats, status breakdown, top sources, and all pledges.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#1E40AF]/5 border border-[#1E40AF]/20 p-3">
|
||||
<p className="text-xs text-[#1E40AF] font-bold">
|
||||
Connect to Zapier or Make to send automatic reminder emails to donors without WhatsApp
|
||||
</p>
|
||||
</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 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>
|
||||
{/* LEFT: Downloads + Data */}
|
||||
<div className="lg:col-span-7 space-y-4">
|
||||
{/* Full data */}
|
||||
<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>
|
||||
<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, consent flags.</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-l-2 border-[#111827] pl-3 grid grid-cols-2 gap-1 text-xs text-gray-600">
|
||||
<span>Donor name, email, phone</span>
|
||||
<span>Amount and payment status</span>
|
||||
<span>Payment method and reference</span>
|
||||
<span>Appeal name and source</span>
|
||||
<span>Gift Aid and Zakat flags</span>
|
||||
<span>Days to collect</span>
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Download CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Gift Aid */}
|
||||
<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>
|
||||
<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 donors who ticked Gift Aid and whose payment was received.</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>
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Download Gift Aid Report
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* API / Zapier */}
|
||||
<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>
|
||||
<h3 className="text-base font-bold text-[#111827]">Connect to other tools</h3>
|
||||
<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 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>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-bold text-gray-600">Dashboard endpoint</p>
|
||||
<code className="block bg-[#F9FAFB] p-3 text-[11px] font-mono break-all border border-gray-100">GET /api/dashboard</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RIGHT: Education + Context */}
|
||||
<div className="lg:col-span-5 space-y-6">
|
||||
|
||||
{/* What your treasurer needs */}
|
||||
<div className="border border-gray-200 bg-white">
|
||||
<div className="border-b border-gray-100 px-5 py-3">
|
||||
<h3 className="text-sm font-bold text-[#111827]">For your treasurer</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{[
|
||||
{ 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 => (
|
||||
<div key={s.n} className="px-5 py-3 flex gap-3">
|
||||
<span className="text-lg font-black text-gray-200 shrink-0 w-6">{s.n}</span>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-[#111827]">{s.title}</p>
|
||||
<p className="text-[11px] text-gray-500 leading-relaxed mt-0.5">{s.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Understanding Gift Aid */}
|
||||
<div className="border-l-2 border-[#16A34A] pl-4 space-y-2">
|
||||
<p className="text-xs font-bold text-[#111827]">Understanding Gift Aid</p>
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ 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 => (
|
||||
<div key={item.q}>
|
||||
<p className="text-[11px] font-bold text-[#111827]">{item.q}</p>
|
||||
<p className="text-[10px] text-gray-500">{item.a}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Understanding your collection rate */}
|
||||
<div className="border-l-2 border-[#1E40AF] pl-4 space-y-2">
|
||||
<p className="text-xs font-bold text-[#111827]">Understanding your numbers</p>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[10px] text-gray-500">
|
||||
<strong className="text-[#111827]">Collection rate</strong> — what percentage of promised money has actually arrived. 70%+ is excellent for pledge-based fundraising.
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-500">
|
||||
<strong className="text-[#111827]">Still outstanding</strong> — money promised but not yet received. Reminders are sent automatically for these.
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-500">
|
||||
<strong className="text-[#111827]">Per-appeal breakdown</strong> — compare which events or campaigns collected best. Use this to plan your next fundraiser.
|
||||
</p>
|
||||
</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 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -235,10 +235,46 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Has pledges: Stats + Feed ── */}
|
||||
{/* ── Empty state: educational guidance ── */}
|
||||
{isEmpty && !pledgeLink && (
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="border-2 border-dashed border-gray-200 p-8 text-center">
|
||||
<h3 className="text-base font-bold text-[#111827]">Share your pledge link to get started</h3>
|
||||
<p className="text-sm text-gray-500 mt-2 max-w-xs mx-auto">
|
||||
Go to <Link href="/dashboard/collect" className="text-[#1E40AF] font-bold hover:underline">Collect</Link> to
|
||||
create an appeal and get your pledge link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 bg-white">
|
||||
<div className="border-b border-gray-100 px-5 py-3">
|
||||
<h3 className="text-sm font-bold text-[#111827]">How it works</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{[
|
||||
{ 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 => (
|
||||
<div key={s.n} className="px-5 py-3 flex gap-3">
|
||||
<span className="text-lg font-black text-gray-200 shrink-0 w-6">{s.n}</span>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-[#111827]">{s.title}</p>
|
||||
<p className="text-[11px] text-gray-500 leading-relaxed mt-0.5">{s.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Has pledges: Stats + Feed + Education ── */}
|
||||
{!isEmpty && (
|
||||
<>
|
||||
{/* Stats — dark inversion like landing page hero */}
|
||||
{/* Stats — gap-px grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-px bg-gray-200">
|
||||
{[
|
||||
{ value: String(s.totalPledges), label: "Pledges" },
|
||||
@@ -282,9 +318,9 @@ export default function DashboardPage() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="grid lg:grid-cols-5 gap-6">
|
||||
{/* LEFT column */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="grid lg:grid-cols-12 gap-6">
|
||||
{/* LEFT column: Data */}
|
||||
<div className="lg:col-span-7 space-y-6">
|
||||
{/* Needs attention */}
|
||||
{needsAttention.length > 0 && (
|
||||
<div className="bg-white border border-gray-200">
|
||||
@@ -312,47 +348,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* How pledges are doing */}
|
||||
<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="divide-y divide-gray-50">
|
||||
{Object.entries(byStatus).map(([status, count]) => {
|
||||
const sl = STATUS_LABELS[status] || STATUS_LABELS.new
|
||||
return (
|
||||
<div key={status} className="px-5 py-2.5 flex items-center justify-between">
|
||||
<span className={`text-xs font-bold px-2 py-0.5 ${sl.bg} ${sl.color}`}>{sl.label}</span>
|
||||
<span className="text-sm font-black text-[#111827]">{count as number}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Where pledges come from */}
|
||||
{topSources.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]">Where pledges come from</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{topSources.slice(0, 5).map((src: { label: string; count: number; amount: number }, i: number) => (
|
||||
<div key={i} className="px-5 py-2.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-xs font-black text-gray-300 w-4">{i + 1}</span>
|
||||
<span className="text-sm text-[#111827]">{src.label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[#111827]">{formatPence(src.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT column: Recent pledges */}
|
||||
<div className="lg:col-span-3">
|
||||
{/* Recent pledges */}
|
||||
<div className="bg-white border border-gray-200">
|
||||
<div className="border-b border-gray-100 px-5 py-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold text-[#111827]">Recent pledges</h3>
|
||||
@@ -396,6 +392,98 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT column: Status + Sources + Guidance */}
|
||||
<div className="lg:col-span-5 space-y-6">
|
||||
{/* How pledges are doing */}
|
||||
<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="divide-y divide-gray-50">
|
||||
{Object.entries(byStatus).map(([status, count]) => {
|
||||
const sl = STATUS_LABELS[status] || STATUS_LABELS.new
|
||||
return (
|
||||
<div key={status} className="px-5 py-2.5 flex items-center justify-between">
|
||||
<span className={`text-xs font-bold px-2 py-0.5 ${sl.bg} ${sl.color}`}>{sl.label}</span>
|
||||
<span className="text-sm font-black text-[#111827]">{count as number}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Where pledges come from */}
|
||||
{topSources.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]">Where pledges come from</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{topSources.slice(0, 5).map((src: { label: string; count: number; amount: number }, i: number) => (
|
||||
<div key={i} className="px-5 py-2.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-xs font-black text-gray-300 w-4">{i + 1}</span>
|
||||
<span className="text-sm text-[#111827]">{src.label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-[#111827]">{formatPence(src.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* What to do next — contextual guidance */}
|
||||
<div className="border-l-2 border-[#F59E0B] pl-4 space-y-2">
|
||||
<p className="text-xs font-bold text-[#111827]">What to do next</p>
|
||||
<div className="space-y-2">
|
||||
{s.collectionRate < 100 && (byStatus.initiated || 0) > 0 && (
|
||||
<Link href="/dashboard/money" className="flex items-start gap-2 group">
|
||||
<span className="text-[#F59E0B] font-bold text-xs shrink-0 mt-0.5">→</span>
|
||||
<p className="text-xs text-gray-600 group-hover:text-[#111827] transition-colors">
|
||||
<strong>Upload your bank statement</strong> to confirm {byStatus.initiated} {byStatus.initiated === 1 ? "payment" : "payments"} automatically
|
||||
</p>
|
||||
</Link>
|
||||
)}
|
||||
{s.collectionRate < 50 && (
|
||||
<Link href="/dashboard/collect" className="flex items-start gap-2 group">
|
||||
<span className="text-[#F59E0B] font-bold text-xs shrink-0 mt-0.5">→</span>
|
||||
<p className="text-xs text-gray-600 group-hover:text-[#111827] transition-colors">
|
||||
<strong>Share your link more widely</strong> — WhatsApp groups, social media, or print the QR
|
||||
</p>
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/dashboard/automations" className="flex items-start gap-2 group">
|
||||
<span className="text-[#F59E0B] font-bold text-xs shrink-0 mt-0.5">→</span>
|
||||
<p className="text-xs text-gray-600 group-hover:text-[#111827] transition-colors">
|
||||
<strong>Check your messages</strong> — see what donors receive and improve wording
|
||||
</p>
|
||||
</Link>
|
||||
<Link href="/dashboard/reports" className="flex items-start gap-2 group">
|
||||
<span className="text-[#F59E0B] font-bold text-xs shrink-0 mt-0.5">→</span>
|
||||
<p className="text-xs text-gray-600 group-hover:text-[#111827] transition-colors">
|
||||
<strong>Download for your treasurer</strong> — Gift Aid report, full pledge data, HMRC-ready CSV
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Understanding statuses */}
|
||||
<div className="border-l-2 border-[#1E40AF] pl-4 space-y-1.5">
|
||||
<p className="text-xs font-bold text-[#111827]">What the statuses mean</p>
|
||||
{[
|
||||
{ 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 => (
|
||||
<div key={s.label} className="flex items-start gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-[#1E40AF] shrink-0 mt-1.5" />
|
||||
<p className="text-[10px] text-gray-500"><strong className="text-[#111827]">{s.label}</strong> — {s.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -583,134 +583,212 @@ export default function MoneyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Search + filter ── */}
|
||||
<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: "new", label: "Waiting" },
|
||||
{ value: "initiated", label: "Said paid" },
|
||||
{ value: "overdue", label: "Overdue" },
|
||||
{ value: "paid", label: "Received" },
|
||||
].map(t => (
|
||||
<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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* ━━ TWO-COLUMN: Pledges left, Education right ━━━━━━━━ */}
|
||||
<div className="grid lg:grid-cols-12 gap-6">
|
||||
|
||||
{/* ── Pledge table ── */}
|
||||
{tablePledges.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200">
|
||||
<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-2">Amount</div>
|
||||
<div className="col-span-2">Appeal</div>
|
||||
<div className="col-span-2">Status</div>
|
||||
<div className="col-span-1">When</div>
|
||||
<div className="col-span-1"></div>
|
||||
{/* LEFT: Search + Table */}
|
||||
<div className="lg:col-span-8 space-y-4">
|
||||
{/* Search + filter */}
|
||||
<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: "new", label: "Waiting" },
|
||||
{ value: "initiated", label: "Said paid" },
|
||||
{ value: "overdue", label: "Overdue" },
|
||||
{ value: "paid", label: "Received" },
|
||||
].map(t => (
|
||||
<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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tablePledges.map((p: Pledge) => {
|
||||
const sl = STATUS[p.status] || STATUS.new
|
||||
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 className="col-span-6 md:col-span-4">
|
||||
<p className="text-sm font-medium text-[#111827] truncate">{p.donorName || "Anonymous"}</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<code className="text-[10px] text-gray-400 font-mono">{p.reference}</code>
|
||||
{p.donorPhone && <MessageCircle className="h-2.5 w-2.5 text-[#25D366]" />}
|
||||
{/* Pledge table */}
|
||||
{tablePledges.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200">
|
||||
<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-2">Amount</div>
|
||||
<div className="col-span-2">Appeal</div>
|
||||
<div className="col-span-2">Status</div>
|
||||
<div className="col-span-1">When</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
|
||||
{tablePledges.map((p: Pledge) => {
|
||||
const sl = STATUS[p.status] || STATUS.new
|
||||
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 className="col-span-6 md:col-span-4">
|
||||
<p className="text-sm font-medium text-[#111827] truncate">{p.donorName || "Anonymous"}</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<code className="text-[10px] text-gray-400 font-mono">{p.reference}</code>
|
||||
{p.donorPhone && <MessageCircle className="h-2.5 w-2.5 text-[#25D366]" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-3 md:col-span-2">
|
||||
<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.installmentTotal && p.installmentTotal > 1 && (
|
||||
<p className="text-[9px] text-[#F59E0B] font-bold">{p.installmentNumber}/{p.installmentTotal}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2 hidden md:block">
|
||||
<p className="text-xs text-gray-600 truncate">{p.eventName}</p>
|
||||
{p.qrSourceLabel && <p className="text-[10px] text-gray-400 truncate">{p.qrSourceLabel}</p>}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 inline-block ${sl.bg} ${sl.color}`}>{sl.label}</span>
|
||||
</div>
|
||||
<div className="col-span-1 hidden md:block">
|
||||
<span className="text-xs text-gray-500">{timeAgo(p.createdAt)}</span>
|
||||
</div>
|
||||
<div className="col-span-1 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="p-1.5 hover:bg-gray-100 transition-colors">
|
||||
<MoreVertical className="h-4 w-4 text-gray-400" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{p.status !== "paid" && (
|
||||
<DropdownMenuItem onClick={() => updateStatus(p.id, "paid")}>
|
||||
<CheckCircle2 className="h-4 w-4 text-[#16A34A]" /> Mark as received
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{p.status !== "initiated" && p.status !== "paid" && (
|
||||
<DropdownMenuItem onClick={() => updateStatus(p.id, "initiated")}>
|
||||
<Send className="h-4 w-4 text-[#F59E0B]" /> Mark as "said they paid"
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{p.donorPhone && p.status !== "paid" && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => sendReminder(p)}>
|
||||
<MessageCircle className="h-4 w-4 text-[#25D366]" /> Send WhatsApp reminder
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{p.status !== "cancelled" && p.status !== "paid" && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem destructive onClick={() => updateStatus(p.id, "cancelled")}>
|
||||
<XCircle className="h-4 w-4" /> Cancel pledge
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<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>
|
||||
<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"><ChevronLeft className="h-4 w-4" /></button>
|
||||
<button disabled={page >= totalPages - 1} onClick={() => setPage(p => p + 1)} className="border border-gray-200 p-1.5 disabled:opacity-30 hover:bg-gray-50"><ChevronRight className="h-4 w-4" /></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Education + Context */}
|
||||
<div className="lg:col-span-4 space-y-6">
|
||||
|
||||
{/* How matching works */}
|
||||
<div className="border border-gray-200 bg-white">
|
||||
<div className="border-b border-gray-100 px-5 py-3">
|
||||
<h3 className="text-sm font-bold text-[#111827]">How matching works</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{[
|
||||
{ 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 => (
|
||||
<div key={s.n} className="px-5 py-3 flex gap-3">
|
||||
<span className="text-lg font-black text-gray-200 shrink-0 w-6">{s.n}</span>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-[#111827]">{s.title}</p>
|
||||
<p className="text-[11px] text-gray-500 leading-relaxed mt-0.5">{s.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-3 md:col-span-2">
|
||||
<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.installmentTotal && p.installmentTotal > 1 && (
|
||||
<p className="text-[9px] text-[#F59E0B] font-bold">{p.installmentNumber}/{p.installmentTotal}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2 hidden md:block">
|
||||
<p className="text-xs text-gray-600 truncate">{p.eventName}</p>
|
||||
{p.qrSourceLabel && <p className="text-[10px] text-gray-400 truncate">{p.qrSourceLabel}</p>}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 inline-block ${sl.bg} ${sl.color}`}>{sl.label}</span>
|
||||
</div>
|
||||
<div className="col-span-1 hidden md:block">
|
||||
<span className="text-xs text-gray-500">{timeAgo(p.createdAt)}</span>
|
||||
</div>
|
||||
<div className="col-span-1 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="p-1.5 hover:bg-gray-100 transition-colors">
|
||||
<MoreVertical className="h-4 w-4 text-gray-400" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{p.status !== "paid" && (
|
||||
<DropdownMenuItem onClick={() => updateStatus(p.id, "paid")}>
|
||||
<CheckCircle2 className="h-4 w-4 text-[#16A34A]" /> Mark as received
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{p.status !== "initiated" && p.status !== "paid" && (
|
||||
<DropdownMenuItem onClick={() => updateStatus(p.id, "initiated")}>
|
||||
<Send className="h-4 w-4 text-[#F59E0B]" /> Mark as "said they paid"
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{p.donorPhone && p.status !== "paid" && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => sendReminder(p)}>
|
||||
<MessageCircle className="h-4 w-4 text-[#25D366]" /> Send WhatsApp reminder
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{p.status !== "cancelled" && p.status !== "paid" && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem destructive onClick={() => updateStatus(p.id, "cancelled")}>
|
||||
<XCircle className="h-4 w-4" /> Cancel pledge
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<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>
|
||||
<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"><ChevronLeft className="h-4 w-4" /></button>
|
||||
<button disabled={page >= totalPages - 1} onClick={() => setPage(p => p + 1)} className="border border-gray-200 p-1.5 disabled:opacity-30 hover:bg-gray-50"><ChevronRight className="h-4 w-4" /></button>
|
||||
{/* What the statuses mean */}
|
||||
<div className="border-l-2 border-[#1E40AF] pl-4 space-y-2">
|
||||
<p className="text-xs font-bold text-[#111827]">What the statuses mean</p>
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ 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 => (
|
||||
<div key={s.label} className="flex items-start gap-2">
|
||||
<span className={`w-1.5 h-1.5 ${s.color} shrink-0 mt-1.5`} />
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-[#111827]">{s.label}</p>
|
||||
<p className="text-[10px] text-gray-500">{s.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="border-l-2 border-[#F59E0B] pl-4 space-y-1.5">
|
||||
<p className="text-xs font-bold text-[#111827]">Tips for better collection</p>
|
||||
<p className="text-[10px] text-gray-500">Upload your bank statement weekly — the sooner you confirm, the sooner reminders stop.</p>
|
||||
<p className="text-[10px] text-gray-500">Donors who say they've paid usually have — but always verify with your bank.</p>
|
||||
<p className="text-[10px] text-gray-500">If someone asks "where do I pay?" — they can reply HELP to any WhatsApp message.</p>
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="border border-gray-200 bg-white p-4 space-y-3">
|
||||
<p className="text-xs font-bold text-[#111827]">Quick actions</p>
|
||||
<div className="space-y-2">
|
||||
<button onClick={() => setFilter("initiated")} className="w-full text-left px-3 py-2 text-xs bg-[#F59E0B]/5 hover:bg-[#F59E0B]/10 transition-colors flex items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5 text-[#F59E0B]" />
|
||||
<span>Show "said they paid" ({stats.initiatedCount})</span>
|
||||
</button>
|
||||
<button onClick={() => setFilter("overdue")} className="w-full text-left px-3 py-2 text-xs bg-[#DC2626]/5 hover:bg-[#DC2626]/10 transition-colors flex items-center gap-2">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-[#DC2626]" />
|
||||
<span>Show overdue ({stats.overdueCount})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ export default function SettingsPage() {
|
||||
: `${totalCount - doneCount} thing${totalCount - doneCount > 1 ? "s" : ""} left before you go live.`
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* ── Header — human progress, not a form page ── */}
|
||||
<div className={`p-6 mb-6 ${doneCount === totalCount ? "bg-[#16A34A]" : "bg-[#111827]"}`}>
|
||||
@@ -210,7 +210,11 @@ export default function SettingsPage() {
|
||||
|
||||
{error && <div className="border-l-2 border-[#DC2626] bg-[#DC2626]/5 p-3 text-sm text-[#DC2626]">{error}</div>}
|
||||
|
||||
{/* ── The Checklist ── */}
|
||||
{/* ━━ TWO-COLUMN: Checklist left, Education right ━━━━━━ */}
|
||||
<div className="grid lg:grid-cols-12 gap-6">
|
||||
|
||||
{/* LEFT: The Checklist */}
|
||||
<div className="lg:col-span-7">
|
||||
<div className="border border-gray-200 divide-y divide-gray-100 bg-white">
|
||||
|
||||
{/* ▸ WhatsApp ─────────────────────────── */}
|
||||
@@ -501,6 +505,85 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Education + Context */}
|
||||
<div className="lg:col-span-5 space-y-6">
|
||||
|
||||
{/* What each setting does */}
|
||||
<div className="border border-gray-200 bg-white">
|
||||
<div className="border-b border-gray-100 px-5 py-3">
|
||||
<h3 className="text-sm font-bold text-[#111827]">What you're setting up</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{[
|
||||
{ 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 => (
|
||||
<div key={s.n} className="px-5 py-3 flex gap-3">
|
||||
<span className={`text-lg font-black shrink-0 w-6 ${s.essential ? "text-[#1E40AF]" : "text-gray-200"}`}>{s.n}</span>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-[#111827] flex items-center gap-1.5">
|
||||
{s.title}
|
||||
{s.essential && <span className="text-[8px] font-bold bg-[#1E40AF]/10 text-[#1E40AF] px-1 py-0.5">Required</span>}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500 leading-relaxed mt-0.5">{s.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Privacy & data */}
|
||||
<div className="border-l-2 border-[#1E40AF] pl-4 space-y-2">
|
||||
<p className="text-xs font-bold text-[#111827]">Privacy & data</p>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[10px] text-gray-500">
|
||||
<strong className="text-[#111827]">Your data stays yours.</strong> We never access your Stripe account, bank details, or WhatsApp messages. Everything is stored encrypted.
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-500">
|
||||
<strong className="text-[#111827]">GDPR compliant.</strong> Donor consent is recorded at pledge time. You can export or delete all data anytime.
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-500">
|
||||
<strong className="text-[#111827]">No vendor lock-in.</strong> Download your full data as CSV from Reports. Your donors, your data, always.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Common questions */}
|
||||
<div className="border-l-2 border-[#F59E0B] pl-4 space-y-2">
|
||||
<p className="text-xs font-bold text-[#111827]">Common questions</p>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ 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 => (
|
||||
<div key={item.q}>
|
||||
<p className="text-[11px] font-bold text-[#111827]">{item.q}</p>
|
||||
<p className="text-[10px] text-gray-500">{item.a}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Need help? */}
|
||||
<div className="bg-[#111827] p-5">
|
||||
<p className="text-xs font-bold text-white">Need help setting up?</p>
|
||||
<p className="text-[11px] text-gray-400 mt-1 leading-relaxed">
|
||||
Our team can walk you through the setup in 15 minutes. Free, no strings attached.
|
||||
</p>
|
||||
<a href="mailto:omair@quikcue.com" className="inline-block mt-3 border border-gray-600 px-3 py-1.5 text-[11px] font-bold text-gray-300 hover:text-white hover:border-white transition-colors">
|
||||
Get in touch →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
96
temp_files/sg/ListScheduledGivingDonations.php
Normal file
96
temp_files/sg/ListScheduledGivingDonations.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ScheduledGivingDonationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ScheduledGivingDonationResource;
|
||||
use App\Models\ScheduledGivingCampaign;
|
||||
use App\Models\ScheduledGivingDonation;
|
||||
use Filament\Resources\Components\Tab;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ListScheduledGivingDonations extends ListRecords
|
||||
{
|
||||
protected static string $resource = ScheduledGivingDonationResource::class;
|
||||
|
||||
public function getHeading(): string
|
||||
{
|
||||
return 'Scheduled Giving Subscribers';
|
||||
}
|
||||
|
||||
public function getSubheading(): string
|
||||
{
|
||||
$active = ScheduledGivingDonation::where('is_active', true)->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';
|
||||
}
|
||||
}
|
||||
204
temp_files/sg/ScheduledGivingDashboard.php
Normal file
204
temp_files/sg/ScheduledGivingDashboard.php
Normal file
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\ScheduledGivingCampaign;
|
||||
use App\Models\ScheduledGivingDonation;
|
||||
use App\Models\ScheduledGivingPayment;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Scheduled Giving Command Centre.
|
||||
*
|
||||
* Three campaigns, three seasons, one dashboard.
|
||||
* Every number a supporter-care agent needs is on this page.
|
||||
*/
|
||||
class ScheduledGivingDashboard extends Page
|
||||
{
|
||||
protected static ?string $navigationIcon = 'heroicon-o-calendar-days';
|
||||
|
||||
protected static ?string $navigationGroup = 'Seasonal Campaigns';
|
||||
|
||||
protected static ?int $navigationSort = 0;
|
||||
|
||||
protected static ?string $navigationLabel = 'Campaign Dashboard';
|
||||
|
||||
protected static ?string $title = 'Scheduled Giving';
|
||||
|
||||
protected static string $view = 'filament.pages.scheduled-giving-dashboard';
|
||||
|
||||
/**
|
||||
* Aggregate stats for every campaign, plus global totals.
|
||||
* Runs 3 efficient queries (not N+1).
|
||||
*/
|
||||
public function getCampaignData(): array
|
||||
{
|
||||
$campaigns = ScheduledGivingCampaign::withCount([
|
||||
'donations',
|
||||
'donations as active_count' => 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();
|
||||
}
|
||||
}
|
||||
184
temp_files/sg/nav_update.py
Normal file
184
temp_files/sg/nav_update.py
Normal file
@@ -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!')
|
||||
199
temp_files/sg/scheduled-giving-dashboard.blade.php
Normal file
199
temp_files/sg/scheduled-giving-dashboard.blade.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<x-filament-panels::page>
|
||||
@php
|
||||
$global = $this->getGlobalStats();
|
||||
$campaigns = $this->getCampaignData();
|
||||
$failed = $this->getFailedPayments();
|
||||
$upcoming = $this->getUpcomingPayments();
|
||||
@endphp
|
||||
|
||||
{{-- ── Global Overview ──────────────────────────────────────── --}}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<x-filament::section>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-primary-600">{{ number_format($global['active_subscribers']) }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Active Subscribers</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-success-600">£{{ number_format($global['collected'], 2) }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Total Collected</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-warning-600">£{{ number_format($global['pending'], 2) }}</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Pending Collection</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<x-filament::section>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold {{ $global['collection_rate'] >= 80 ? 'text-success-600' : ($global['collection_rate'] >= 60 ? 'text-warning-600' : 'text-danger-600') }}">
|
||||
{{ $global['collection_rate'] }}%
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 mt-1">Collection Rate</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
|
||||
{{-- ── Campaign Cards ──────────────────────────────────────── --}}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
@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
|
||||
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>{{ $c->title }}</span>
|
||||
@if ($isActive)
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">● Live</span>
|
||||
@else
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">○ Ended</span>
|
||||
@endif
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
{{-- Stats grid --}}
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Subscribers</div>
|
||||
<div class="text-lg font-semibold">{{ number_format($data['active']) }} <span class="text-xs text-gray-400 font-normal">/ {{ number_format($data['subscribers']) }}</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Avg / Night</div>
|
||||
<div class="text-lg font-semibold">£{{ number_format($data['avg_per_night'], 2) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Collected</div>
|
||||
<div class="text-lg font-semibold text-success-600">£{{ number_format($data['collected'], 2) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Pending</div>
|
||||
<div class="text-lg font-semibold text-warning-600">£{{ number_format($data['pending_amount'], 2) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Payment progress bar --}}
|
||||
<div class="mb-3">
|
||||
<div class="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>{{ number_format($data['paid_payments']) }} / {{ number_format($data['total_payments']) }} payments</span>
|
||||
<span>{{ $progressPct }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div class="h-2.5 rounded-full {{ $progressPct >= 80 ? 'bg-success-500' : ($progressPct >= 50 ? 'bg-warning-500' : 'bg-primary-500') }}"
|
||||
style="width: {{ $progressPct }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Key metrics --}}
|
||||
<div class="grid grid-cols-3 gap-2 text-center border-t pt-3">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Nights</div>
|
||||
<div class="font-semibold">{{ $data['total_nights'] }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Completed</div>
|
||||
<div class="font-semibold text-success-600">{{ $data['fully_completed'] ?? 0 }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Failed</div>
|
||||
<div class="font-semibold {{ $data['failed_payments'] > 0 ? 'text-danger-600' : 'text-gray-400' }}">{{ $data['failed_payments'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Quick links --}}
|
||||
<div class="flex gap-2 mt-3 pt-3 border-t">
|
||||
<a href="{{ url('/admin/scheduled-giving-donations?tableFilters[scheduled_giving_campaign_id][value]=' . $c->id) }}"
|
||||
class="text-xs text-primary-600 hover:underline">View Subscribers →</a>
|
||||
<a href="{{ url('/admin/scheduled-giving-campaigns/' . $c->id . '/edit') }}"
|
||||
class="text-xs text-gray-500 hover:underline ml-auto">Edit Campaign</a>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- ── Upcoming Payments ───────────────────────────────────── --}}
|
||||
@if (count($upcoming) > 0)
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-heroicon-o-clock class="w-5 h-5 text-primary-500" />
|
||||
Upcoming Payments (Next 48 Hours)
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
@foreach ($upcoming as $u)
|
||||
<div class="flex items-center justify-between p-3 bg-primary-50 rounded-lg">
|
||||
<div>
|
||||
<div class="font-semibold">{{ $u->campaign }}</div>
|
||||
<div class="text-sm text-gray-500">{{ $u->payment_count }} payments</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="font-bold text-primary-600">£{{ number_format($u->total_amount, 2) }}</div>
|
||||
<div class="text-xs text-gray-500">{{ \Carbon\Carbon::parse($u->earliest)->diffForHumans() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
{{-- ── Failed Payments (Needs Attention) ───────────────────── --}}
|
||||
@if (count($failed) > 0)
|
||||
<x-filament::section class="mt-6">
|
||||
<x-slot name="heading">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-heroicon-o-exclamation-triangle class="w-5 h-5 text-danger-500" />
|
||||
Failed Payments — Needs Attention ({{ count($failed) }})
|
||||
</div>
|
||||
</x-slot>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-gray-500 uppercase border-b">
|
||||
<th class="pb-2">Donor</th>
|
||||
<th class="pb-2">Campaign</th>
|
||||
<th class="pb-2">Amount</th>
|
||||
<th class="pb-2">Expected</th>
|
||||
<th class="pb-2">Attempts</th>
|
||||
<th class="pb-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($failed as $f)
|
||||
<tr class="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td class="py-2">
|
||||
<div class="font-medium">{{ $f->donor_name }}</div>
|
||||
<div class="text-xs text-gray-400">{{ $f->donor_email }}</div>
|
||||
</td>
|
||||
<td class="py-2">{{ $f->campaign }}</td>
|
||||
<td class="py-2 font-semibold">£{{ number_format($f->amount / 100, 2) }}</td>
|
||||
<td class="py-2 text-gray-500">{{ \Carbon\Carbon::parse($f->expected_at)->format('d M Y') }}</td>
|
||||
<td class="py-2">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-danger-100 text-danger-800">
|
||||
{{ $f->attempts }}x failed
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2">
|
||||
<a href="{{ url('/admin/scheduled-giving-donations/' . $f->donation_id . '/edit') }}"
|
||||
class="text-xs text-primary-600 hover:underline">View →</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
</x-filament-panels::page>
|
||||
141
temp_files/sg/table_update.py
Normal file
141
temp_files/sg/table_update.py
Normal file
@@ -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')
|
||||
Reference in New Issue
Block a user