diff --git a/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx b/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx index 821c330..5026a65 100644 --- a/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx @@ -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 = { 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(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
@@ -162,28 +260,26 @@ export default function MoneyPage() {
{/* ── Header ── */} -
-
-

Money

-

- {formatPence(stats.totalCollected)} received of {formatPence(stats.totalPledged)} promised -

-
+
+

Money

+

+ {formatPence(stats.totalCollected)} received of {formatPence(stats.totalPledged)} promised +

- {/* ── Big numbers ── */} + {/* ── Stats bar (clickable filters) ── */}
{[ - { 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 => (
- {/* ── 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. + ═══════════════════════════════════════════════════════ */} +
+ {/* Header + drop zone combined */} +
+
+
+
+ +
+
+

Match bank payments

+

+ Upload your bank statement CSV — we auto-match payments to pledges +

+
+
+ {csvFile && ( + + )} +
+ + {/* Upload area */} + {!matchResults && ( + <> +
+ e.target.files?.[0] && handleFileSelect(e.target.files[0])} + className="hidden" + id="bank-csv-upload" + /> + +
+ + {/* Column mapping (collapsed by default — auto-detected) */} + {csvFile && ( +
+ + + {showMapping && ( +
+ {[ + { key: "dateCol", label: "Date" }, + { key: "descriptionCol", label: "Description" }, + { key: "creditCol", label: "Amount / Credit" }, + { key: "referenceCol", label: "Reference" }, + ].map(col => ( +
+ + 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" + /> +
+ ))} +
+ )} + + +
+ )} + + )} + + {/* ── RESULTS (inline, not a separate page) ── */} + {matchResults && ( +
+ {/* Summary */} +
+ {[ + { 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 => ( +
+

{s.value}

+

{s.label}

+
+ ))} +
+ + {/* Auto-confirmed success message */} + {matchResults.summary.autoConfirmed > 0 && ( +
+ +

+ {matchResults.summary.autoConfirmed} {matchResults.summary.autoConfirmed === 1 ? "pledge" : "pledges"} automatically confirmed as received +

+
+ )} + + {/* Match rows */} +
+
+

Match results

+ +
+
+ + + + + + + + + + + + + {matchResults.matches.map((m, i) => ( + + + + + + + + + ))} + +
DateDescriptionAmountPledge refResult
+ {m.confidence === "exact" || m.autoConfirmed + ? + : m.confidence === "partial" + ? + : } + {m.bankRow.date}{m.bankRow.description}£{m.matchedAmount.toFixed(2)}{m.pledgeReference || "—"} + {m.autoConfirmed ? ( + Confirmed + ) : m.confidence === "exact" ? ( + Matched + ) : m.confidence === "partial" ? ( + Check + ) : ( + No match + )} +
+
+
+
+ )} +
+
+ + {/* ── CONTEXTUAL: Confirm these (said they paid) ── */} + {saidPaid.length > 0 && showContextual && !matchResults && (
-

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

+

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

- - Upload bank statement - + Upload a bank statement above to confirm automatically
{saidPaid.slice(0, 5).map((p: Pledge) => ( @@ -241,14 +521,14 @@ export default function MoneyPage() {
)} - {/* ── CONTEXTUAL SECTION: People who need a nudge ── */} - {overdue.length > 0 && filter === "all" && !search && ( + {/* ── CONTEXTUAL: People who need a nudge ── */} + {overdue.length > 0 && showContextual && (
-
-
- -

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

-
+
+ +

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

{overdue.slice(0, 5).map((p: Pledge) => { @@ -264,18 +544,11 @@ export default function MoneyPage() {
{p.donorPhone && ( - )} -
@@ -293,7 +566,7 @@ export default function MoneyPage() {
)} - {/* ── Search + filter bar ── */} + {/* ── Search + filter ── */}
@@ -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() {
) : (
- {/* Header */}
Donor
Amount
@@ -345,13 +615,10 @@ export default function MoneyPage() {
- {/* Rows */} {tablePledges.map((p: Pledge) => { const sl = STATUS[p.status] || STATUS.new - return (
- {/* Donor */}

{p.donorName || "Anonymous"}

@@ -359,8 +626,6 @@ export default function MoneyPage() { {p.donorPhone && }
- - {/* Amount */}

{formatPence(p.amountPence)}

{p.giftAid && +Gift Aid} @@ -368,24 +633,16 @@ export default function MoneyPage() {

{p.installmentNumber}/{p.installmentTotal}

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

{p.eventName}

{p.qrSourceLabel &&

{p.qrSourceLabel}

}
- - {/* Status */}
{sl.label}
- - {/* When (desktop) */}
{timeAgo(p.createdAt)}
- - {/* Actions */}
@@ -427,32 +684,16 @@ export default function MoneyPage() {
)} - {/* ── Pagination ── */} + {/* Pagination */} {totalPages > 1 && (

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

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

Match bank payments

-

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

-
- -
-
) } diff --git a/pledge-now-pay-later/src/app/dashboard/reconcile/page.tsx b/pledge-now-pay-later/src/app/dashboard/reconcile/page.tsx index 0d0f294..ef640d7 100644 --- a/pledge-now-pay-later/src/app/dashboard/reconcile/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/reconcile/page.tsx @@ -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(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 - case "partial": return - default: return - } - } - - return ( -
-
- - Back to Money - -

Match Payments

-

Upload your bank statement and we'll match payments to pledges automatically

-
- - {/* Upload section */} -
-
-

Upload bank statement

-

- Download a CSV from your bank's website and upload it here. We recognise formats from Barclays, HSBC, Lloyds, NatWest, Monzo, Starling, and more. -

-
- - {/* File drop zone */} -
- - e.target.files?.[0] && handleFileSelect(e.target.files[0])} className="hidden" id="csv-upload" /> - -
- - {/* Column mapping — auto-detected, editable */} - {file && ( -
-
-

Column mapping

- {bankName && {bankName} detected} -
-
- {[ - { key: "dateCol", label: "Date column" }, - { key: "descriptionCol", label: "Description column" }, - { key: "creditCol", label: "Amount / Credit column" }, - { key: "referenceCol", label: "Reference column" }, - ].map(col => ( -
- - 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" - /> -
- ))} -
-
- )} - - -
- - {/* Results */} - {results && ( - <> - {/* Summary — gap-px grid */} -
- {[ - { 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 => ( -
-

{s.value}

-

{s.label}

-
- ))} -
- - {/* Match table */} -
-
-

Results

-
-
- - - - - - - - - - - - - {results.matches.map((m, i) => ( - - - - - - - - - ))} - -
MatchDateDescriptionAmountPledgeStatus
{confidenceIcon(m.confidence)}{m.bankRow.date}{m.bankRow.description}£{m.matchedAmount.toFixed(2)}{m.pledgeReference || "—"} - {m.autoConfirmed ? ( - Auto-confirmed - ) : m.confidence === "partial" ? ( - Check this - ) : m.confidence === "none" ? ( - No match - ) : ( - Matched - )} -
-
-
- - )} -
- ) +export default function ReconcileRedirect() { + const router = useRouter() + useEffect(() => { router.replace("/dashboard/money") }, [router]) + return null } diff --git a/temp_files/v3/CustomerResource.php b/temp_files/v3/CustomerResource.php new file mode 100644 index 0000000..12d950c --- /dev/null +++ b/temp_files/v3/CustomerResource.php @@ -0,0 +1,320 @@ +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'), + ]; + } +} diff --git a/temp_files/v3/EditCustomer.php b/temp_files/v3/EditCustomer.php new file mode 100644 index 0000000..62b8881 --- /dev/null +++ b/temp_files/v3/EditCustomer.php @@ -0,0 +1,177 @@ +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 .= ' ⭐ Major Donor'; + } + if ($giftAid) { + $badges .= ' Gift Aid'; + } + + $sg = $customer->scheduledGivingDonations()->where('is_active', true)->first(); + if ($sg) { + $amt = '£' . number_format($sg->total_amount, 0); + $badges .= ' 💙 ' . $amt . '/month'; + } + + // 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 .= ' ⚠ ' . $incompleteRecent . ' incomplete'; + } + + 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(), + ]; + } +}