Files
calvana/pledge-now-pay-later/src/app/dashboard/pledges/page.tsx

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>
)
}