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,92 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select } from "@/components/ui/select"
import { TrendingUp, Shield, Zap } from "lucide-react"
export default function ApplyPage() {
return (
<div className="space-y-8 max-w-3xl">
<div className="text-center space-y-4">
<h1 className="text-3xl font-extrabold text-gray-900">
Fractional Head of Technology
</h1>
<p className="text-lg text-muted-foreground max-w-xl mx-auto">
Get expert technology leadership for your charity without the full-time cost.
</p>
</div>
{/* Benefits */}
<div className="grid sm:grid-cols-3 gap-4">
{[
{ icon: TrendingUp, title: "Optimise Your Stack", desc: "Reduce costs, improve donor experience, integrate tools" },
{ icon: Shield, title: "Data & Compliance", desc: "GDPR, consent management, security best practices" },
{ icon: Zap, title: "Automate Everything", desc: "Connect your CRM, comms, payments, and reporting" },
].map((b, i) => (
<Card key={i}>
<CardContent className="pt-6 text-center space-y-2">
<b.icon className="h-8 w-8 text-trust-blue mx-auto" />
<h3 className="font-bold">{b.title}</h3>
<p className="text-xs text-muted-foreground">{b.desc}</p>
</CardContent>
</Card>
))}
</div>
{/* Application form */}
<Card>
<CardHeader>
<CardTitle>Apply</CardTitle>
<CardDescription>Tell us about your charity&apos;s tech needs</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Your Name</Label>
<Input placeholder="Full name" />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input type="email" placeholder="you@charity.org" />
</div>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Organisation</Label>
<Input placeholder="Charity name" />
</div>
<div className="space-y-2">
<Label>Annual Budget (approx)</Label>
<Select>
<option value="">Select...</option>
<option>Under £100k</option>
<option>£100k - £500k</option>
<option>£500k - £1M</option>
<option>£1M - £5M</option>
<option>Over £5M</option>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Current Tech Stack</Label>
<Textarea placeholder="e.g. Salesforce CRM, Mailchimp, WordPress, manual spreadsheets..." />
</div>
<div className="space-y-2">
<Label>Biggest Tech Challenge</Label>
<Textarea placeholder="What's the biggest technology pain point you're facing?" />
</div>
<Button size="lg" className="w-full">
Submit Application
</Button>
<p className="text-xs text-muted-foreground text-center">
We&apos;ll review your application and get back within 48 hours.
</p>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,241 @@
"use client"
import { useState, useEffect } from "react"
import { useParams } from "next/navigation"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { formatPence } from "@/lib/utils"
import { Plus, Download, QrCode, ExternalLink, Copy, Check, Loader2, ArrowLeft } from "lucide-react"
import Link from "next/link"
import { QRCodeCanvas } from "@/components/qr-code"
interface QrSourceInfo {
id: string
label: string
code: string
volunteerName: string | null
tableName: string | null
scanCount: number
pledgeCount: number
totalPledged: number
}
export default function EventQRPage() {
const params = useParams()
const eventId = params.id as string
const [qrSources, setQrSources] = useState<QrSourceInfo[]>([])
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [copiedCode, setCopiedCode] = useState<string | null>(null)
const [form, setForm] = useState({ label: "", volunteerName: "", tableName: "" })
const [creating, setCreating] = useState(false)
const baseUrl = typeof window !== "undefined" ? window.location.origin : ""
useEffect(() => {
fetch(`/api/events/${eventId}/qr`)
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data)) setQrSources(data)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [eventId])
const copyLink = async (code: string) => {
await navigator.clipboard.writeText(`${baseUrl}/p/${code}`)
setCopiedCode(code)
setTimeout(() => setCopiedCode(null), 2000)
}
const handleCreate = async () => {
setCreating(true)
try {
const res = await fetch(`/api/events/${eventId}/qr`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
})
if (res.ok) {
const qr = await res.json()
setQrSources((prev) => [{ ...qr, scanCount: 0, pledgeCount: 0, totalPledged: 0 }, ...prev])
setShowCreate(false)
setForm({ label: "", volunteerName: "", tableName: "" })
}
} catch {}
setCreating(false)
}
// Auto-generate label
useEffect(() => {
if (form.volunteerName || form.tableName) {
const parts = [form.tableName, form.volunteerName].filter(Boolean)
setForm((f) => ({ ...f, label: parts.join(" - ") }))
}
}, [form.volunteerName, form.tableName])
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 totalScans = qrSources.reduce((s, q) => s + q.scanCount, 0)
const totalPledges = qrSources.reduce((s, q) => s + q.pledgeCount, 0)
const totalAmount = qrSources.reduce((s, q) => s + q.totalPledged, 0)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<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">QR Codes</h1>
<p className="text-muted-foreground mt-1">
{qrSources.length} QR code{qrSources.length !== 1 ? "s" : ""} · {totalScans} scans · {totalPledges} pledges · {formatPence(totalAmount)}
</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus className="h-4 w-4 mr-2" /> New QR Code
</Button>
</div>
{/* QR Grid */}
{qrSources.length === 0 ? (
<Card>
<CardContent className="py-12 text-center space-y-4">
<QrCode className="h-12 w-12 text-muted-foreground mx-auto" />
<p className="text-muted-foreground">No QR codes yet. Create one to start collecting pledges!</p>
<Button onClick={() => setShowCreate(true)}>
<Plus className="h-4 w-4 mr-2" /> Create First QR Code
</Button>
</CardContent>
</Card>
) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{qrSources.map((qr) => (
<Card key={qr.id} className="hover:shadow-md transition-shadow">
<CardContent className="pt-6 space-y-4">
{/* QR Code */}
<div className="max-w-[180px] mx-auto bg-white rounded-2xl flex items-center justify-center p-2">
<QRCodeCanvas url={`${baseUrl}/p/${qr.code}`} size={164} />
</div>
<div className="text-center">
<h3 className="font-bold">{qr.label}</h3>
{qr.volunteerName && (
<p className="text-xs text-muted-foreground">Volunteer: {qr.volunteerName}</p>
)}
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-2 text-center text-xs">
<div className="rounded-lg bg-gray-50 p-2">
<p className="font-bold text-sm">{qr.scanCount}</p>
<p className="text-muted-foreground">Scans</p>
</div>
<div className="rounded-lg bg-gray-50 p-2">
<p className="font-bold text-sm">{qr.pledgeCount}</p>
<p className="text-muted-foreground">Pledges</p>
</div>
<div className="rounded-lg bg-gray-50 p-2">
<p className="font-bold text-sm">{formatPence(qr.totalPledged)}</p>
<p className="text-muted-foreground">Total</p>
</div>
</div>
{/* Conversion rate */}
{qr.scanCount > 0 && (
<div className="text-center">
<span className="text-xs text-muted-foreground">
Conversion: <span className="font-semibold text-foreground">{Math.round((qr.pledgeCount / qr.scanCount) * 100)}%</span>
</span>
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => copyLink(qr.code)}
>
{copiedCode === qr.code ? (
<><Check className="h-3 w-3 mr-1" /> Copied</>
) : (
<><Copy className="h-3 w-3 mr-1" /> Link</>
)}
</Button>
<a href={`/api/events/${eventId}/qr/${qr.id}/download?code=${qr.code}`} download>
<Button variant="outline" size="sm">
<Download className="h-3 w-3 mr-1" /> PNG
</Button>
</a>
<a href={`/p/${qr.code}`} target="_blank">
<Button variant="outline" size="sm">
<ExternalLink className="h-3 w-3" />
</Button>
</a>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Create dialog */}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogHeader>
<DialogTitle>Create QR Code</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Table Name</Label>
<Input
placeholder="e.g. Table 5"
value={form.tableName}
onChange={(e) => setForm((f) => ({ ...f, tableName: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Volunteer Name</Label>
<Input
placeholder="e.g. Ahmed"
value={form.volunteerName}
onChange={(e) => setForm((f) => ({ ...f, volunteerName: e.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label>Label *</Label>
<Input
placeholder="e.g. Table 5 - Ahmed"
value={form.label}
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
/>
<p className="text-xs text-muted-foreground">Auto-generated from table + volunteer, or enter custom</p>
</div>
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => setShowCreate(false)} className="flex-1">
Cancel
</Button>
<Button onClick={handleCreate} disabled={!form.label || creating} className="flex-1">
{creating ? "Creating..." : "Create QR Code"}
</Button>
</div>
</div>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,225 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { formatPence } from "@/lib/utils"
import { Plus, QrCode, Calendar, MapPin, Target } from "lucide-react"
import Link from "next/link"
interface EventSummary {
id: string
name: string
slug: string
eventDate: string | null
location: string | null
goalAmount: number | null
status: string
pledgeCount: number
qrSourceCount: number
totalPledged: number
totalCollected: number
}
export default function EventsPage() {
const [events, setEvents] = useState<EventSummary[]>([])
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
useEffect(() => {
fetch("/api/events", { headers: { "x-org-id": "demo" } })
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data)) setEvents(data)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const [creating, setCreating] = useState(false)
const [form, setForm] = useState({ name: "", description: "", location: "", eventDate: "", goalAmount: "" })
const handleCreate = async () => {
setCreating(true)
try {
const res = await fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json", "x-org-id": "demo" },
body: JSON.stringify({
...form,
goalAmount: form.goalAmount ? Math.round(parseFloat(form.goalAmount) * 100) : undefined,
eventDate: form.eventDate ? new Date(form.eventDate).toISOString() : undefined,
}),
})
if (res.ok) {
const event = await res.json()
setEvents((prev) => [{ ...event, pledgeCount: 0, qrSourceCount: 0, totalPledged: 0, totalCollected: 0 }, ...prev])
setShowCreate(false)
setForm({ name: "", description: "", location: "", eventDate: "", goalAmount: "" })
}
} catch {
// handle error
}
setCreating(false)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-extrabold text-gray-900">Events</h1>
<p className="text-muted-foreground mt-1">Manage your fundraising events and QR codes</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus className="h-4 w-4 mr-2" /> New Event
</Button>
</div>
{/* Event cards */}
<div className="grid md:grid-cols-2 gap-4">
{events.map((event) => {
const progress = event.goalAmount ? Math.round((event.totalPledged / event.goalAmount) * 100) : 0
return (
<Card key={event.id} className="hover:shadow-md transition-shadow">
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{event.name}</CardTitle>
<CardDescription className="flex items-center gap-3 mt-1">
{event.eventDate && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{new Date(event.eventDate).toLocaleDateString("en-GB")}
</span>
)}
{event.location && (
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{event.location}
</span>
)}
</CardDescription>
</div>
<Badge variant={event.status === "active" ? "success" : "secondary"}>
{event.status}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<p className="text-xl font-bold">{event.pledgeCount}</p>
<p className="text-xs text-muted-foreground">Pledges</p>
</div>
<div>
<p className="text-xl font-bold">{formatPence(event.totalPledged)}</p>
<p className="text-xs text-muted-foreground">Pledged</p>
</div>
<div>
<p className="text-xl font-bold text-success-green">{formatPence(event.totalCollected)}</p>
<p className="text-xs text-muted-foreground">Collected</p>
</div>
</div>
{event.goalAmount && (
<div>
<div className="flex justify-between text-xs text-muted-foreground mb-1">
<span>{progress}% of goal</span>
<span className="flex items-center gap-1">
<Target className="h-3 w-3" /> {formatPence(event.goalAmount)}
</span>
</div>
<div className="h-2 rounded-full bg-gray-100 overflow-hidden">
<div
className="h-full rounded-full bg-trust-blue transition-all"
style={{ width: `${Math.min(progress, 100)}%` }}
/>
</div>
</div>
)}
<div className="flex gap-2">
<Link href={`/dashboard/events/${event.id}`} className="flex-1">
<Button variant="outline" size="sm" className="w-full">
<QrCode className="h-4 w-4 mr-1" /> QR Codes ({event.qrSourceCount})
</Button>
</Link>
<Link href={`/dashboard/pledges?event=${event.id}`} className="flex-1">
<Button variant="outline" size="sm" className="w-full">
View Pledges
</Button>
</Link>
</div>
</CardContent>
</Card>
)
})}
</div>
{/* Create dialog */}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogHeader>
<DialogTitle>Create Event</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Event Name *</Label>
<Input
placeholder="e.g. Ramadan Gala 2025"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
placeholder="Brief description..."
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Date</Label>
<Input
type="datetime-local"
value={form.eventDate}
onChange={(e) => setForm((f) => ({ ...f, eventDate: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Goal (£)</Label>
<Input
type="number"
placeholder="50000"
value={form.goalAmount}
onChange={(e) => setForm((f) => ({ ...f, goalAmount: e.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label>Location</Label>
<Input
placeholder="Venue name and address"
value={form.location}
onChange={(e) => setForm((f) => ({ ...f, location: e.target.value }))}
/>
</div>
<div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => setShowCreate(false)} className="flex-1">
Cancel
</Button>
<Button onClick={handleCreate} disabled={!form.name || creating} className="flex-1">
{creating ? "Creating..." : "Create Event"}
</Button>
</div>
</div>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,77 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Download, FileSpreadsheet, Webhook } from "lucide-react"
export default function ExportsPage() {
const handleCrmExport = () => {
const a = document.createElement("a")
a.href = "/api/exports/crm-pack"
a.download = `pledges-export-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-extrabold text-gray-900">Exports</h1>
<p className="text-muted-foreground mt-1">Export data for your CRM and automation tools</p>
</div>
<div className="grid md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" /> CRM Export Pack
</CardTitle>
<CardDescription>
Download all pledges as CSV with full attribution data, ready to import into your CRM.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-muted-foreground space-y-1">
<p>Includes:</p>
<ul className="list-disc list-inside space-y-0.5">
<li>Donor name, email, phone</li>
<li>Pledge amount and status</li>
<li>Payment method and reference</li>
<li>Event name and source attribution</li>
<li>Gift Aid flag</li>
<li>Days to collect</li>
</ul>
</div>
<Button onClick={handleCrmExport} className="w-full">
<Download className="h-4 w-4 mr-2" /> Download CRM Pack (CSV)
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Webhook className="h-5 w-5" /> Webhook Events
</CardTitle>
<CardDescription>
Poll pending reminder events for external automation (Zapier, Make, n8n).
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-muted-foreground space-y-2">
<p>Endpoint:</p>
<code className="block bg-gray-100 rounded-lg p-3 text-xs font-mono">
GET /api/webhooks?since=2025-01-01T00:00:00Z
</code>
<p>Returns pending reminders with donor contact info and pledge details.</p>
</div>
<div className="rounded-xl bg-trust-blue/5 border border-trust-blue/20 p-3">
<p className="text-xs text-trust-blue font-medium">
💡 Connect this to Zapier or Make to send emails/SMS automatically
</p>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,89 @@
import Link from "next/link"
import { LayoutDashboard, Calendar, FileBarChart, Upload, Download, Settings } from "lucide-react"
const navItems = [
{ href: "/dashboard", label: "Overview", icon: LayoutDashboard },
{ href: "/dashboard/events", label: "Events", icon: Calendar },
{ href: "/dashboard/pledges", label: "Pledges", icon: FileBarChart },
{ href: "/dashboard/reconcile", label: "Reconcile", icon: Upload },
{ href: "/dashboard/exports", label: "Exports", icon: Download },
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
]
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gray-50/50">
{/* Top bar */}
<header className="sticky top-0 z-40 border-b bg-white/80 backdrop-blur-xl">
<div className="flex h-16 items-center gap-4 px-6">
<Link href="/dashboard" className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-trust-blue flex items-center justify-center">
<span className="text-white font-bold text-sm">P</span>
</div>
<span className="font-bold text-lg hidden sm:block">Pledge Now, Pay Later</span>
</Link>
<div className="flex-1" />
<Link
href="/"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
View Public Site
</Link>
</div>
</header>
<div className="flex">
{/* Sidebar */}
<aside className="hidden md:flex w-64 flex-col border-r bg-white min-h-[calc(100vh-4rem)] p-4">
<nav className="space-y-1">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-muted-foreground hover:bg-gray-100 hover:text-foreground transition-colors"
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
))}
</nav>
{/* Upsell CTA */}
<div className="mt-auto pt-4">
<div className="rounded-2xl bg-gradient-to-br from-trust-blue/5 to-warm-amber/5 border p-4 space-y-2">
<p className="text-sm font-semibold">Need tech leadership?</p>
<p className="text-xs text-muted-foreground">
Get a fractional Head of Technology to optimise your charity&apos;s digital stack.
</p>
<Link
href="/dashboard/apply"
className="inline-block text-xs font-semibold text-trust-blue hover:underline"
>
Learn more
</Link>
</div>
</div>
</aside>
{/* Mobile bottom nav */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 border-t bg-white/80 backdrop-blur-xl flex justify-around py-2">
{navItems.slice(0, 5).map((item) => (
<Link
key={item.href}
href={item.href}
className="flex flex-col items-center gap-1 p-2 text-muted-foreground hover:text-trust-blue transition-colors"
>
<item.icon className="h-5 w-5" />
<span className="text-[10px] font-medium">{item.label}</span>
</Link>
))}
</nav>
{/* Main content */}
<main className="flex-1 p-4 md:p-8 pb-20 md:pb-8">
{children}
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function DashboardLoading() {
return (
<div className="space-y-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-24 rounded-2xl" />
))}
</div>
<Skeleton className="h-16 rounded-2xl" />
<div className="grid md:grid-cols-2 gap-4">
<Skeleton className="h-64 rounded-2xl" />
<Skeleton className="h-64 rounded-2xl" />
</div>
<Skeleton className="h-96 rounded-2xl" />
</div>
)
}

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

View File

@@ -0,0 +1,293 @@
"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>
)
}

View File

@@ -0,0 +1,239 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Upload, CheckCircle2, AlertCircle, HelpCircle, FileSpreadsheet } from "lucide-react"
interface MatchResult {
bankRow: {
date: string
description: string
amount: number
reference: string
}
pledgeId: string | null
pledgeReference: string | null
confidence: "exact" | "partial" | "amount_only" | "none"
matchedAmount: number
autoConfirmed: boolean
}
export default function ReconcilePage() {
const [file, setFile] = useState<File | null>(null)
const [uploading, setUploading] = useState(false)
const [results, setResults] = useState<{
summary: {
totalRows: number
credits: number
exactMatches: number
partialMatches: number
unmatched: number
autoConfirmed: number
}
matches: MatchResult[]
} | null>(null)
const [mapping, setMapping] = useState({
dateCol: "Date",
descriptionCol: "Description",
creditCol: "Credit",
referenceCol: "Reference",
})
const handleUpload = async () => {
if (!file) return
setUploading(true)
try {
const formData = new FormData()
formData.append("file", file)
formData.append("mapping", JSON.stringify(mapping))
const res = await fetch("/api/imports/bank-statement", {
method: "POST",
headers: { "x-org-id": "demo" },
body: formData,
})
const data = await res.json()
if (data.summary) {
setResults(data)
}
} catch {
// handle error
}
setUploading(false)
}
const confidenceIcon = (c: string) => {
switch (c) {
case "exact": return <CheckCircle2 className="h-4 w-4 text-success-green" />
case "partial": return <AlertCircle className="h-4 w-4 text-warm-amber" />
default: return <HelpCircle className="h-4 w-4 text-muted-foreground" />
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-extrabold text-gray-900">Reconcile Payments</h1>
<p className="text-muted-foreground mt-1">Upload a bank statement CSV to automatically match payments to pledges</p>
</div>
{/* Upload card */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" /> Bank Statement Import
</CardTitle>
<CardDescription>
Export your bank statement as CSV and upload it here. We&apos;ll match payment references automatically.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Column mapping */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-1">
<Label className="text-xs">Date Column</Label>
<Input
value={mapping.dateCol}
onChange={(e) => setMapping((m) => ({ ...m, dateCol: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Description Column</Label>
<Input
value={mapping.descriptionCol}
onChange={(e) => setMapping((m) => ({ ...m, descriptionCol: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Credit/Amount Column</Label>
<Input
value={mapping.creditCol}
onChange={(e) => setMapping((m) => ({ ...m, creditCol: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Reference Column</Label>
<Input
value={mapping.referenceCol}
onChange={(e) => setMapping((m) => ({ ...m, referenceCol: e.target.value }))}
className="h-9 text-sm"
/>
</div>
</div>
{/* File upload */}
<div className="border-2 border-dashed border-gray-200 rounded-2xl p-8 text-center hover:border-trust-blue/50 transition-colors">
<Upload className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
<input
type="file"
accept=".csv"
onChange={(e) => setFile(e.target.files?.[0] || null)}
className="hidden"
id="csv-upload"
/>
<label htmlFor="csv-upload" className="cursor-pointer">
<p className="font-medium">{file ? file.name : "Click to upload CSV"}</p>
<p className="text-xs text-muted-foreground mt-1">CSV file from your bank</p>
</label>
</div>
<Button onClick={handleUpload} disabled={!file || uploading} className="w-full">
{uploading ? "Processing..." : "Upload & Match"}
</Button>
</CardContent>
</Card>
{/* Results */}
{results && (
<>
{/* Summary */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold">{results.summary.totalRows}</p>
<p className="text-xs text-muted-foreground">Total Rows</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold">{results.summary.credits}</p>
<p className="text-xs text-muted-foreground">Credits</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold text-success-green">{results.summary.exactMatches}</p>
<p className="text-xs text-muted-foreground">Exact Matches</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold text-warm-amber">{results.summary.partialMatches}</p>
<p className="text-xs text-muted-foreground">Partial</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold text-success-green">{results.summary.autoConfirmed}</p>
<p className="text-xs text-muted-foreground">Auto-confirmed</p>
</CardContent>
</Card>
</div>
{/* Match table */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Match Results</CardTitle>
</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">Confidence</th>
<th className="pb-3 font-medium text-muted-foreground">Date</th>
<th className="pb-3 font-medium text-muted-foreground">Description</th>
<th className="pb-3 font-medium text-muted-foreground">Amount</th>
<th className="pb-3 font-medium text-muted-foreground">Matched Pledge</th>
<th className="pb-3 font-medium text-muted-foreground">Status</th>
</tr>
</thead>
<tbody className="divide-y">
{results.matches.map((m, i) => (
<tr key={i} className="hover:bg-gray-50/50">
<td className="py-3">{confidenceIcon(m.confidence)}</td>
<td className="py-3">{m.bankRow.date}</td>
<td className="py-3 max-w-[200px] truncate">{m.bankRow.description}</td>
<td className="py-3 font-bold">£{m.matchedAmount.toFixed(2)}</td>
<td className="py-3 font-mono">{m.pledgeReference || "—"}</td>
<td className="py-3">
{m.autoConfirmed ? (
<Badge variant="success">Auto-confirmed</Badge>
) : m.confidence === "partial" ? (
<Badge variant="warning">Review needed</Badge>
) : m.confidence === "none" ? (
<Badge variant="outline">No match</Badge>
) : (
<Badge variant="success">Matched</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,266 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Building2, CreditCard, Palette, Check, Loader2, AlertCircle } from "lucide-react"
interface OrgSettings {
name: string
bankName: string
bankSortCode: string
bankAccountNo: string
bankAccountName: string
refPrefix: string
primaryColor: string
gcAccessToken: string
gcEnvironment: string
}
export default function SettingsPage() {
const [settings, setSettings] = useState<OrgSettings | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState<string | null>(null)
const [saved, setSaved] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch("/api/settings", { headers: { "x-org-id": "demo" } })
.then((r) => r.json())
.then((data) => {
if (data.name) setSettings(data)
})
.catch(() => setError("Failed to load settings"))
.finally(() => setLoading(false))
}, [])
const save = async (section: string, data: Record<string, string>) => {
setSaving(section)
setError(null)
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json", "x-org-id": "demo" },
body: JSON.stringify(data),
})
if (res.ok) {
setSaved(section)
setTimeout(() => setSaved(null), 2000)
} else {
setError("Failed to save")
}
} catch {
setError("Failed to save")
}
setSaving(null)
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
</div>
)
}
if (!settings) {
return (
<div className="text-center py-20">
<AlertCircle className="h-8 w-8 text-danger-red mx-auto mb-2" />
<p className="text-muted-foreground">Failed to load settings</p>
</div>
)
}
const update = (key: keyof OrgSettings, value: string) => {
setSettings((s) => s ? { ...s, [key]: value } : s)
}
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-3xl font-extrabold text-gray-900">Settings</h1>
<p className="text-muted-foreground mt-1">Configure your organisation&apos;s payment details</p>
</div>
{error && (
<div className="rounded-xl bg-danger-red/10 border border-danger-red/20 p-3 text-sm text-danger-red">
{error}
</div>
)}
{/* Bank Details */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Building2 className="h-5 w-5" /> Bank Account Details
</CardTitle>
<CardDescription>
These details are shown to donors when they choose bank transfer.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Bank Name</Label>
<Input value={settings.bankName} onChange={(e) => update("bankName", e.target.value)} />
</div>
<div className="space-y-2">
<Label>Account Name</Label>
<Input value={settings.bankAccountName} onChange={(e) => update("bankAccountName", e.target.value)} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Sort Code</Label>
<Input value={settings.bankSortCode} onChange={(e) => update("bankSortCode", e.target.value)} placeholder="XX-XX-XX" />
</div>
<div className="space-y-2">
<Label>Account Number</Label>
<Input value={settings.bankAccountNo} onChange={(e) => update("bankAccountNo", e.target.value)} placeholder="XXXXXXXX" />
</div>
</div>
<div className="space-y-2">
<Label>Reference Prefix</Label>
<Input value={settings.refPrefix} onChange={(e) => update("refPrefix", e.target.value)} maxLength={4} />
<p className="text-xs text-muted-foreground">Max 4 chars. Used in payment references, e.g. {settings.refPrefix}-XXXX-50</p>
</div>
<Button
onClick={() => save("bank", {
bankName: settings.bankName,
bankSortCode: settings.bankSortCode,
bankAccountNo: settings.bankAccountNo,
bankAccountName: settings.bankAccountName,
refPrefix: settings.refPrefix,
})}
disabled={saving === "bank"}
>
{saving === "bank" ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Saving...</>
) : saved === "bank" ? (
<><Check className="h-4 w-4 mr-2" /> Saved!</>
) : (
"Save Bank Details"
)}
</Button>
</CardContent>
</Card>
{/* GoCardless */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<CreditCard className="h-5 w-5" /> GoCardless (Direct Debit)
</CardTitle>
<CardDescription>
Connect GoCardless to enable Direct Debit collection.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Access Token</Label>
<Input
type="password"
value={settings.gcAccessToken}
onChange={(e) => update("gcAccessToken", e.target.value)}
placeholder="sandbox_xxxxx or live_xxxxx"
/>
</div>
<div className="space-y-2">
<Label>Environment</Label>
<div className="flex gap-3">
{["sandbox", "live"].map((env) => (
<button
key={env}
onClick={() => update("gcEnvironment", env)}
className={`px-4 py-2 rounded-xl text-sm font-medium border-2 transition-colors ${
settings.gcEnvironment === env
? env === "live"
? "border-danger-red bg-danger-red/5 text-danger-red"
: "border-trust-blue bg-trust-blue/5 text-trust-blue"
: "border-gray-200 text-muted-foreground hover:border-gray-300"
}`}
>
{env.charAt(0).toUpperCase() + env.slice(1)}
{env === "live" && settings.gcEnvironment === "live" && " ⚠️"}
</button>
))}
</div>
{settings.gcEnvironment === "live" && (
<p className="text-xs text-danger-red font-medium"> Live mode will create real Direct Debit mandates</p>
)}
</div>
<Button
onClick={() => save("gc", {
gcAccessToken: settings.gcAccessToken,
gcEnvironment: settings.gcEnvironment,
})}
disabled={saving === "gc"}
>
{saving === "gc" ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Saving...</>
) : saved === "gc" ? (
<><Check className="h-4 w-4 mr-2" /> Connected!</>
) : (
"Save GoCardless Settings"
)}
</Button>
</CardContent>
</Card>
{/* Branding */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Palette className="h-5 w-5" /> Branding
</CardTitle>
<CardDescription>
Customise the look of your pledge pages.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Organisation Name</Label>
<Input
value={settings.name}
onChange={(e) => update("name", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Primary Colour</Label>
<div className="flex gap-2">
<Input
type="color"
value={settings.primaryColor}
onChange={(e) => update("primaryColor", e.target.value)}
className="w-14 h-11 p-1"
/>
<Input
value={settings.primaryColor}
onChange={(e) => update("primaryColor", e.target.value)}
className="flex-1"
/>
</div>
</div>
<Button
onClick={() => save("brand", {
name: settings.name,
primaryColor: settings.primaryColor,
})}
disabled={saving === "brand"}
>
{saving === "brand" ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Saving...</>
) : saved === "brand" ? (
<><Check className="h-4 w-4 mr-2" /> Saved!</>
) : (
"Save Branding"
)}
</Button>
</CardContent>
</Card>
</div>
)
}