AUTH: - NextAuth with credentials provider (bcrypt password hashing) - /api/auth/signup: creates org + user in transaction - /login, /signup pages with clean minimal UI - Middleware protects all /dashboard/* routes → redirects to /login - Session-based org resolution (no more hardcoded 'demo' headers) - SessionProvider wraps entire app - Dashboard header shows org name + sign out button LANDING PAGE: - Full marketing page at / with hero, problem, how-it-works, features, CTA - 'Get Started Free' → /signup → auto-login → /dashboard/setup - Clean responsive design, no auth required for public pages WAHA QR FIX: - WAHA CORE doesn't expose QR value via API or webhook - Now uses /api/screenshot (full browser capture) with CSS crop to QR area - Settings panel shows cropped screenshot with overflow:hidden - Auto-polls every 5s, refresh button MULTI-TENANT: - getOrgId() tries session first, then header, then first-org fallback - All dashboard APIs use session-based org - Signup creates isolated org per charity
418 lines
19 KiB
TypeScript
418 lines
19 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useEffect, useCallback } from "react"
|
||
import { Card, CardContent } from "@/components/ui/card"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Input } from "@/components/ui/input"
|
||
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"
|
||
|
||
interface Pledge {
|
||
id: string
|
||
reference: string
|
||
amountPence: number
|
||
status: string
|
||
rail: string
|
||
donorName: string | null
|
||
donorEmail: string | null
|
||
donorPhone: string | null
|
||
giftAid: boolean
|
||
dueDate: string | null
|
||
planId: string | null
|
||
installmentNumber: number | null
|
||
installmentTotal: number | null
|
||
eventName: string
|
||
qrSourceLabel: string | null
|
||
volunteerName: string | null
|
||
createdAt: string
|
||
paidAt: string | null
|
||
}
|
||
|
||
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 [page, setPage] = useState(0)
|
||
const [updating, setUpdating] = useState<string | null>(null)
|
||
const { toast } = useToast()
|
||
const pageSize = 25
|
||
|
||
// 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")
|
||
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,
|
||
})
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => { fetchPledges() }, [fetchPledges])
|
||
useEffect(() => { fetchStats() }, [fetchStats])
|
||
|
||
// Auto-refresh
|
||
useEffect(() => {
|
||
const interval = setInterval(fetchPledges, 30000)
|
||
return () => clearInterval(interval)
|
||
}, [fetchPledges])
|
||
|
||
const updateStatus = async (pledgeId: string, newStatus: string) => {
|
||
setUpdating(pledgeId)
|
||
try {
|
||
await fetch(`/api/pledges/${pledgeId}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ status: newStatus }),
|
||
})
|
||
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 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">
|
||
{/* Header */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||
<div>
|
||
<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">
|
||
<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>
|
||
|
||
{/* 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>
|
||
<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>
|
||
) : 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>
|
||
)
|
||
}
|