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,39 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, Suspense } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { formatPence } from "@/lib/utils"
|
||||
import { Search, Loader2, Download, RefreshCw, ArrowLeft } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { useToast } from "@/components/ui/toast"
|
||||
import {
|
||||
Search, MoreVertical, Calendar, Clock, AlertTriangle,
|
||||
CheckCircle2, XCircle, MessageCircle, Send, Filter,
|
||||
ChevronLeft, ChevronRight, Users, Loader2
|
||||
} from "lucide-react"
|
||||
|
||||
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: "Initiated",
|
||||
paid: "Paid",
|
||||
overdue: "Overdue",
|
||||
cancelled: "Cancelled",
|
||||
}
|
||||
|
||||
const railLabels: Record<string, string> = {
|
||||
bank: "Bank Transfer",
|
||||
gocardless: "Direct Debit",
|
||||
card: "Card",
|
||||
fpx: "FPX",
|
||||
}
|
||||
|
||||
interface PledgeRow {
|
||||
interface Pledge {
|
||||
id: string
|
||||
reference: string
|
||||
amountPence: number
|
||||
@@ -43,252 +26,392 @@ interface PledgeRow {
|
||||
donorEmail: string | null
|
||||
donorPhone: string | null
|
||||
giftAid: boolean
|
||||
dueDate: string | null
|
||||
planId: string | null
|
||||
installmentNumber: number | null
|
||||
installmentTotal: number | null
|
||||
eventName: string
|
||||
source: string | null
|
||||
qrSourceLabel: string | null
|
||||
volunteerName: string | null
|
||||
createdAt: string
|
||||
paidAt: string | null
|
||||
}
|
||||
|
||||
function PledgesContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const eventId = searchParams.get("event")
|
||||
const [pledges, setPledges] = useState<PledgeRow[]>([])
|
||||
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "success" | "warning" | "destructive"; icon: typeof Clock }> = {
|
||||
new: { label: "Pending", variant: "secondary", icon: Clock },
|
||||
initiated: { label: "Initiated", variant: "warning", icon: Send },
|
||||
paid: { label: "Paid", variant: "success", icon: CheckCircle2 },
|
||||
overdue: { label: "Overdue", variant: "destructive", icon: AlertTriangle },
|
||||
cancelled: { label: "Cancelled", variant: "secondary", icon: XCircle },
|
||||
}
|
||||
|
||||
const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}`
|
||||
|
||||
function timeAgo(dateStr: string) {
|
||||
const d = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const days = Math.floor(diff / 86400000)
|
||||
if (days === 0) return "Today"
|
||||
if (days === 1) return "Yesterday"
|
||||
if (days < 7) return `${days}d ago`
|
||||
if (days < 30) return `${Math.floor(days / 7)}w ago`
|
||||
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" })
|
||||
}
|
||||
|
||||
function dueLabel(dueDate: string) {
|
||||
const d = new Date(dueDate)
|
||||
const now = new Date()
|
||||
const diff = d.getTime() - now.getTime()
|
||||
const days = Math.ceil(diff / 86400000)
|
||||
if (days < 0) return { text: `${Math.abs(days)}d overdue`, urgent: true }
|
||||
if (days === 0) return { text: "Due today", urgent: true }
|
||||
if (days === 1) return { text: "Due tomorrow", urgent: false }
|
||||
if (days <= 7) return { text: `Due in ${days}d`, urgent: false }
|
||||
return { text: d.toLocaleDateString("en-GB", { day: "numeric", month: "short" }), urgent: false }
|
||||
}
|
||||
|
||||
export default function PledgesPage() {
|
||||
const [pledges, setPledges] = useState<Pledge[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tab, setTab] = useState("all")
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||
const [page, setPage] = useState(0)
|
||||
const [updating, setUpdating] = useState<string | null>(null)
|
||||
const [eventName, setEventName] = useState<string | null>(null)
|
||||
const { toast } = useToast()
|
||||
const pageSize = 25
|
||||
|
||||
const fetchPledges = () => {
|
||||
const url = eventId
|
||||
? `/api/dashboard?eventId=${eventId}`
|
||||
: "/api/dashboard"
|
||||
fetch(url, { headers: { "x-org-id": "demo" } })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.pledges) {
|
||||
setPledges(data.pledges)
|
||||
if (eventId && data.pledges.length > 0) {
|
||||
setEventName(data.pledges[0].eventName)
|
||||
}
|
||||
}
|
||||
// Stats
|
||||
const [stats, setStats] = useState({ total: 0, pending: 0, dueSoon: 0, overdue: 0, paid: 0, totalPledged: 0, totalCollected: 0 })
|
||||
|
||||
const fetchPledges = useCallback(async () => {
|
||||
const params = new URLSearchParams()
|
||||
params.set("limit", String(pageSize))
|
||||
params.set("offset", String(page * pageSize))
|
||||
if (tab !== "all") {
|
||||
if (tab === "due-soon") params.set("dueSoon", "true")
|
||||
else if (tab === "overdue") params.set("overdue", "true")
|
||||
else params.set("status", tab)
|
||||
}
|
||||
if (search) params.set("search", search)
|
||||
params.set("sort", tab === "due-soon" ? "dueDate" : "createdAt")
|
||||
params.set("dir", tab === "due-soon" ? "asc" : "desc")
|
||||
|
||||
const res = await fetch(`/api/pledges?${params}`)
|
||||
const data = await res.json()
|
||||
setPledges(data.pledges || [])
|
||||
setTotal(data.total || 0)
|
||||
setLoading(false)
|
||||
}, [tab, search, page])
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
const res = await fetch("/api/dashboard", { headers: { "x-org-id": "demo" } })
|
||||
const data = await res.json()
|
||||
if (data.summary) {
|
||||
setStats({
|
||||
total: data.summary.totalPledges,
|
||||
pending: data.byStatus?.new || 0,
|
||||
dueSoon: 0, // calculated client-side
|
||||
overdue: data.byStatus?.overdue || 0,
|
||||
paid: data.byStatus?.paid || 0,
|
||||
totalPledged: data.summary.totalPledgedPence,
|
||||
totalCollected: data.summary.totalCollectedPence,
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchPledges() }, [fetchPledges])
|
||||
useEffect(() => { fetchStats() }, [fetchStats])
|
||||
|
||||
// Auto-refresh
|
||||
useEffect(() => {
|
||||
fetchPledges()
|
||||
const interval = setInterval(fetchPledges, 15000)
|
||||
const interval = setInterval(fetchPledges, 30000)
|
||||
return () => clearInterval(interval)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [eventId])
|
||||
}, [fetchPledges])
|
||||
|
||||
const handleStatusChange = async (pledgeId: string, newStatus: string) => {
|
||||
const updateStatus = async (pledgeId: string, newStatus: string) => {
|
||||
setUpdating(pledgeId)
|
||||
try {
|
||||
const res = await fetch(`/api/pledges/${pledgeId}`, {
|
||||
await fetch(`/api/pledges/${pledgeId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setPledges((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === pledgeId
|
||||
? { ...p, status: newStatus, paidAt: newStatus === "paid" ? new Date().toISOString() : p.paidAt }
|
||||
: p
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch {}
|
||||
setPledges(prev => prev.map(p => p.id === pledgeId ? { ...p, status: newStatus } : p))
|
||||
toast(`Pledge marked as ${newStatus}`, "success")
|
||||
} catch {
|
||||
toast("Failed to update", "error")
|
||||
}
|
||||
setUpdating(null)
|
||||
}
|
||||
|
||||
const filtered = pledges.filter((p) => {
|
||||
const matchSearch =
|
||||
!search ||
|
||||
p.reference.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.donorName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.donorEmail?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.donorPhone?.includes(search)
|
||||
const matchStatus = statusFilter === "all" || p.status === statusFilter
|
||||
return matchSearch && matchStatus
|
||||
})
|
||||
|
||||
const statusCounts = pledges.reduce((acc, p) => {
|
||||
acc[p.status] = (acc[p.status] || 0) + 1
|
||||
return acc
|
||||
}, {} as Record<string, number>)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
|
||||
</div>
|
||||
)
|
||||
const sendReminder = async (pledge: Pledge) => {
|
||||
if (!pledge.donorPhone) {
|
||||
toast("No phone number — can't send WhatsApp", "error")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fetch("/api/whatsapp/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "reminder",
|
||||
phone: pledge.donorPhone,
|
||||
data: {
|
||||
donorName: pledge.donorName,
|
||||
amountPounds: (pledge.amountPence / 100).toFixed(0),
|
||||
eventName: pledge.eventName,
|
||||
reference: pledge.reference,
|
||||
daysSincePledge: Math.floor((Date.now() - new Date(pledge.createdAt).getTime()) / 86400000),
|
||||
step: 1,
|
||||
},
|
||||
}),
|
||||
})
|
||||
toast("Reminder sent via WhatsApp ✓", "success")
|
||||
} catch {
|
||||
toast("Failed to send", "error")
|
||||
}
|
||||
}
|
||||
|
||||
const collectionRate = stats.totalPledged > 0 ? Math.round((stats.totalCollected / stats.totalPledged) * 100) : 0
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
{eventId && (
|
||||
<Link
|
||||
href="/dashboard/events"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1 mb-2"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" /> Back to Events
|
||||
</Link>
|
||||
)}
|
||||
<h1 className="text-3xl font-extrabold text-gray-900">
|
||||
{eventName ? `${eventName} — Pledges` : "All Pledges"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{pledges.length} pledge{pledges.length !== 1 ? "s" : ""} totalling{" "}
|
||||
{formatPence(pledges.reduce((s, p) => s + p.amountPence, 0))}
|
||||
<h1 className="text-2xl font-black text-gray-900">Pledges</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{stats.total} total · {formatPence(stats.totalPledged)} pledged · {collectionRate}% collected
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchPledges}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" /> Refresh
|
||||
</Button>
|
||||
<a href={`/api/exports/crm-pack${eventId ? `?eventId=${eventId}` : ""}`} download>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-4 w-4 mr-1" /> Export CSV
|
||||
</Button>
|
||||
</a>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search name, email, ref..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0) }}
|
||||
className="pl-9 w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<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-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by reference, name, email, or phone..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{["all", "new", "initiated", "paid", "overdue", "cancelled"].map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={`text-xs px-3 py-2 rounded-xl font-medium transition-colors whitespace-nowrap ${
|
||||
statusFilter === s ? "bg-trust-blue text-white" : "bg-gray-100 text-muted-foreground hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{s === "all" ? `All (${pledges.length})` : `${statusLabels[s]} (${statusCounts[s] || 0})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pledges list */}
|
||||
{filtered.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{search ? "No pledges match your search." : "No pledges yet. Share a QR code to get started!"}
|
||||
</p>
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setTab("all")}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-trust-blue" />
|
||||
<span className="text-xs text-muted-foreground">All</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold mt-1">{stats.total}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filtered.map((p) => (
|
||||
<Card key={p.id} className="hover:shadow-sm transition-shadow">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono font-bold text-trust-blue">{p.reference}</span>
|
||||
<Badge variant={statusColors[p.status]}>{statusLabels[p.status]}</Badge>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-muted-foreground">
|
||||
{railLabels[p.rail] || p.rail}
|
||||
</span>
|
||||
{p.giftAid && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-50 text-green-700 font-medium">
|
||||
Gift Aid ✓
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-semibold">{p.donorName || "Anonymous"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[p.donorEmail, p.donorPhone].filter(Boolean).join(" · ") || "No contact info"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{p.eventName}{p.source ? ` · ${p.source}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-xl font-bold">{formatPence(p.amountPence)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(p.createdAt).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
{p.paidAt && (
|
||||
<p className="text-xs text-success-green font-medium">
|
||||
Paid {new Date(p.paidAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setTab("new")}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-warm-amber" />
|
||||
<span className="text-xs text-muted-foreground">Pending</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold mt-1">{stats.pending}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow border-warm-amber/30" onClick={() => setTab("due-soon")}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-warm-amber" />
|
||||
<span className="text-xs text-muted-foreground">Due Soon</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold mt-1 text-warm-amber">{stats.dueSoon || "—"}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow border-danger-red/30" onClick={() => setTab("overdue")}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-danger-red" />
|
||||
<span className="text-xs text-muted-foreground">Overdue</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold mt-1 text-danger-red">{stats.overdue}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setTab("paid")}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-success-green" />
|
||||
<span className="text-xs text-muted-foreground">Paid</span>
|
||||
</div>
|
||||
<p className="text-xl font-bold mt-1 text-success-green">{stats.paid}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Collection progress */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Progress value={collectionRate} className="flex-1 h-2" indicatorClassName="bg-gradient-to-r from-trust-blue to-success-green" />
|
||||
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
|
||||
{formatPence(stats.totalCollected)} / {formatPence(stats.totalPledged)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tabs + Table */}
|
||||
<Tabs value={tab} onValueChange={(v) => { setTab(v); setPage(0) }}>
|
||||
<TabsList className="w-full sm:w-auto overflow-x-auto">
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="new">Pending</TabsTrigger>
|
||||
<TabsTrigger value="due-soon">Due Soon</TabsTrigger>
|
||||
<TabsTrigger value="overdue">Overdue</TabsTrigger>
|
||||
<TabsTrigger value="initiated">Initiated</TabsTrigger>
|
||||
<TabsTrigger value="paid">Paid</TabsTrigger>
|
||||
<TabsTrigger value="cancelled">Cancelled</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={tab}>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 text-trust-blue animate-spin" />
|
||||
</div>
|
||||
{p.status !== "paid" && p.status !== "cancelled" && (
|
||||
<div className="flex gap-2 mt-3 pt-3 border-t">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="success"
|
||||
className="text-xs"
|
||||
disabled={updating === p.id}
|
||||
onClick={() => handleStatusChange(p.id, "paid")}
|
||||
>
|
||||
{updating === p.id ? "..." : "Mark Paid"}
|
||||
</Button>
|
||||
{p.status === "new" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
disabled={updating === p.id}
|
||||
onClick={() => handleStatusChange(p.id, "initiated")}
|
||||
>
|
||||
Mark Initiated
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-xs text-danger-red ml-auto"
|
||||
disabled={updating === p.id}
|
||||
onClick={() => handleStatusChange(p.id, "cancelled")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : pledges.length === 0 ? (
|
||||
<div className="text-center py-16 space-y-3">
|
||||
<Filter className="h-8 w-8 text-muted-foreground mx-auto" />
|
||||
<p className="font-medium text-gray-900">No pledges found</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{search ? `No results for "${search}"` : "Create an event and share QR codes to start collecting pledges"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Donor</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Event</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Due / Created</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Method</TableHead>
|
||||
<TableHead className="w-10"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pledges.map((p) => {
|
||||
const sc = statusConfig[p.status] || statusConfig.new
|
||||
const due = p.dueDate ? dueLabel(p.dueDate) : null
|
||||
const isInstallment = p.installmentTotal && p.installmentTotal > 1
|
||||
|
||||
return (
|
||||
<TableRow key={p.id} className={updating === p.id ? "opacity-50" : ""}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{p.donorName || "Anonymous"}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">{p.reference}</p>
|
||||
{p.donorPhone && (
|
||||
<p className="text-[10px] text-[#25D366] flex items-center gap-0.5 mt-0.5">
|
||||
<MessageCircle className="h-2.5 w-2.5" /> WhatsApp
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="font-bold">{formatPence(p.amountPence)}</p>
|
||||
{p.giftAid && <span className="text-[10px] text-success-green">🎁 +Gift Aid</span>}
|
||||
{isInstallment && (
|
||||
<p className="text-[10px] text-warm-amber font-medium">
|
||||
{p.installmentNumber}/{p.installmentTotal}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<p className="text-sm truncate max-w-[140px]">{p.eventName}</p>
|
||||
{p.qrSourceLabel && (
|
||||
<p className="text-[10px] text-muted-foreground">{p.qrSourceLabel}</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={sc.variant} className="gap-1 text-[11px]">
|
||||
<sc.icon className="h-3 w-3" /> {sc.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
{due ? (
|
||||
<span className={`text-xs font-medium ${due.urgent ? "text-danger-red" : "text-muted-foreground"}`}>
|
||||
{due.urgent && "⚠ "}{due.text}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{timeAgo(p.createdAt)}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<span className="text-xs capitalize text-muted-foreground">
|
||||
{p.rail === "gocardless" ? "Direct Debit" : p.rail}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="p-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||
<MoreVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{p.status !== "paid" && (
|
||||
<DropdownMenuItem onClick={() => updateStatus(p.id, "paid")}>
|
||||
<CheckCircle2 className="h-4 w-4 text-success-green" /> Mark Paid
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{p.status !== "initiated" && p.status !== "paid" && (
|
||||
<DropdownMenuItem onClick={() => updateStatus(p.id, "initiated")}>
|
||||
<Send className="h-4 w-4 text-warm-amber" /> Mark Initiated
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{p.donorPhone && p.status !== "paid" && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => sendReminder(p)}>
|
||||
<MessageCircle className="h-4 w-4 text-[#25D366]" /> Send WhatsApp Reminder
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{p.status !== "cancelled" && p.status !== "paid" && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem destructive onClick={() => updateStatus(p.id, "cancelled")}>
|
||||
<XCircle className="h-4 w-4" /> Cancel Pledge
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total}
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage(p => p - 1)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages - 1} onClick={() => setPage(p => p + 1)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PledgesPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
|
||||
</div>
|
||||
}>
|
||||
<PledgesContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user