feat: add improved pi agent with observatory, dashboard, and pledge-now-pay-later
This commit is contained in:
92
pledge-now-pay-later/src/app/dashboard/apply/page.tsx
Normal file
92
pledge-now-pay-later/src/app/dashboard/apply/page.tsx
Normal 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'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'll review your application and get back within 48 hours.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
241
pledge-now-pay-later/src/app/dashboard/events/[id]/page.tsx
Normal file
241
pledge-now-pay-later/src/app/dashboard/events/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
225
pledge-now-pay-later/src/app/dashboard/events/page.tsx
Normal file
225
pledge-now-pay-later/src/app/dashboard/events/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
pledge-now-pay-later/src/app/dashboard/exports/page.tsx
Normal file
77
pledge-now-pay-later/src/app/dashboard/exports/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
89
pledge-now-pay-later/src/app/dashboard/layout.tsx
Normal file
89
pledge-now-pay-later/src/app/dashboard/layout.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
23
pledge-now-pay-later/src/app/dashboard/loading.tsx
Normal file
23
pledge-now-pay-later/src/app/dashboard/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
324
pledge-now-pay-later/src/app/dashboard/page.tsx
Normal file
324
pledge-now-pay-later/src/app/dashboard/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
293
pledge-now-pay-later/src/app/dashboard/pledges/page.tsx
Normal file
293
pledge-now-pay-later/src/app/dashboard/pledges/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
239
pledge-now-pay-later/src/app/dashboard/reconcile/page.tsx
Normal file
239
pledge-now-pay-later/src/app/dashboard/reconcile/page.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
266
pledge-now-pay-later/src/app/dashboard/settings/page.tsx
Normal file
266
pledge-now-pay-later/src/app/dashboard/settings/page.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user