production: reminder cron, dashboard overhaul, shadcn components, setup wizard
- /api/cron/reminders: processes pending reminders every 15min, sends WhatsApp with email fallback - /api/cron/overdue: marks overdue pledges daily (7d deferred, 14d immediate) - /api/pledges: GET handler with filtering, search, pagination, sort by dueDate - Dashboard overview: stats, collection progress bar, needs attention, upcoming payments - Dashboard pledges: proper table with status tabs, search, actions, pagination - New shadcn components: Table, Tabs, DropdownMenu, Progress - Setup wizard: 4-step onboarding (org → bank → event → QR code) - Settings API: PUT handler for org create/update - Org resolver: single-tenant fallback to first org - Cron jobs installed: reminders every 15min, overdue check at 6am - Auto-generates installment dates when not provided - HOSTNAME=0.0.0.0 in compose for multi-network binding
This commit is contained in:
@@ -1,321 +1,324 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { formatPence } from "@/lib/utils"
|
||||
import { TrendingUp, Users, Banknote, AlertTriangle } from "lucide-react"
|
||||
import { TrendingUp, Users, Banknote, AlertTriangle, Calendar, Clock, CheckCircle2, ArrowRight, Loader2, MessageCircle, ExternalLink } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
interface DashboardData {
|
||||
summary: {
|
||||
totalPledges: number
|
||||
totalPledgedPence: number
|
||||
totalCollectedPence: number
|
||||
collectionRate: number
|
||||
overdueRate: number
|
||||
}
|
||||
summary: { totalPledges: number; totalPledgedPence: number; totalCollectedPence: number; collectionRate: number; overdueRate: number }
|
||||
byStatus: Record<string, number>
|
||||
byRail: Record<string, number>
|
||||
topSources: Array<{ label: string; count: number; amount: number }>
|
||||
pledges: Array<{
|
||||
id: string
|
||||
reference: string
|
||||
amountPence: number
|
||||
status: string
|
||||
rail: string
|
||||
donorName: string | null
|
||||
donorEmail: string | null
|
||||
eventName: string
|
||||
source: string | null
|
||||
createdAt: string
|
||||
paidAt: string | null
|
||||
nextReminder: string | null
|
||||
id: string; reference: string; amountPence: number; status: string; rail: string;
|
||||
donorName: string | null; donorEmail: string | null; donorPhone: string | null;
|
||||
eventName: string; source: string | null; giftAid: boolean;
|
||||
dueDate: string | null; isDeferred: boolean; planId: string | null;
|
||||
installmentNumber: number | null; installmentTotal: number | null;
|
||||
createdAt: string; paidAt: string | null; nextReminder: string | null;
|
||||
}>
|
||||
}
|
||||
|
||||
const statusColors: Record<string, "default" | "secondary" | "success" | "warning" | "destructive" | "outline"> = {
|
||||
new: "secondary",
|
||||
initiated: "warning",
|
||||
paid: "success",
|
||||
overdue: "destructive",
|
||||
cancelled: "outline",
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
new: "New",
|
||||
initiated: "Payment Initiated",
|
||||
paid: "Paid",
|
||||
overdue: "Overdue",
|
||||
cancelled: "Cancelled",
|
||||
}
|
||||
|
||||
const EMPTY_DATA: DashboardData = {
|
||||
summary: { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0, overdueRate: 0 },
|
||||
byStatus: {},
|
||||
byRail: {},
|
||||
topSources: [],
|
||||
pledges: [],
|
||||
}
|
||||
const statusIcons: Record<string, typeof Clock> = { new: Clock, initiated: TrendingUp, paid: CheckCircle2, overdue: AlertTriangle }
|
||||
const statusColors: Record<string, "secondary" | "warning" | "success" | "destructive"> = { new: "secondary", initiated: "warning", paid: "success", overdue: "destructive" }
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [data, setData] = useState<DashboardData>(EMPTY_DATA)
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filter, setFilter] = useState<string>("all")
|
||||
const [whatsappStatus, setWhatsappStatus] = useState<boolean | null>(null)
|
||||
|
||||
const fetchData = () => {
|
||||
fetch("/api/dashboard", {
|
||||
headers: { "x-org-id": "demo" },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (d.summary) setData(d)
|
||||
})
|
||||
const fetchData = useCallback(() => {
|
||||
fetch("/api/dashboard", { headers: { "x-org-id": "demo" } })
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.summary) setData(d) })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
// Auto-refresh every 10 seconds
|
||||
const interval = setInterval(fetchData, 10000)
|
||||
fetch("/api/whatsapp/send").then(r => r.json()).then(d => setWhatsappStatus(d.connected)).catch(() => {})
|
||||
const interval = setInterval(fetchData, 15000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const filteredPledges = filter === "all" ? data.pledges : data.pledges.filter((p) => p.status === filter)
|
||||
|
||||
const handleStatusChange = async (pledgeId: string, newStatus: string) => {
|
||||
try {
|
||||
await fetch(`/api/pledges/${pledgeId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
pledges: prev.pledges.map((p) =>
|
||||
p.id === pledgeId ? { ...p, status: newStatus } : p
|
||||
),
|
||||
}))
|
||||
} catch {
|
||||
// handle error
|
||||
}
|
||||
}
|
||||
}, [fetchData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold text-gray-900">Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-1">Loading...</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i}><CardContent className="pt-6"><div className="h-16 animate-pulse bg-gray-100 rounded-lg" /></CardContent></Card>
|
||||
))}
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="text-center py-20 space-y-4">
|
||||
<Calendar className="h-12 w-12 text-muted-foreground mx-auto" />
|
||||
<h2 className="text-xl font-bold">Welcome to Pledge Now, Pay Later</h2>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
Start by configuring your organisation's bank details, then create your first event.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link href="/dashboard/settings">
|
||||
<Button variant="outline">Configure Bank Details</Button>
|
||||
</Link>
|
||||
<Link href="/dashboard/events">
|
||||
<Button>Create First Event →</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const s = data.summary
|
||||
const upcomingPledges = data.pledges.filter(p =>
|
||||
p.isDeferred && p.dueDate && p.status !== "paid" && p.status !== "cancelled"
|
||||
).sort((a, b) => new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime())
|
||||
const recentPledges = data.pledges.filter(p => p.status !== "cancelled").slice(0, 8)
|
||||
const overduePledges = data.pledges.filter(p => p.status === "overdue")
|
||||
const needsAction = [...overduePledges, ...upcomingPledges.filter(p => {
|
||||
const due = new Date(p.dueDate!)
|
||||
return due.getTime() - Date.now() < 2 * 86400000 // due in 2 days
|
||||
})].slice(0, 5)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold text-gray-900">Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-1">Overview of all pledge activity</p>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-gray-900">Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{whatsappStatus !== null && (
|
||||
<span className={`inline-flex items-center gap-1 mr-3 ${whatsappStatus ? "text-[#25D366]" : "text-muted-foreground"}`}>
|
||||
<MessageCircle className="h-3 w-3" />
|
||||
{whatsappStatus ? "WhatsApp connected" : "WhatsApp offline"}
|
||||
</span>
|
||||
)}
|
||||
Auto-refreshes every 15s
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/dashboard/pledges">
|
||||
<Button variant="outline" size="sm">View All Pledges <ArrowRight className="h-3 w-3 ml-1" /></Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-trust-blue/10 p-2.5">
|
||||
<Users className="h-5 w-5 text-trust-blue" />
|
||||
</div>
|
||||
<div className="rounded-xl bg-trust-blue/10 p-2.5"><Users className="h-5 w-5 text-trust-blue" /></div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{data.summary.totalPledges}</p>
|
||||
<p className="text-2xl font-black">{s.totalPledges}</p>
|
||||
<p className="text-xs text-muted-foreground">Total Pledges</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-warm-amber/10 p-2.5">
|
||||
<Banknote className="h-5 w-5 text-warm-amber" />
|
||||
</div>
|
||||
<div className="rounded-xl bg-warm-amber/10 p-2.5"><Banknote className="h-5 w-5 text-warm-amber" /></div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{formatPence(data.summary.totalPledgedPence)}</p>
|
||||
<p className="text-2xl font-black">{formatPence(s.totalPledgedPence)}</p>
|
||||
<p className="text-xs text-muted-foreground">Total Pledged</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-success-green/10 p-2.5">
|
||||
<TrendingUp className="h-5 w-5 text-success-green" />
|
||||
</div>
|
||||
<div className="rounded-xl bg-success-green/10 p-2.5"><TrendingUp className="h-5 w-5 text-success-green" /></div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{data.summary.collectionRate}%</p>
|
||||
<p className="text-xs text-muted-foreground">Collection Rate</p>
|
||||
<p className="text-2xl font-black">{formatPence(s.totalCollectedPence)}</p>
|
||||
<p className="text-xs text-muted-foreground">Collected ({s.collectionRate}%)</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Card className={s.overdueRate > 10 ? "border-danger-red/30" : ""}>
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-danger-red/10 p-2.5">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-red" />
|
||||
</div>
|
||||
<div className="rounded-xl bg-danger-red/10 p-2.5"><AlertTriangle className="h-5 w-5 text-danger-red" /></div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{data.summary.overdueRate}%</p>
|
||||
<p className="text-xs text-muted-foreground">Overdue Rate</p>
|
||||
<p className="text-2xl font-black">{data.byStatus.overdue || 0}</p>
|
||||
<p className="text-xs text-muted-foreground">Overdue</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Collection progress bar */}
|
||||
{/* Collection progress */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium">Pledged vs Collected</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatPence(data.summary.totalCollectedPence)} of {formatPence(data.summary.totalPledgedPence)}
|
||||
</span>
|
||||
<span className="text-sm font-medium">Pledged → Collected</span>
|
||||
<span className="text-sm font-bold text-muted-foreground">{s.collectionRate}%</span>
|
||||
</div>
|
||||
<div className="h-4 rounded-full bg-gray-100 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-trust-blue to-success-green transition-all duration-1000"
|
||||
style={{ width: `${data.summary.collectionRate}%` }}
|
||||
/>
|
||||
<Progress value={s.collectionRate} indicatorClassName="bg-gradient-to-r from-trust-blue to-success-green" />
|
||||
<div className="flex justify-between mt-2 text-xs text-muted-foreground">
|
||||
<span>{formatPence(s.totalCollectedPence)} collected</span>
|
||||
<span>{formatPence(s.totalPledgedPence - s.totalCollectedPence)} outstanding</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Two-column: Sources + Status */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{/* Top QR sources */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Top Sources</CardTitle>
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
{/* Needs attention */}
|
||||
<Card className={needsAction.length > 0 ? "border-warm-amber/30" : ""}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-warm-amber" /> Needs Attention
|
||||
{needsAction.length > 0 && <Badge variant="warning">{needsAction.length}</Badge>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.topSources.map((src, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-bold text-muted-foreground w-5">{i + 1}</span>
|
||||
<CardContent className="space-y-2">
|
||||
{needsAction.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">All clear! No urgent items.</p>
|
||||
) : (
|
||||
needsAction.map(p => (
|
||||
<div key={p.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{src.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{src.count} pledges</p>
|
||||
<p className="text-sm font-medium">{p.donorName || "Anonymous"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatPence(p.amountPence)} · {p.eventName}
|
||||
{p.dueDate && ` · Due ${new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={p.status === "overdue" ? "destructive" : "warning"}>
|
||||
{p.status === "overdue" ? "Overdue" : "Due soon"}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-sm font-bold">{formatPence(src.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
{needsAction.length > 0 && (
|
||||
<Link href="/dashboard/pledges?tab=overdue" className="text-xs text-trust-blue hover:underline flex items-center gap-1 pt-1">
|
||||
View all <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status breakdown */}
|
||||
{/* Upcoming payments */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">By Status</CardTitle>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-trust-blue" /> Upcoming Payments
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Object.entries(data.byStatus).map(([status, count]) => (
|
||||
<div key={status} className="flex items-center justify-between">
|
||||
<Badge variant={statusColors[status]}>
|
||||
{statusLabels[status] || status}
|
||||
</Badge>
|
||||
<span className="text-sm font-bold">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
<CardContent className="space-y-2">
|
||||
{upcomingPledges.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No scheduled payments</p>
|
||||
) : (
|
||||
upcomingPledges.slice(0, 5).map(p => (
|
||||
<div key={p.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-trust-blue/5 flex items-center justify-center text-xs font-bold text-trust-blue">
|
||||
{new Date(p.dueDate!).getDate()}
|
||||
<br />
|
||||
<span className="text-[8px]">{new Date(p.dueDate!).toLocaleDateString("en-GB", { month: "short" })}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{p.donorName || "Anonymous"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{p.eventName}
|
||||
{p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-bold text-sm">{formatPence(p.amountPence)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Pledge pipeline */}
|
||||
{/* Pipeline + Sources */}
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Pipeline by Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Object.entries(data.byStatus).map(([status, count]) => {
|
||||
const Icon = statusIcons[status] || Clock
|
||||
return (
|
||||
<div key={status} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<Badge variant={statusColors[status] || "secondary"}>{status}</Badge>
|
||||
</div>
|
||||
<span className="font-bold">{count}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Top Sources</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.topSources.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">Create QR codes to track sources</p>
|
||||
) : (
|
||||
data.topSources.slice(0, 6).map((src, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-muted-foreground w-5">{i + 1}</span>
|
||||
<span className="text-sm">{src.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{src.count} pledges</span>
|
||||
</div>
|
||||
<span className="font-bold text-sm">{formatPence(src.amount)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent pledges */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">Pledge Pipeline</CardTitle>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{["all", "new", "initiated", "paid", "overdue", "cancelled"].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFilter(s)}
|
||||
className={`text-xs px-3 py-1.5 rounded-full font-medium transition-colors ${
|
||||
filter === s
|
||||
? "bg-trust-blue text-white"
|
||||
: "bg-gray-100 text-muted-foreground hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{s === "all" ? "All" : statusLabels[s] || s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<CardHeader className="pb-3 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Recent Pledges</CardTitle>
|
||||
<Link href="/dashboard/pledges">
|
||||
<Button variant="ghost" size="sm" className="text-xs">View all <ExternalLink className="h-3 w-3 ml-1" /></Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left">
|
||||
<th className="pb-3 font-medium text-muted-foreground">Reference</th>
|
||||
<th className="pb-3 font-medium text-muted-foreground">Donor</th>
|
||||
<th className="pb-3 font-medium text-muted-foreground">Amount</th>
|
||||
<th className="pb-3 font-medium text-muted-foreground">Rail</th>
|
||||
<th className="pb-3 font-medium text-muted-foreground">Source</th>
|
||||
<th className="pb-3 font-medium text-muted-foreground">Status</th>
|
||||
<th className="pb-3 font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{filteredPledges.map((pledge) => (
|
||||
<tr key={pledge.id} className="hover:bg-gray-50/50">
|
||||
<td className="py-3 font-mono font-bold text-trust-blue">{pledge.reference}</td>
|
||||
<td className="py-3">
|
||||
<div>
|
||||
<p className="font-medium">{pledge.donorName || "Anonymous"}</p>
|
||||
<p className="text-xs text-muted-foreground">{pledge.donorEmail || ""}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 font-bold">{formatPence(pledge.amountPence)}</td>
|
||||
<td className="py-3 capitalize">{pledge.rail}</td>
|
||||
<td className="py-3 text-xs">{pledge.source || "—"}</td>
|
||||
<td className="py-3">
|
||||
<Badge variant={statusColors[pledge.status]}>
|
||||
{statusLabels[pledge.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div className="flex gap-1">
|
||||
{pledge.status !== "paid" && pledge.status !== "cancelled" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleStatusChange(pledge.id, "paid")}
|
||||
className="text-xs px-2 py-1 rounded-lg bg-success-green/10 text-success-green hover:bg-success-green/20 font-medium"
|
||||
>
|
||||
Mark Paid
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStatusChange(pledge.id, "cancelled")}
|
||||
className="text-xs px-2 py-1 rounded-lg bg-danger-red/10 text-danger-red hover:bg-danger-red/20 font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="space-y-2">
|
||||
{recentPledges.map(p => {
|
||||
const sc = statusColors[p.status] || "secondary"
|
||||
return (
|
||||
<div key={p.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-trust-blue/10 flex items-center justify-center text-xs font-bold text-trust-blue">
|
||||
{(p.donorName || "A")[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{p.donorName || "Anonymous"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{p.eventName} · {new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}
|
||||
{p.dueDate && !p.paidAt && ` · Due ${new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`}
|
||||
{p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-sm">{formatPence(p.amountPence)}</span>
|
||||
<Badge variant={sc}>{p.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user