Reconciliation embedded in Money — no more separate page
The reconcile feature was hidden behind a text link at the bottom of the Money page. That's backwards. Reconciliation IS the Money page. Aaisha's actual thought process: 1. People pledged 2. Some say they paid 3. 'Did the money actually arrive?' → opens bank website, downloads CSV 4. 'Let me match it' → THIS SHOULD BE RIGHT HERE, not behind a link 5. '8 out of 10 matched' → pledges auto-move to 'received' Changes: - Full bank statement upload area is NOW embedded directly in /dashboard/money With icon, description, drop zone — always visible, not a link - When CSV is selected: file name + detected bank format shown inline Column mapping is collapsed by default (auto-detected) but expandable - 'Match payments' button is blue, full-width, prominent - Results appear INLINE below the upload area: - Summary stats (gap-px grid): rows, incoming, matched, possible, auto-confirmed - Green success banner when pledges are auto-confirmed - Full match results table with confidence icons - 'Upload another' button to reset - After matching: dashboard data auto-refreshes to show updated pledge statuses - 'Said they paid' section now says 'Upload a bank statement above to confirm' instead of linking to a separate page - /dashboard/reconcile now redirects to /dashboard/money (backward compat) - Contextual sections (confirm/nudge) hide when match results are showing to avoid visual clutter Architecture is now: Stats → MATCH PAYMENTS → Confirm these → Need a nudge → All pledges table Not: Stats → table → small link at bottom → navigate away → separate page
This commit is contained in:
@@ -5,28 +5,35 @@ import { useToast } from "@/components/ui/toast"
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Search, MoreVertical, CheckCircle2, XCircle, MessageCircle, Send,
|
||||
ChevronLeft, ChevronRight, Loader2, Upload, ArrowRight, AlertTriangle,
|
||||
Clock
|
||||
ChevronLeft, ChevronRight, Loader2, Upload, AlertTriangle,
|
||||
Clock, HelpCircle, AlertCircle, FileUp, X
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { formatPence } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* /dashboard/money — "Where's the money?"
|
||||
*
|
||||
* Redesigned as a context-aware inbox. Instead of a flat table,
|
||||
* the page surfaces what needs attention RIGHT NOW:
|
||||
* The ENTIRE point of this page: "Has the money arrived?"
|
||||
* Reconciliation is not a side feature — it IS this page.
|
||||
*
|
||||
* 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
|
||||
* Architecture:
|
||||
* ┌──────────────────────────────────────────────────┐
|
||||
* │ Stats (clickable filters) │
|
||||
* ├──────────────────────────────────────────────────┤
|
||||
* │ MATCH PAYMENTS (always visible, expandable) │
|
||||
* │ Upload bank CSV → auto-match → results inline │
|
||||
* ├──────────────────────────────────────────────────┤
|
||||
* │ CONFIRM THESE (contextual — "said they paid") │
|
||||
* │ One-click confirm buttons │
|
||||
* ├──────────────────────────────────────────────────┤
|
||||
* │ NEED A NUDGE (contextual — overdue) │
|
||||
* │ One-click WhatsApp nudge │
|
||||
* ├──────────────────────────────────────────────────┤
|
||||
* │ ALL PLEDGES (table with search/filter/pagination) │
|
||||
* └──────────────────────────────────────────────────┘
|
||||
*
|
||||
* 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
|
||||
* The bank statement upload is ABOVE the table, not linked from
|
||||
* the bottom. It's the primary action, not an afterthought.
|
||||
*/
|
||||
|
||||
interface Pledge {
|
||||
@@ -38,6 +45,13 @@ interface Pledge {
|
||||
createdAt: string; paidAt: string | null
|
||||
}
|
||||
|
||||
interface MatchResult {
|
||||
bankRow: { date: string; description: string; amount: number; reference: string }
|
||||
pledgeId: string | null; pledgeReference: string | null
|
||||
confidence: "exact" | "partial" | "amount_only" | "none"
|
||||
matchedAmount: number; autoConfirmed: boolean
|
||||
}
|
||||
|
||||
const STATUS: Record<string, { label: string; color: string; bg: string }> = {
|
||||
new: { label: "Waiting", color: "text-gray-600", bg: "bg-gray-100" },
|
||||
initiated: { label: "Said they paid", color: "text-[#F59E0B]", bg: "bg-[#F59E0B]/10" },
|
||||
@@ -71,7 +85,18 @@ export default function MoneyPage() {
|
||||
cancelledCount: 0, totalPledged: 0, totalCollected: 0
|
||||
})
|
||||
|
||||
// Load top-level data (for contextual sections)
|
||||
// ── Reconciliation state (embedded, not a separate page) ──
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [bankName, setBankName] = useState("")
|
||||
const [matchResults, setMatchResults] = useState<{
|
||||
summary: { totalRows: number; credits: number; exactMatches: number; partialMatches: number; unmatched: number; autoConfirmed: number }
|
||||
matches: MatchResult[]
|
||||
} | null>(null)
|
||||
const [mapping, setMapping] = useState({ dateCol: "Date", descriptionCol: "Description", creditCol: "Credit", referenceCol: "Reference" })
|
||||
const [showMapping, setShowMapping] = useState(false)
|
||||
|
||||
// Load top-level data
|
||||
const loadDashboard = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetch("/api/dashboard").then(r => r.json())
|
||||
@@ -86,13 +111,12 @@ export default function MoneyPage() {
|
||||
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
|
||||
// Load paginated table
|
||||
const loadTable = useCallback(async () => {
|
||||
const params = new URLSearchParams()
|
||||
params.set("limit", String(pageSize))
|
||||
@@ -123,12 +147,10 @@ export default function MoneyPage() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
// Update both lists
|
||||
const updater = (p: Pledge) => p.id === pledgeId ? { ...p, status: newStatus } : p
|
||||
setAllPledges(prev => prev.map(updater))
|
||||
setTablePledges(prev => prev.map(updater))
|
||||
toast("Updated", "success")
|
||||
// Refresh stats
|
||||
loadDashboard()
|
||||
} catch { toast("Failed to update", "error") }
|
||||
setUpdating(null)
|
||||
@@ -149,12 +171,88 @@ export default function MoneyPage() {
|
||||
} catch { toast("Failed to send", "error") }
|
||||
}
|
||||
|
||||
// ── Reconciliation handlers ──
|
||||
const autoDetect = async (f: File) => {
|
||||
const text = await f.text()
|
||||
const firstLine = text.split("\n")[0]
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/imports/presets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ headers: firstLine.split(",").map(h => h.replace(/"/g, "").trim()) }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.detected && data.preset) {
|
||||
setBankName(data.preset.bankName)
|
||||
setMapping({
|
||||
dateCol: data.preset.dateCol,
|
||||
descriptionCol: data.preset.descriptionCol,
|
||||
creditCol: data.preset.creditCol || data.preset.amountCol || "Credit",
|
||||
referenceCol: data.preset.referenceCol || "Reference",
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
|
||||
try {
|
||||
const headers = firstLine.split(",").map(h => h.replace(/"/g, "").trim())
|
||||
const rows = text.split("\n").slice(1, 4).map(row => row.split(",").map(c => c.replace(/"/g, "").trim()))
|
||||
const res = await fetch("/api/ai/map-columns", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ headers, sampleRows: rows }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.dateCol) {
|
||||
setBankName(data.source === "ai" ? "Auto-detected" : "")
|
||||
setMapping({
|
||||
dateCol: data.dateCol,
|
||||
descriptionCol: data.descriptionCol || "Description",
|
||||
creditCol: data.creditCol || data.amountCol || "Credit",
|
||||
referenceCol: data.referenceCol || "Reference",
|
||||
})
|
||||
}
|
||||
} catch { /* keep defaults */ }
|
||||
}
|
||||
|
||||
const handleFileSelect = (f: File) => {
|
||||
setCsvFile(f)
|
||||
setMatchResults(null)
|
||||
autoDetect(f)
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!csvFile) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append("file", csvFile)
|
||||
formData.append("mapping", JSON.stringify(mapping))
|
||||
const res = await fetch("/api/imports/bank-statement", { method: "POST", body: formData })
|
||||
const data = await res.json()
|
||||
if (data.summary) {
|
||||
setMatchResults(data)
|
||||
// Refresh dashboard data to pick up newly confirmed pledges
|
||||
setTimeout(() => { loadDashboard(); loadTable() }, 500)
|
||||
}
|
||||
} catch { /* */ }
|
||||
setUploading(false)
|
||||
}
|
||||
|
||||
const clearReconcile = () => {
|
||||
setCsvFile(null)
|
||||
setMatchResults(null)
|
||||
setBankName("")
|
||||
setShowMapping(false)
|
||||
}
|
||||
|
||||
const collectionRate = stats.totalPledged > 0 ? Math.round((stats.totalCollected / stats.totalPledged) * 100) : 0
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
// Contextual sections data
|
||||
const saidPaid = allPledges.filter((p: Pledge) => p.status === "initiated")
|
||||
const overdue = allPledges.filter((p: Pledge) => p.status === "overdue")
|
||||
const showContextual = filter === "all" && !search
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
|
||||
|
||||
@@ -162,28 +260,26 @@ export default function MoneyPage() {
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Money</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{formatPence(stats.totalCollected)} received of {formatPence(stats.totalPledged)} promised
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Money</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{formatPence(stats.totalCollected)} received of {formatPence(stats.totalPledged)} promised
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Big numbers ── */}
|
||||
{/* ── Stats bar (clickable filters) ── */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-px bg-gray-200">
|
||||
{[
|
||||
{ value: String(stats.total), label: "Total pledges", onClick: () => setFilter("all") },
|
||||
{ value: String(stats.newCount), label: "Waiting", onClick: () => setFilter("new") },
|
||||
{ value: String(stats.initiatedCount), label: "Said they paid", onClick: () => setFilter("initiated"), accent: stats.initiatedCount > 0 ? "text-[#F59E0B]" : undefined },
|
||||
{ value: String(stats.overdueCount), label: "Need a nudge", onClick: () => setFilter("overdue"), accent: stats.overdueCount > 0 ? "text-[#DC2626]" : undefined },
|
||||
{ value: String(stats.paidCount), label: "Received ✓", onClick: () => setFilter("paid"), accent: "text-[#16A34A]" },
|
||||
{ key: "all", value: String(stats.total), label: "Total pledges" },
|
||||
{ key: "new", value: String(stats.newCount), label: "Waiting" },
|
||||
{ key: "initiated", value: String(stats.initiatedCount), label: "Said they paid", accent: stats.initiatedCount > 0 ? "text-[#F59E0B]" : undefined },
|
||||
{ key: "overdue", value: String(stats.overdueCount), label: "Need a nudge", accent: stats.overdueCount > 0 ? "text-[#DC2626]" : undefined },
|
||||
{ key: "paid", value: String(stats.paidCount), label: "Received ✓", accent: "text-[#16A34A]" },
|
||||
].map(s => (
|
||||
<button
|
||||
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]" : ""}`}
|
||||
key={s.key}
|
||||
onClick={() => { setFilter(s.key); setPage(0) }}
|
||||
className={`bg-white p-4 text-left hover:bg-gray-50 transition-colors ${filter === s.key ? "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>
|
||||
@@ -199,17 +295,201 @@ export default function MoneyPage() {
|
||||
<span className="text-xs font-bold text-[#111827] whitespace-nowrap">{collectionRate}%</span>
|
||||
</div>
|
||||
|
||||
{/* ── CONTEXTUAL SECTION: Confirm payments ── */}
|
||||
{saidPaid.length > 0 && filter === "all" && !search && (
|
||||
{/* ═══════════════════════════════════════════════════════
|
||||
MATCH PAYMENTS — THE PRIMARY ACTION, NOT AN AFTERTHOUGHT
|
||||
This section is ALWAYS visible. It expands when you drop a file.
|
||||
═══════════════════════════════════════════════════════ */}
|
||||
<div className={`border-2 transition-colors ${csvFile ? "border-[#1E40AF] bg-[#1E40AF]/[0.02]" : "border-gray-200 bg-white"}`}>
|
||||
{/* Header + drop zone combined */}
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-[#1E40AF]/10 flex items-center justify-center shrink-0">
|
||||
<Upload className="h-5 w-5 text-[#1E40AF]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-[#111827]">Match bank payments</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Upload your bank statement CSV — we auto-match payments to pledges
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{csvFile && (
|
||||
<button onClick={clearReconcile} className="text-gray-400 hover:text-gray-600 p-1">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload area */}
|
||||
{!matchResults && (
|
||||
<>
|
||||
<div className={`border-2 border-dashed p-6 text-center transition-colors cursor-pointer ${csvFile ? "border-[#1E40AF]/30 bg-[#1E40AF]/5" : "border-gray-200 hover:border-[#1E40AF]/50"}`}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={e => e.target.files?.[0] && handleFileSelect(e.target.files[0])}
|
||||
className="hidden"
|
||||
id="bank-csv-upload"
|
||||
/>
|
||||
<label htmlFor="bank-csv-upload" className="cursor-pointer block">
|
||||
{csvFile ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<FileUp className="h-5 w-5 text-[#1E40AF]" />
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-bold text-[#111827]">{csvFile.name}</p>
|
||||
<p className="text-[10px] text-gray-500">{bankName ? `${bankName} format detected` : "Click to change file"}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-6 w-6 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-sm font-medium text-[#111827]">Drop a bank statement CSV here</p>
|
||||
<p className="text-[10px] text-gray-500 mt-1">
|
||||
We recognise Barclays, HSBC, Lloyds, NatWest, Monzo, Starling, and more
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Column mapping (collapsed by default — auto-detected) */}
|
||||
{csvFile && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<button
|
||||
onClick={() => setShowMapping(!showMapping)}
|
||||
className="text-[10px] font-semibold text-gray-500 hover:text-[#111827] flex items-center gap-1"
|
||||
>
|
||||
{bankName && <span className="text-[10px] font-bold text-[#16A34A] bg-[#16A34A]/10 px-1.5 py-0.5 mr-1">{bankName}</span>}
|
||||
{showMapping ? "Hide column mapping ▲" : "Edit column mapping ▼"}
|
||||
</button>
|
||||
|
||||
{showMapping && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ key: "dateCol", label: "Date" },
|
||||
{ key: "descriptionCol", label: "Description" },
|
||||
{ key: "creditCol", label: "Amount / Credit" },
|
||||
{ key: "referenceCol", label: "Reference" },
|
||||
].map(col => (
|
||||
<div key={col.key}>
|
||||
<label className="text-[10px] font-bold text-gray-500 block mb-1">{col.label}</label>
|
||||
<input
|
||||
value={mapping[col.key as keyof typeof mapping]}
|
||||
onChange={e => setMapping(m => ({ ...m, [col.key]: e.target.value }))}
|
||||
className="w-full h-8 px-2 border border-gray-200 text-xs focus:border-[#1E40AF] outline-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading}
|
||||
className="w-full bg-[#1E40AF] px-4 py-3 text-sm font-bold text-white hover:bg-[#1E40AF]/90 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{uploading ? <><Loader2 className="h-4 w-4 animate-spin" /> Matching payments...</> : <><Upload className="h-4 w-4" /> Match payments</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── RESULTS (inline, not a separate page) ── */}
|
||||
{matchResults && (
|
||||
<div className="space-y-4">
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-px bg-gray-200">
|
||||
{[
|
||||
{ value: matchResults.summary.totalRows, label: "Rows in CSV" },
|
||||
{ value: matchResults.summary.credits, label: "Incoming payments" },
|
||||
{ value: matchResults.summary.exactMatches, label: "Matched ✓", accent: "text-[#16A34A]" },
|
||||
{ value: matchResults.summary.partialMatches, label: "Possible matches", accent: "text-[#F59E0B]" },
|
||||
{ value: matchResults.summary.autoConfirmed, label: "Auto-confirmed", accent: "text-[#16A34A]" },
|
||||
].map(s => (
|
||||
<div key={s.label} className="bg-white p-3 text-center">
|
||||
<p className={`text-xl font-black ${s.accent || "text-[#111827]"}`}>{s.value}</p>
|
||||
<p className="text-[9px] text-gray-500 mt-0.5">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Auto-confirmed success message */}
|
||||
{matchResults.summary.autoConfirmed > 0 && (
|
||||
<div className="bg-[#16A34A]/5 border border-[#16A34A]/20 p-3 flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-[#16A34A] shrink-0" />
|
||||
<p className="text-xs text-[#16A34A] font-bold">
|
||||
{matchResults.summary.autoConfirmed} {matchResults.summary.autoConfirmed === 1 ? "pledge" : "pledges"} automatically confirmed as received
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Match rows */}
|
||||
<div className="border border-gray-200 bg-white">
|
||||
<div className="border-b border-gray-100 px-4 py-2.5 flex items-center justify-between">
|
||||
<h3 className="text-xs font-bold text-[#111827]">Match results</h3>
|
||||
<button onClick={clearReconcile} className="text-[10px] text-gray-400 hover:text-[#111827]">Upload another →</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="px-4 py-2 text-left text-[9px] font-bold text-gray-400 uppercase w-8"></th>
|
||||
<th className="px-4 py-2 text-left text-[9px] font-bold text-gray-400 uppercase">Date</th>
|
||||
<th className="px-4 py-2 text-left text-[9px] font-bold text-gray-400 uppercase">Description</th>
|
||||
<th className="px-4 py-2 text-left text-[9px] font-bold text-gray-400 uppercase">Amount</th>
|
||||
<th className="px-4 py-2 text-left text-[9px] font-bold text-gray-400 uppercase">Pledge ref</th>
|
||||
<th className="px-4 py-2 text-left text-[9px] font-bold text-gray-400 uppercase">Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{matchResults.matches.map((m, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-2.5">
|
||||
{m.confidence === "exact" || m.autoConfirmed
|
||||
? <CheckCircle2 className="h-3.5 w-3.5 text-[#16A34A]" />
|
||||
: m.confidence === "partial"
|
||||
? <AlertCircle className="h-3.5 w-3.5 text-[#F59E0B]" />
|
||||
: <HelpCircle className="h-3.5 w-3.5 text-gray-300" />}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-600 whitespace-nowrap">{m.bankRow.date}</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-600 max-w-[180px] truncate">{m.bankRow.description}</td>
|
||||
<td className="px-4 py-2.5 text-sm font-black text-[#111827]">£{m.matchedAmount.toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-xs font-mono text-gray-500">{m.pledgeReference || "—"}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{m.autoConfirmed ? (
|
||||
<span className="text-[9px] font-bold px-1.5 py-0.5 bg-[#16A34A]/10 text-[#16A34A]">Confirmed</span>
|
||||
) : m.confidence === "exact" ? (
|
||||
<span className="text-[9px] font-bold px-1.5 py-0.5 bg-[#16A34A]/10 text-[#16A34A]">Matched</span>
|
||||
) : m.confidence === "partial" ? (
|
||||
<span className="text-[9px] font-bold px-1.5 py-0.5 bg-[#F59E0B]/10 text-[#F59E0B]">Check</span>
|
||||
) : (
|
||||
<span className="text-[9px] font-bold px-1.5 py-0.5 bg-gray-100 text-gray-400">No match</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── CONTEXTUAL: Confirm these (said they paid) ── */}
|
||||
{saidPaid.length > 0 && showContextual && !matchResults && (
|
||||
<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>
|
||||
<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>
|
||||
<span className="text-[10px] text-gray-500">Upload a bank statement above to confirm automatically</span>
|
||||
</div>
|
||||
<div className="divide-y divide-[#F59E0B]/10">
|
||||
{saidPaid.slice(0, 5).map((p: Pledge) => (
|
||||
@@ -241,14 +521,14 @@ export default function MoneyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── CONTEXTUAL SECTION: People who need a nudge ── */}
|
||||
{overdue.length > 0 && filter === "all" && !search && (
|
||||
{/* ── CONTEXTUAL: People who need a nudge ── */}
|
||||
{overdue.length > 0 && showContextual && (
|
||||
<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 className="px-5 py-3 flex items-center gap-2 border-b border-[#DC2626]/10">
|
||||
<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 className="divide-y divide-[#DC2626]/10">
|
||||
{overdue.slice(0, 5).map((p: Pledge) => {
|
||||
@@ -264,18 +544,11 @@ export default function MoneyPage() {
|
||||
</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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
@@ -293,7 +566,7 @@ export default function MoneyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Search + filter bar ── */}
|
||||
{/* ── 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" />
|
||||
@@ -316,9 +589,7 @@ export default function MoneyPage() {
|
||||
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"
|
||||
filter === t.value ? "bg-[#111827] text-white" : "border border-gray-200 text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
@@ -335,7 +606,6 @@ export default function MoneyPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200">
|
||||
{/* Header */}
|
||||
<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>
|
||||
@@ -345,13 +615,10 @@ export default function MoneyPage() {
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{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" : ""}`}>
|
||||
{/* Donor */}
|
||||
<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">
|
||||
@@ -359,8 +626,6 @@ export default function MoneyPage() {
|
||||
{p.donorPhone && <MessageCircle className="h-2.5 w-2.5 text-[#25D366]" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<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>}
|
||||
@@ -368,24 +633,16 @@ export default function MoneyPage() {
|
||||
<p className="text-[9px] text-[#F59E0B] font-bold">{p.installmentNumber}/{p.installmentTotal}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Appeal (desktop) */}
|
||||
<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>
|
||||
|
||||
{/* Status */}
|
||||
<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>
|
||||
|
||||
{/* When (desktop) */}
|
||||
<div className="col-span-1 hidden md:block">
|
||||
<span className="text-xs text-gray-500">{timeAgo(p.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="col-span-1 text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="p-1.5 hover:bg-gray-100 transition-colors">
|
||||
@@ -427,32 +684,16 @@ export default function MoneyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Pagination ── */}
|
||||
{/* 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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ── Match payments CTA ── */}
|
||||
<Link href="/dashboard/reconcile">
|
||||
<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">
|
||||
<Upload className="h-5 w-5 text-[#1E40AF] shrink-0" />
|
||||
<div className="flex-1">
|
||||
<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>
|
||||
<ArrowRight className="h-4 w-4 text-[#1E40AF] shrink-0" />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,225 +1,13 @@
|
||||
/**
|
||||
* /dashboard/reconcile → now embedded in /dashboard/money
|
||||
* This route redirects for backward compatibility.
|
||||
*/
|
||||
"use client"
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Upload, CheckCircle2, AlertCircle, HelpCircle, ArrowLeft } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
interface MatchResult {
|
||||
bankRow: { date: string; description: string; amount: number; reference: string }
|
||||
pledgeId: string | null; pledgeReference: string | null
|
||||
confidence: "exact" | "partial" | "amount_only" | "none"
|
||||
matchedAmount: number; autoConfirmed: boolean
|
||||
}
|
||||
|
||||
export default function MatchPaymentsPage() {
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [bankName, setBankName] = useState("")
|
||||
const [results, setResults] = useState<{ summary: { totalRows: number; credits: number; exactMatches: number; partialMatches: number; unmatched: number; autoConfirmed: number }; matches: MatchResult[] } | null>(null)
|
||||
const [mapping, setMapping] = useState({ dateCol: "Date", descriptionCol: "Description", creditCol: "Credit", referenceCol: "Reference" })
|
||||
|
||||
// Try auto-detecting bank format
|
||||
const autoDetect = async (f: File) => {
|
||||
const text = await f.text()
|
||||
const firstLine = text.split("\n")[0]
|
||||
|
||||
// Try bank preset detection
|
||||
try {
|
||||
const res = await fetch("/api/imports/presets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ headers: firstLine.split(",").map(h => h.replace(/"/g, "").trim()) }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.detected && data.preset) {
|
||||
setBankName(data.preset.bankName)
|
||||
setMapping({
|
||||
dateCol: data.preset.dateCol,
|
||||
descriptionCol: data.preset.descriptionCol,
|
||||
creditCol: data.preset.creditCol || data.preset.amountCol || "Credit",
|
||||
referenceCol: data.preset.referenceCol || "Reference",
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch { /* fallback to AI */ }
|
||||
|
||||
// Try AI column mapping
|
||||
try {
|
||||
const headers = firstLine.split(",").map(h => h.replace(/"/g, "").trim())
|
||||
const rows = text.split("\n").slice(1, 4).map(row => row.split(",").map(c => c.replace(/"/g, "").trim()))
|
||||
const res = await fetch("/api/ai/map-columns", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ headers, sampleRows: rows }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.dateCol) {
|
||||
setBankName(data.source === "ai" ? "Auto-detected" : "")
|
||||
setMapping({
|
||||
dateCol: data.dateCol,
|
||||
descriptionCol: data.descriptionCol || "Description",
|
||||
creditCol: data.creditCol || data.amountCol || "Credit",
|
||||
referenceCol: data.referenceCol || "Reference",
|
||||
})
|
||||
}
|
||||
} catch { /* keep defaults */ }
|
||||
}
|
||||
|
||||
const handleFileSelect = (f: File) => {
|
||||
setFile(f)
|
||||
autoDetect(f)
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
formData.append("mapping", JSON.stringify(mapping))
|
||||
const res = await fetch("/api/imports/bank-statement", { method: "POST", body: formData })
|
||||
const data = await res.json()
|
||||
if (data.summary) setResults(data)
|
||||
} catch { /* */ }
|
||||
setUploading(false)
|
||||
}
|
||||
|
||||
const confidenceIcon = (c: string) => {
|
||||
switch (c) {
|
||||
case "exact": return <CheckCircle2 className="h-4 w-4 text-[#16A34A]" />
|
||||
case "partial": return <AlertCircle className="h-4 w-4 text-[#F59E0B]" />
|
||||
default: return <HelpCircle className="h-4 w-4 text-gray-300" />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<Link href="/dashboard/money" className="text-xs text-gray-500 hover:text-[#111827] transition-colors inline-flex items-center gap-1 mb-2">
|
||||
<ArrowLeft className="h-3 w-3" /> Back to Money
|
||||
</Link>
|
||||
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Match Payments</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Upload your bank statement and we'll match payments to pledges automatically</p>
|
||||
</div>
|
||||
|
||||
{/* Upload section */}
|
||||
<div className="bg-white border border-gray-200 p-6 space-y-5">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-[#111827]">Upload bank statement</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Download a CSV from your bank's website and upload it here. We recognise formats from Barclays, HSBC, Lloyds, NatWest, Monzo, Starling, and more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* File drop zone */}
|
||||
<div className="border-2 border-dashed border-gray-200 p-8 text-center hover:border-[#1E40AF]/50 transition-colors">
|
||||
<Upload className="h-8 w-8 text-gray-300 mx-auto mb-3" />
|
||||
<input type="file" accept=".csv" onChange={e => e.target.files?.[0] && handleFileSelect(e.target.files[0])} className="hidden" id="csv-upload" />
|
||||
<label htmlFor="csv-upload" className="cursor-pointer">
|
||||
<p className="text-sm font-medium text-[#111827]">{file ? file.name : "Click to choose a CSV file"}</p>
|
||||
<p className="text-[10px] text-gray-500 mt-1">CSV file from your online banking</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Column mapping — auto-detected, editable */}
|
||||
{file && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<p className="text-xs font-bold text-gray-600">Column mapping</p>
|
||||
{bankName && <span className="text-[10px] font-bold text-[#16A34A] bg-[#16A34A]/10 px-1.5 py-0.5">{bankName} detected</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ key: "dateCol", label: "Date column" },
|
||||
{ key: "descriptionCol", label: "Description column" },
|
||||
{ key: "creditCol", label: "Amount / Credit column" },
|
||||
{ key: "referenceCol", label: "Reference column" },
|
||||
].map(col => (
|
||||
<div key={col.key} className="space-y-1">
|
||||
<label className="text-[10px] font-bold text-gray-500">{col.label}</label>
|
||||
<input
|
||||
value={mapping[col.key as keyof typeof mapping]}
|
||||
onChange={e => setMapping(m => ({ ...m, [col.key]: e.target.value }))}
|
||||
className="w-full h-8 px-2 border border-gray-200 text-xs focus:border-[#1E40AF] outline-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || uploading}
|
||||
className="w-full bg-[#111827] px-4 py-2.5 text-sm font-bold text-white hover:bg-gray-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{uploading ? "Matching payments..." : "Upload & Match"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{results && (
|
||||
<>
|
||||
{/* Summary — gap-px grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-px bg-gray-200">
|
||||
{[
|
||||
{ value: results.summary.totalRows, label: "Rows" },
|
||||
{ value: results.summary.credits, label: "Incoming payments" },
|
||||
{ value: results.summary.exactMatches, label: "Matched", accent: "text-[#16A34A]" },
|
||||
{ value: results.summary.partialMatches, label: "Possible matches", accent: "text-[#F59E0B]" },
|
||||
{ value: results.summary.autoConfirmed, label: "Auto-confirmed", accent: "text-[#16A34A]" },
|
||||
].map(s => (
|
||||
<div key={s.label} className="bg-white p-4 text-center">
|
||||
<p className={`text-2xl font-black ${s.accent || "text-[#111827]"}`}>{s.value}</p>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Match table */}
|
||||
<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]">Results</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<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">Match</th>
|
||||
<th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Date</th>
|
||||
<th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Description</th>
|
||||
<th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Amount</th>
|
||||
<th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Pledge</th>
|
||||
<th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{results.matches.map((m, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50/50">
|
||||
<td className="px-5 py-3">{confidenceIcon(m.confidence)}</td>
|
||||
<td className="px-5 py-3 text-xs text-gray-600">{m.bankRow.date}</td>
|
||||
<td className="px-5 py-3 text-xs text-gray-600 max-w-[200px] truncate">{m.bankRow.description}</td>
|
||||
<td className="px-5 py-3 text-sm font-black text-[#111827]">£{m.matchedAmount.toFixed(2)}</td>
|
||||
<td className="px-5 py-3 text-xs font-mono text-gray-500">{m.pledgeReference || "—"}</td>
|
||||
<td className="px-5 py-3">
|
||||
{m.autoConfirmed ? (
|
||||
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-[#16A34A]/10 text-[#16A34A]">Auto-confirmed</span>
|
||||
) : m.confidence === "partial" ? (
|
||||
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-[#F59E0B]/10 text-[#F59E0B]">Check this</span>
|
||||
) : m.confidence === "none" ? (
|
||||
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-gray-100 text-gray-500">No match</span>
|
||||
) : (
|
||||
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-[#16A34A]/10 text-[#16A34A]">Matched</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
export default function ReconcileRedirect() {
|
||||
const router = useRouter()
|
||||
useEffect(() => { router.replace("/dashboard/money") }, [router])
|
||||
return null
|
||||
}
|
||||
|
||||
320
temp_files/v3/CustomerResource.php
Normal file
320
temp_files/v3/CustomerResource.php
Normal file
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Definitions\DonationOccurrence;
|
||||
use App\Filament\Resources\CustomerResource\Pages\EditCustomer;
|
||||
use App\Filament\Resources\CustomerResource\Pages\ListCustomers;
|
||||
use App\Filament\Resources\CustomerResource\RelationManagers\AddressesRelationManager;
|
||||
use App\Filament\RelationManagers\InternalNotesRelationManager;
|
||||
use App\Filament\Resources\CustomerResource\RelationManagers\DonationsRelationManager;
|
||||
use App\Filament\Resources\CustomerResource\RelationManagers\ScheduledGivingDonationsRelationManager;
|
||||
use App\Models\Customer;
|
||||
use Filament\Forms\Components\Grid;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\GlobalSearch\Actions\Action as GlobalSearchAction;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class CustomerResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Customer::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-user-circle';
|
||||
|
||||
protected static ?string $navigationGroup = 'Supporter Care';
|
||||
|
||||
protected static ?string $navigationLabel = 'Donors';
|
||||
|
||||
protected static ?string $modelLabel = 'Donor';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Donors';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'email';
|
||||
|
||||
// ─── Global Search (Cmd+K) ────────────────────────────────────
|
||||
// This is how Sahibah finds a donor when they call or email.
|
||||
// She might type an email, a name, or a phone number.
|
||||
|
||||
public static function getGloballySearchableAttributes(): array
|
||||
{
|
||||
return ['first_name', 'last_name', 'email', 'phone'];
|
||||
}
|
||||
|
||||
public static function getGlobalSearchResultTitle(Model $record): string|HtmlString
|
||||
{
|
||||
return $record->name;
|
||||
}
|
||||
|
||||
public static function getGlobalSearchResultDetails(Model $record): array
|
||||
{
|
||||
$donationCount = $record->donations()->count();
|
||||
$totalDonated = $record->donations()
|
||||
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||
->sum('amount') / 100;
|
||||
|
||||
$activeSG = $record->scheduledGivingDonations()->where('is_active', true)->count();
|
||||
|
||||
$details = [
|
||||
'Email' => $record->email,
|
||||
];
|
||||
|
||||
if ($record->phone) {
|
||||
$details['Phone'] = $record->phone;
|
||||
}
|
||||
|
||||
if ($donationCount > 0) {
|
||||
$details['Donations'] = $donationCount . ' (£' . number_format($totalDonated, 0) . ' total)';
|
||||
}
|
||||
|
||||
if ($activeSG > 0) {
|
||||
$details['Monthly Giving'] = $activeSG . ' active';
|
||||
}
|
||||
|
||||
return $details;
|
||||
}
|
||||
|
||||
public static function getGlobalSearchResultActions(Model $record): array
|
||||
{
|
||||
return [
|
||||
GlobalSearchAction::make('edit')
|
||||
->label('Open Donor Profile')
|
||||
->url(static::getUrl('edit', ['record' => $record])),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getGlobalSearchEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getGlobalSearchEloquentQuery()->latest('created_at');
|
||||
}
|
||||
|
||||
public static function getGlobalSearchResultsLimit(): int
|
||||
{
|
||||
return 10;
|
||||
}
|
||||
|
||||
// ─── Form (Edit screen) ──────────────────────────────────────
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Select::make('user_id')
|
||||
->relationship('user', 'email')
|
||||
->searchable()
|
||||
->live()
|
||||
->helperText('The website login account linked to this donor (if any).')
|
||||
->disabled(),
|
||||
|
||||
Grid::make()->schema([
|
||||
Section::make('Personal Details')->schema([
|
||||
Select::make('title')
|
||||
->required()
|
||||
->options(config('donate.titles'))
|
||||
->columnSpan(1),
|
||||
|
||||
TextInput::make('first_name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->columnSpan(2),
|
||||
|
||||
TextInput::make('last_name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->columnSpan(2),
|
||||
])
|
||||
->columns(5)
|
||||
->columnSpan(1),
|
||||
|
||||
Section::make('Contact Information')->schema([
|
||||
TextInput::make('email')
|
||||
->email()
|
||||
->required()
|
||||
->disabled(fn (\Filament\Forms\Get $get) => (bool) $get('user_id'))
|
||||
->live()
|
||||
->maxLength(255)
|
||||
->copyable(),
|
||||
|
||||
TextInput::make('phone')
|
||||
->tel()
|
||||
->maxLength(32)
|
||||
->copyable(),
|
||||
])
|
||||
->columns()
|
||||
->columnSpan(1),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Table (List screen) ─────────────────────────────────────
|
||||
// This is the "Donor Lookup" — staff search by any field and
|
||||
// immediately see key context: are they a monthly giver? how many
|
||||
// donations? This helps them triage before even clicking in.
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Donor')
|
||||
->searchable(['first_name', 'last_name'])
|
||||
->sortable(['first_name'])
|
||||
->weight('bold')
|
||||
->description(fn (Customer $record): ?string => $record->email),
|
||||
|
||||
TextColumn::make('phone')
|
||||
->label('Phone')
|
||||
->searchable()
|
||||
->copyable()
|
||||
->placeholder('—'),
|
||||
|
||||
TextColumn::make('confirmed_total')
|
||||
->label('Total Donated')
|
||||
->getStateUsing(function (Customer $record) {
|
||||
return $record->donations()
|
||||
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||
->sum('amount') / 100;
|
||||
})
|
||||
->money('gbp')
|
||||
->sortable(query: function (Builder $query, string $direction) {
|
||||
$query->withSum([
|
||||
'donations as confirmed_total' => fn ($q) => $q->whereHas('donationConfirmation', fn ($q2) => $q2->whereNotNull('confirmed_at'))
|
||||
], 'amount')->orderBy('confirmed_total', $direction);
|
||||
})
|
||||
->color(fn ($state) => $state >= 1000 ? 'success' : null)
|
||||
->weight(fn ($state) => $state >= 1000 ? 'bold' : null),
|
||||
|
||||
TextColumn::make('donations_count')
|
||||
->label('Donations')
|
||||
->counts('donations')
|
||||
->sortable()
|
||||
->badge()
|
||||
->color(fn ($state) => match(true) {
|
||||
$state >= 50 => 'success',
|
||||
$state >= 10 => 'info',
|
||||
$state > 0 => 'gray',
|
||||
default => 'danger',
|
||||
}),
|
||||
|
||||
TextColumn::make('monthly_giving')
|
||||
->label('Monthly')
|
||||
->getStateUsing(function (Customer $record) {
|
||||
$active = $record->scheduledGivingDonations()->where('is_active', true)->first();
|
||||
if (!$active) return null;
|
||||
return '£' . number_format($active->total_amount, 0) . '/mo';
|
||||
})
|
||||
->badge()
|
||||
->color('success')
|
||||
->placeholder('—'),
|
||||
|
||||
TextColumn::make('gift_aid')
|
||||
->label('Gift Aid')
|
||||
->getStateUsing(function (Customer $record) {
|
||||
return $record->donations()
|
||||
->whereHas('donationPreferences', fn ($q) => $q->where('is_gift_aid', true))
|
||||
->exists() ? 'Yes' : null;
|
||||
})
|
||||
->badge()
|
||||
->color('success')
|
||||
->placeholder('—'),
|
||||
|
||||
TextColumn::make('last_donation')
|
||||
->label('Last Donation')
|
||||
->getStateUsing(function (Customer $record) {
|
||||
$last = $record->donations()
|
||||
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||
->latest()
|
||||
->first();
|
||||
return $last?->created_at;
|
||||
})
|
||||
->since()
|
||||
->placeholder('Never'),
|
||||
])
|
||||
->filters([
|
||||
Filter::make('has_donations')
|
||||
->label('Has donated')
|
||||
->toggle()
|
||||
->query(fn (Builder $q) => $q->has('donations')),
|
||||
|
||||
Filter::make('monthly_supporter')
|
||||
->label('Monthly supporter')
|
||||
->toggle()
|
||||
->query(fn (Builder $q) => $q->whereHas(
|
||||
'scheduledGivingDonations',
|
||||
fn ($q2) => $q2->where('is_active', true)
|
||||
)),
|
||||
|
||||
Filter::make('gift_aid')
|
||||
->label('Gift Aid donors')
|
||||
->toggle()
|
||||
->query(fn (Builder $q) => $q->whereHas(
|
||||
'donations',
|
||||
fn ($q2) => $q2->whereHas('donationPreferences', fn ($q3) => $q3->where('is_gift_aid', true))
|
||||
)),
|
||||
|
||||
Filter::make('major_donor')
|
||||
->label('Major donors (£1000+)')
|
||||
->toggle()
|
||||
->query(function (Builder $q) {
|
||||
$q->whereHas('donations', function ($q2) {
|
||||
$q2->whereHas('donationConfirmation', fn ($q3) => $q3->whereNotNull('confirmed_at'));
|
||||
}, '>=', 1)
|
||||
->withSum([
|
||||
'donations as total_confirmed' => fn ($q2) => $q2->whereHas('donationConfirmation', fn ($q3) => $q3->whereNotNull('confirmed_at'))
|
||||
], 'amount')
|
||||
->having('total_confirmed', '>=', 100000);
|
||||
}),
|
||||
|
||||
Filter::make('incomplete_donations')
|
||||
->label('Has incomplete donations')
|
||||
->toggle()
|
||||
->query(fn (Builder $q) => $q->whereHas(
|
||||
'donations',
|
||||
fn ($q2) => $q2->whereDoesntHave('donationConfirmation', fn ($q3) => $q3->whereNotNull('confirmed_at'))
|
||||
->where('created_at', '>=', now()->subDays(30))
|
||||
)),
|
||||
|
||||
Filter::make('recent')
|
||||
->label('Joined last 30 days')
|
||||
->toggle()
|
||||
->query(fn (Builder $q) => $q->where('created_at', '>=', now()->subDays(30))),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make()
|
||||
->label('Open')
|
||||
->icon('heroicon-o-arrow-right'),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->searchPlaceholder('Search by name, email, or phone...');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
DonationsRelationManager::class,
|
||||
ScheduledGivingDonationsRelationManager::class,
|
||||
AddressesRelationManager::class,
|
||||
InternalNotesRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListCustomers::route('/'),
|
||||
'edit' => EditCustomer::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
177
temp_files/v3/EditCustomer.php
Normal file
177
temp_files/v3/EditCustomer.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\CustomerResource\Pages;
|
||||
|
||||
use App\Filament\Resources\CustomerResource;
|
||||
use App\Filament\Resources\DonationResource;
|
||||
use App\Models\Customer;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class EditCustomer extends EditRecord
|
||||
{
|
||||
protected static string $resource = CustomerResource::class;
|
||||
|
||||
// ─── Heading: Show who this person IS, not just a name ───────
|
||||
// When Sahibah opens a donor, she needs to immediately understand:
|
||||
// "Is this person important? Are they a monthly giver? Gift Aid?"
|
||||
// No clicking, no scrolling — right in the heading.
|
||||
|
||||
public function getHeading(): string|HtmlString
|
||||
{
|
||||
$customer = $this->record;
|
||||
$total = $customer->donations()
|
||||
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||
->sum('amount') / 100;
|
||||
|
||||
$giftAid = $customer->donations()
|
||||
->whereHas('donationPreferences', fn ($q) => $q->where('is_gift_aid', true))
|
||||
->exists();
|
||||
|
||||
$badges = '';
|
||||
if ($total >= 1000) {
|
||||
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-2">⭐ Major Donor</span>';
|
||||
}
|
||||
if ($giftAid) {
|
||||
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 ml-2">Gift Aid</span>';
|
||||
}
|
||||
|
||||
$sg = $customer->scheduledGivingDonations()->where('is_active', true)->first();
|
||||
if ($sg) {
|
||||
$amt = '£' . number_format($sg->total_amount, 0);
|
||||
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 ml-2">💙 ' . $amt . '/month</span>';
|
||||
}
|
||||
|
||||
// Check for incomplete (problem) donations in last 7 days
|
||||
$incompleteRecent = $customer->donations()
|
||||
->whereDoesntHave('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||
->where('created_at', '>=', now()->subDays(7))
|
||||
->count();
|
||||
if ($incompleteRecent > 0) {
|
||||
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 ml-2">⚠ ' . $incompleteRecent . ' incomplete</span>';
|
||||
}
|
||||
|
||||
return new HtmlString($customer->name . $badges);
|
||||
}
|
||||
|
||||
// ─── Subheading: The one-line story of this donor ────────────
|
||||
|
||||
public function getSubheading(): ?string
|
||||
{
|
||||
$customer = $this->record;
|
||||
$confirmed = $customer->donations()
|
||||
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'));
|
||||
$total = $confirmed->sum('amount') / 100;
|
||||
$count = $confirmed->count();
|
||||
$first = $customer->donations()->oldest()->first();
|
||||
$since = $first ? $first->created_at->format('M Y') : null;
|
||||
|
||||
$parts = [];
|
||||
if ($total > 0) {
|
||||
$parts[] = '£' . number_format($total, 2) . ' donated across ' . $count . ' donations';
|
||||
} else {
|
||||
$parts[] = 'No confirmed donations yet';
|
||||
}
|
||||
if ($since) {
|
||||
$parts[] = 'Supporter since ' . $since;
|
||||
}
|
||||
|
||||
return implode(' · ', $parts);
|
||||
}
|
||||
|
||||
// ─── Header Actions: What staff DO when they find a donor ────
|
||||
// These are the actual tasks that take 4+ clicks today:
|
||||
// "Add a note", "Resend a receipt", "Look them up in Stripe"
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
$customer = $this->record;
|
||||
|
||||
return [
|
||||
// Quick note — the #1 thing support staff do
|
||||
Action::make('add_note')
|
||||
->label('Add Note')
|
||||
->icon('heroicon-o-chat-bubble-left-ellipsis')
|
||||
->color('gray')
|
||||
->form([
|
||||
Textarea::make('body')
|
||||
->label('Note')
|
||||
->placeholder("e.g. Called on " . now()->format('d M') . " — wants to update their address")
|
||||
->required()
|
||||
->rows(3),
|
||||
])
|
||||
->action(function (array $data) use ($customer) {
|
||||
$customer->internalNotes()->create([
|
||||
'user_id' => auth()->id(),
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
Notification::make()->title('Note added')->success()->send();
|
||||
}),
|
||||
|
||||
// Resend receipt — second most common request
|
||||
Action::make('resend_receipt')
|
||||
->label('Resend Receipt')
|
||||
->icon('heroicon-o-envelope')
|
||||
->color('info')
|
||||
->form([
|
||||
Select::make('donation_id')
|
||||
->label('Which donation?')
|
||||
->options(function () use ($customer) {
|
||||
return $customer->donations()
|
||||
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||
->latest()
|
||||
->take(10)
|
||||
->get()
|
||||
->mapWithKeys(function ($d) {
|
||||
$label = '£' . number_format($d->amount / 100, 2)
|
||||
. ' on ' . $d->created_at->format('d M Y')
|
||||
. ' — ' . ($d->donationType?->display_name ?? 'Unknown');
|
||||
return [$d->id => $label];
|
||||
});
|
||||
})
|
||||
->required()
|
||||
->helperText('Select the donation to resend the receipt for'),
|
||||
])
|
||||
->visible(fn () => $customer->donations()
|
||||
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||
->exists())
|
||||
->action(function (array $data) use ($customer) {
|
||||
$donation = $customer->donations()->find($data['donation_id']);
|
||||
if ($donation) {
|
||||
try {
|
||||
Mail::to($customer->email)
|
||||
->send(new \App\Mail\DonationConfirmed($donation));
|
||||
Notification::make()
|
||||
->title('Receipt sent to ' . $customer->email)
|
||||
->body('For donation of £' . number_format($donation->amount / 100, 2) . ' on ' . $donation->created_at->format('d M Y'))
|
||||
->success()
|
||||
->send();
|
||||
} catch (\Throwable $e) {
|
||||
Notification::make()->title('Failed to send receipt')->body($e->getMessage())->danger()->send();
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// View in Stripe — for investigating payment issues
|
||||
Action::make('view_in_stripe')
|
||||
->label('Stripe')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url('https://dashboard.stripe.com/search?query=' . urlencode($customer->email))
|
||||
->openUrlInNewTab(),
|
||||
|
||||
// Email the donor directly
|
||||
Action::make('email_donor')
|
||||
->label('Email')
|
||||
->icon('heroicon-o-at-symbol')
|
||||
->color('gray')
|
||||
->url('mailto:' . $customer->email)
|
||||
->openUrlInNewTab(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user