294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, Suspense } from "react"
|
|
import { useSearchParams } from "next/navigation"
|
|
import { Card, CardContent } from "@/components/ui/card"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
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"
|
|
|
|
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 {
|
|
id: string
|
|
reference: string
|
|
amountPence: number
|
|
status: string
|
|
rail: string
|
|
donorName: string | null
|
|
donorEmail: string | null
|
|
donorPhone: string | null
|
|
giftAid: boolean
|
|
eventName: string
|
|
source: string | null
|
|
createdAt: string
|
|
paidAt: string | null
|
|
}
|
|
|
|
function PledgesContent() {
|
|
const searchParams = useSearchParams()
|
|
const eventId = searchParams.get("event")
|
|
const [pledges, setPledges] = useState<PledgeRow[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [search, setSearch] = useState("")
|
|
const [statusFilter, setStatusFilter] = useState<string>("all")
|
|
const [updating, setUpdating] = useState<string | null>(null)
|
|
const [eventName, setEventName] = useState<string | null>(null)
|
|
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => setLoading(false))
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchPledges()
|
|
const interval = setInterval(fetchPledges, 15000)
|
|
return () => clearInterval(interval)
|
|
}, [eventId])
|
|
|
|
const handleStatusChange = async (pledgeId: string, newStatus: string) => {
|
|
setUpdating(pledgeId)
|
|
try {
|
|
const res = 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 {}
|
|
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>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<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))}
|
|
</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>
|
|
</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>
|
|
</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>
|
|
</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>
|
|
)}
|
|
</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>
|
|
)
|
|
}
|