feat: add improved pi agent with observatory, dashboard, and pledge-now-pay-later

This commit is contained in:
Azreen Jamal
2026-03-01 23:41:24 +08:00
parent ae242436c9
commit f832b913d5
99 changed files with 20949 additions and 74 deletions

View File

@@ -0,0 +1,324 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { formatPence } from "@/lib/utils"
import { TrendingUp, Users, Banknote, AlertTriangle } from "lucide-react"
interface DashboardData {
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
}>
}
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: [],
}
export default function DashboardPage() {
const [data, setData] = useState<DashboardData>(EMPTY_DATA)
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<string>("all")
const fetchData = () => {
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)
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
}
}
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>
</div>
)
}
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>
{/* Stats cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<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>
<p className="text-2xl font-bold">{data.summary.totalPledges}</p>
<p className="text-xs text-muted-foreground">Total Pledges</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<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>
<p className="text-2xl font-bold">{formatPence(data.summary.totalPledgedPence)}</p>
<p className="text-xs text-muted-foreground">Total Pledged</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<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>
<p className="text-2xl font-bold">{data.summary.collectionRate}%</p>
<p className="text-xs text-muted-foreground">Collection Rate</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<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>
<p className="text-2xl font-bold">{data.summary.overdueRate}%</p>
<p className="text-xs text-muted-foreground">Overdue Rate</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Collection progress bar */}
<Card>
<CardContent className="pt-6">
<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>
</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}%` }}
/>
</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>
</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>
<div>
<p className="text-sm font-medium">{src.label}</p>
<p className="text-xs text-muted-foreground">{src.count} pledges</p>
</div>
</div>
<span className="text-sm font-bold">{formatPence(src.amount)}</span>
</div>
))}
</CardContent>
</Card>
{/* Status breakdown */}
<Card>
<CardHeader>
<CardTitle className="text-lg">By Status</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>
</Card>
</div>
{/* Pledge pipeline */}
<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>
<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>
</CardContent>
</Card>
</div>
)
}