Complete dashboard UI overhaul: persona journeys + brand unification

Navigation: goal-oriented, not feature-oriented
- Overview → Home
- Campaigns → Collect ('I want people to pledge')
- Pledges → Money ('Where's the money?')
- Exports → Reports ('My treasurer needs numbers')
- Old routes still work via re-exports

Terminology: human language, not SaaS jargon
- new → Waiting
- initiated → Said they paid
- paid → Received ✓
- overdue → Needs a nudge
- Campaign → Appeal
- QR Source → Pledge link
- Reconcile → Match payments
- Rail → Payment method
- Pipeline by Status → How pledges are doing
- Conversion rate → % who pledged
- CRM Export Pack → Full data download

Visual identity: brand-consistent dashboard
- Sharp edges (no rounded-lg cards)
- Gap-px grids for stats (brand signature pattern)
- Left-border accents (brand signature pattern)
- Midnight/Paper/Promise Blue 60-30-10 color rule
- Typography as hero (big bold numbers, not card-heavy)
- No emoji in UI chrome
- Brand-consistent status badges (colored bg + text, not shadcn Badge)
- Consistent header typography (text-3xl font-black tracking-tight)

Pages rewritten: layout, home, events (collect), pledges (money),
exports (reports), reconcile, settings

Reconcile: auto-detects bank CSV format via presets + AI before upload

UX spec: docs/UX_OVERHAUL_SPEC.md
This commit is contained in:
2026-03-04 20:50:42 +08:00
parent fcfae1c1a4
commit 170a2e7c68
13 changed files with 1867 additions and 1399 deletions

View File

@@ -0,0 +1,169 @@
# PNPL Dashboard — Complete UX Overhaul Spec
## The Core Problem
The dashboard was built feature-first: each page maps to a technical concept (Events, Pledges, Reconcile, Exports, Settings). But the **user doesn't think in features** — they think in **goals**:
- "How do I get people to pledge?"
- "Has anyone paid yet?"
- "Someone said they paid but I can't find it"
- "My treasurer needs a report"
The current UI has 3 fatal problems:
1. **Jargon everywhere** — "Reconcile", "QR Sources", "Rail", "Initiated", "Pipeline by Status", "Conversion rate". A masjid volunteer doesn't speak SaaS.
2. **Two visual identities** — Landing pages are bold, typographic, high-contrast (BRAND.md). Dashboard is generic shadcn/ui with rounded cards, muted colours, and card-heavy layouts. They feel like different products.
3. **Feature-oriented, not goal-oriented** — The user has to learn the system's mental model instead of the system matching theirs.
---
## Personas & Their Real Journeys
### Persona 1: Charity Admin (Aaisha)
**Who:** Fundraising manager at a UK Islamic charity. Not technical. Uses WhatsApp all day.
**Her actual journey:**
1. "I need to collect pledges at our gala next Saturday" → Create campaign + links
2. "The event is tonight, give each table a QR code" → Print QR codes
3. "It's Monday, who pledged? Who's paid?" → Check the money
4. "My treasurer needs numbers for the trustees meeting" → Download a report
5. "Ahmed said he paid but we can't see it" → Find & match a payment
**What confuses her today:**
- "Campaigns" (she calls them "appeals" or just "the dinner")
- "QR Sources" → she just wants "links for my volunteers"
- "Reconcile" → she'd say "check the bank"
- "Initiated" status → "what does that mean?"
- "Rail" → completely alien
- "Pipeline by Status" → ???
### Persona 2: Volunteer (Yusuf)
**Who:** Table captain at the gala. Has his own QR code. Checks his phone to see pledges.
**His actual journey:**
1. Gets a link from Aaisha → opens volunteer view
2. Shows QR code at his table → donors scan it
3. Checks "how am I doing?" on his phone
4. Wants to brag → shares leaderboard
**He never sees the dashboard** — his entire journey is `/v/[code]`. But if Aaisha asks him to "help manage", he'd see the dashboard and be completely lost.
### Persona 3: Treasurer (Fatima)
**Who:** Charity trustee responsible for financial oversight. Needs Gift Aid reports, bank reconciliation, clean data.
**Her actual journey:**
1. "Download last month's Gift Aid declarations" → Export
2. "Match bank statement to pledges" → Upload CSV, see matches
3. "Show me the money: what's collected vs outstanding" → Summary
4. "I need this for the trustees report" → Numbers she can screenshot
**What confuses her today:**
- Column mapping UI (Date Column, Description Column...) → she just wants to upload a file and it works
- Technical status names
- No clear "money in vs money out" view
### Persona Overlap
Aaisha does 80% of the work. She IS the charity admin, the event organiser, AND does the bank checking. Fatima only logs in once a month. Yusuf never logs in.
**This means: optimise everything for Aaisha. Make Fatima's tasks findable but not in the way. Yusuf is a non-factor for dashboard design.**
---
## The Redesign: Goal-Oriented Navigation
### Before (feature-oriented)
```
Overview | Campaigns | Pledges | Reconcile | Exports | Settings
```
### After (goal-oriented)
```
Home | Collect | Money | Reports | Settings
```
| Old | New | Why |
|-----|-----|-----|
| Overview | **Home** | Everyone understands "Home" |
| Campaigns | **Collect** | "I want to collect pledges" — the verb, not the noun |
| Pledges | **Money** | "Where's the money?" — that's what they're checking |
| Reconcile | (merged into Money) | "Check the bank" is part of "where's the money" |
| Exports | **Reports** | Charities think in "reports", not "exports" |
| Settings | **Settings** | Fine as-is, but with friendlier sub-labels |
### Terminology Overhaul
| Old (SaaS jargon) | New (human language) | Where used |
|---|---|---|
| Campaign | Appeal / Collection | Nav, headers |
| QR Source | Pledge link | Cards, dialogs |
| Reconcile | Match payments | Nav, page title |
| Rail | Payment method | Tables, detail |
| Initiated | Said they've paid | Status badge |
| Pipeline by Status | How pledges are doing | Dashboard card |
| Conversion rate | % who pledged | Stats |
| CRM Export Pack | Full data download | Export card |
| Webhook / API | Connect to other tools | Export card |
| GoCardless | Direct Debit | Settings |
| Reference prefix | Code prefix | Settings |
| Auto-confirmed | Automatically matched | Reconcile |
### Status Labels (the biggest confusion)
| Old | New | Color | What it actually means |
|---|---|---|---|
| new | **Waiting** | Gray | Pledge made, no payment yet |
| initiated | **Said they paid** | Amber | Donor clicked "I've paid" or replied PAID |
| paid | **Received** ✓ | Green | Payment confirmed in bank |
| overdue | **Needs a nudge** | Red | No payment after reminder cycle |
| cancelled | **Cancelled** | Gray muted | Donor or staff cancelled |
---
## Visual Identity: Bringing Brand into the Dashboard
### Current Dashboard Style
- Generic shadcn/ui (rounded-lg cards, muted-foreground, hover:shadow-md)
- No brand colors except occasional trust-blue
- Card-heavy layout (everything in a Card with CardHeader/CardContent)
- Small text, low contrast
- Emoji as visual anchors (🤲, 💷, 🏦)
### Target Dashboard Style (matching BRAND.md)
- **Sharp edges** — remove rounded-lg on cards, use squared or rounded-sm max
- **Typography as hero** — big bold numbers, not cards with icons
- **Gap-px grids** instead of card grids for stats
- **Dark sections** for key actions (1-2 per page)
- **Left-border accents** on feature items
- **No emoji in UI** — use the brand icon system
- **60-30-10 color rule** — Midnight + Paper base, Promise Blue actions, Gold/Green/Red status only
- **Signature numbered steps** (01, 02, 03) in setup flow
---
## Implementation Plan
### Phase 1: Navigation + Terminology (layout.tsx)
- Rename nav items
- Update sidebar visual to match brand
- Remove emoji from sidebar help box
### Phase 2: Dashboard Home (page.tsx)
- Rewrite with brand typography
- Gap-px stat grid instead of card grid
- Human status labels
- Remove jargon
### Phase 3: Collect page (events → collect)
- Rename to "Collect"
- Simplify campaign creation
- Better empty state
### Phase 4: Money page (pledges + reconcile merged)
- Two tabs: "People" (pledge list) and "Bank" (reconcile)
- Human column labels
- Human status badges
### Phase 5: Reports page (exports)
- Cleaner cards
- Brand style
### Phase 6: Settings page
- Friendlier labels
- Remove technical jargon

View File

@@ -0,0 +1,5 @@
/**
* /dashboard/collect — "I want people to pledge"
* This is the renamed "Campaigns/Events" page with human language.
*/
export { default } from "../events/page"

View File

@@ -1,65 +1,49 @@
"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, ExternalLink } from "lucide-react"
import { Plus, ExternalLink } 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
paymentMode?: string
externalPlatform?: string
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
paymentMode?: string; externalPlatform?: string
}
const platformNames: Record<string, string> = {
launchgood: "🌙 LaunchGood",
enthuse: "💜 Enthuse",
justgiving: "💛 JustGiving",
gofundme: "💚 GoFundMe",
other: "🔗 External",
launchgood: "LaunchGood", enthuse: "Enthuse", justgiving: "JustGiving",
gofundme: "GoFundMe", other: "External",
}
export default function EventsPage() {
export default function CollectPage() {
const [events, setEvents] = useState<EventSummary[]>([])
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [creating, setCreating] = useState(false)
const [orgType, setOrgType] = useState<string | null>(null)
const [form, setForm] = useState({
name: "", description: "", location: "", eventDate: "", goalAmount: "",
paymentMode: "self" as "self" | "external", externalUrl: "", externalPlatform: "", zakatEligible: false,
})
useEffect(() => {
fetch("/api/events")
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data)) setEvents(data)
})
.then(r => r.json())
.then(data => { if (Array.isArray(data)) setEvents(data) })
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const [creating, setCreating] = useState(false)
const [orgType, setOrgType] = useState<string | null>(null)
const [form, setForm] = useState({ name: "", description: "", location: "", eventDate: "", goalAmount: "", paymentMode: "self" as "self" | "external", externalUrl: "", externalPlatform: "", zakatEligible: false })
// Fetch org type to customize the form
useEffect(() => {
fetch("/api/onboarding").then(r => r.json()).then(d => {
if (d.orgType) setOrgType(d.orgType)
// Auto-set external mode for fundraisers
if (d.orgType === "fundraiser") setForm(f => ({ ...f, paymentMode: "external" }))
}).catch(() => {})
}, [])
@@ -71,274 +55,239 @@ export default function EventsPage() {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: form.name,
description: form.description || undefined,
name: form.name, description: form.description || undefined,
location: form.location || undefined,
goalAmount: form.goalAmount ? Math.round(parseFloat(form.goalAmount) * 100) : undefined,
eventDate: form.eventDate ? new Date(form.eventDate).toISOString() : undefined,
paymentMode: form.paymentMode,
externalUrl: form.externalUrl || undefined,
paymentMode: form.paymentMode, externalUrl: form.externalUrl || undefined,
externalPlatform: form.paymentMode === "external" ? (form.externalPlatform || "other") : undefined,
zakatEligible: form.zakatEligible,
}),
})
if (res.ok) {
const event = await res.json()
setEvents((prev) => [{ ...event, pledgeCount: 0, qrSourceCount: 0, totalPledged: 0, totalCollected: 0 }, ...prev])
setEvents(prev => [{ ...event, pledgeCount: 0, qrSourceCount: 0, totalPledged: 0, totalCollected: 0 }, ...prev])
setShowCreate(false)
setForm({ name: "", description: "", location: "", eventDate: "", goalAmount: "", paymentMode: orgType === "fundraiser" ? "external" : "self", externalUrl: "", externalPlatform: "", zakatEligible: false })
}
} catch {
// handle error
}
} catch { /* */ }
setCreating(false)
}
return (
<div className="space-y-6">
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-extrabold text-gray-900">Campaigns</h1>
<p className="text-muted-foreground mt-1">Create campaigns, share pledge links, and track donations</p>
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Collect</h1>
<p className="text-sm text-gray-500 mt-0.5">Create appeals, share pledge links, and track who&apos;s pledged</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus className="h-4 w-4 mr-2" /> New Campaign
</Button>
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-1.5 bg-[#111827] px-4 py-2 text-sm font-bold text-white hover:bg-gray-800 transition-colors"
>
<Plus className="h-4 w-4" /> New Appeal
</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")}
{/* Appeal cards — brand style: gap-px grid on desktop, stacked on mobile */}
{loading ? (
<div className="text-center py-16 text-sm text-gray-400">Loading appeals...</div>
) : events.length === 0 ? (
<div className="bg-white border-2 border-dashed border-gray-200 p-10 text-center">
<p className="text-4xl font-black text-gray-200 mb-3">01</p>
<h3 className="text-base font-bold text-[#111827]">Create your first appeal</h3>
<p className="text-sm text-gray-500 mt-1 max-w-md mx-auto">
An appeal is a collection your gala dinner, Ramadan campaign, mosque fund, or any cause you&apos;re raising for.
</p>
<button
onClick={() => setShowCreate(true)}
className="mt-4 bg-[#111827] px-5 py-2.5 text-sm font-bold text-white hover:bg-gray-800 transition-colors"
>
Create Appeal
</button>
</div>
) : (
<div className="grid md:grid-cols-2 gap-4">
{events.map(event => {
const progress = event.goalAmount ? Math.min(100, Math.round((event.totalPledged / event.goalAmount) * 100)) : 0
return (
<div key={event.id} className="bg-white border border-gray-200 hover:border-gray-300 transition-colors">
<div className="p-5 space-y-4">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h3 className="text-base font-bold text-[#111827]">{event.name}</h3>
<div className="flex items-center gap-3 text-xs text-gray-500 mt-1">
{event.eventDate && <span>{new Date(event.eventDate).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })}</span>}
{event.location && <span>{event.location}</span>}
</div>
</div>
<div className="flex gap-1.5">
{event.paymentMode === "external" && event.externalPlatform && (
<span className="text-[10px] font-bold text-gray-500 border border-gray-200 px-1.5 py-0.5">
{platformNames[event.externalPlatform] || "External"}
</span>
)}
{event.location && (
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{event.location}
</span>
)}
</CardDescription>
</div>
<div className="flex gap-1.5">
{event.paymentMode === "external" && event.externalPlatform && (
<Badge variant="outline" className="text-[10px]">{platformNames[event.externalPlatform] || "External"}</Badge>
)}
<Badge variant={event.status === "active" ? "success" : "secondary"}>
{event.status}
</Badge>
</div>
</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 className={`text-[10px] font-bold px-1.5 py-0.5 ${event.status === "active" ? "bg-[#16A34A]/10 text-[#16A34A]" : "bg-gray-100 text-gray-500"}`}>
{event.status === "active" ? "Live" : event.status}
</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>
{/* Stats — gap-px grid */}
<div className="grid grid-cols-3 gap-px bg-gray-100">
<div className="bg-white p-3 text-center">
<p className="text-lg font-black text-[#111827]">{event.pledgeCount}</p>
<p className="text-[10px] text-gray-500">Pledges</p>
</div>
<div className="bg-white p-3 text-center">
<p className="text-lg font-black text-[#111827]">{formatPence(event.totalPledged)}</p>
<p className="text-[10px] text-gray-500">Promised</p>
</div>
<div className="bg-white p-3 text-center">
<p className="text-lg font-black text-[#16A34A]">{formatPence(event.totalCollected)}</p>
<p className="text-[10px] text-gray-500">Received</p>
</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" /> Pledge Links ({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>
{/* Goal bar */}
{event.goalAmount && (
<div>
<div className="flex justify-between text-[10px] text-gray-500 mb-1">
<span>{progress}% of target</span>
<span>{formatPence(event.goalAmount)}</span>
</div>
<div className="h-1.5 bg-gray-100 overflow-hidden">
<div className="h-full bg-[#1E40AF] transition-all" style={{ width: `${progress}%` }} />
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<Link href={`/dashboard/events/${event.id}`} className="flex-1">
<button className="w-full border border-gray-200 px-3 py-2 text-xs font-semibold text-[#111827] hover:bg-gray-50 transition-colors">
Pledge Links ({event.qrSourceCount})
</button>
</Link>
<Link href={`/dashboard/money?event=${event.id}`} className="flex-1">
<button className="w-full border border-gray-200 px-3 py-2 text-xs font-semibold text-[#111827] hover:bg-gray-50 transition-colors">
View Pledges
</button>
</Link>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
)
})}
</div>
)}
{/* Create dialog */}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogHeader>
<DialogTitle>New Campaign</DialogTitle>
<DialogTitle className="font-black text-[#111827]">New Appeal</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Campaign Name *</Label>
<Label className="text-xs font-bold">What are you raising for? *</Label>
<Input
placeholder="e.g. Ramadan Appeal 2026, Mosque Building Fund"
placeholder="e.g. Ramadan Appeal 2026, Mosque Building Fund, Annual Gala Dinner"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
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 }))}
/>
<Label className="text-xs font-bold">Description <span className="font-normal text-gray-400">(optional)</span></Label>
<Textarea placeholder="Brief description for donors..." 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 }))}
/>
<Label className="text-xs font-bold">Date <span className="font-normal text-gray-400">(optional)</span></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 }))}
/>
<Label className="text-xs font-bold">Target amount (£) <span className="font-normal text-gray-400">(optional)</span></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 }))}
/>
<Label className="text-xs font-bold">Venue <span className="font-normal text-gray-400">(optional)</span></Label>
<Input placeholder="Venue name and address" value={form.location} onChange={e => setForm(f => ({ ...f, location: e.target.value }))} />
</div>
{/* Payment mode toggle */}
{/* How do donors pay? */}
<div className="space-y-2">
<Label>How do donors pay?</Label>
<Label className="text-xs font-bold">How do donors pay?</Label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setForm(f => ({ ...f, paymentMode: "self" }))}
className={`rounded-lg border p-3 text-left text-xs transition-all ${form.paymentMode === "self" ? "border-trust-blue bg-trust-blue/5" : "border-gray-200"}`}
className={`border-2 p-3 text-left text-xs transition-all ${form.paymentMode === "self" ? "border-[#1E40AF] bg-[#1E40AF]/5" : "border-gray-200"}`}
>
<span className="font-bold block">🏦 Bank transfer</span>
<span className="text-muted-foreground">We show our bank details</span>
<span className="font-bold block">Bank transfer</span>
<span className="text-gray-500">We show our bank details</span>
</button>
<button
type="button"
onClick={() => setForm(f => ({ ...f, paymentMode: "external" }))}
className={`rounded-lg border p-3 text-left text-xs transition-all ${form.paymentMode === "external" ? "border-warm-amber bg-warm-amber/5" : "border-gray-200"}`}
className={`border-2 p-3 text-left text-xs transition-all ${form.paymentMode === "external" ? "border-[#F59E0B] bg-[#F59E0B]/5" : "border-gray-200"}`}
>
<span className="font-bold block">🔗 External page</span>
<span className="text-muted-foreground">LaunchGood, Enthuse, etc.</span>
<span className="font-bold block">External page</span>
<span className="text-gray-500">LaunchGood, Enthuse, etc.</span>
</button>
</div>
</div>
{form.paymentMode === "external" && (
<div className="space-y-3 rounded-lg border border-warm-amber/20 bg-warm-amber/5 p-3">
<div className="space-y-3 border-l-2 border-[#F59E0B] pl-4">
<div className="space-y-2">
<Label>Fundraising page URL *</Label>
<Label className="text-xs font-bold">Fundraising page URL *</Label>
<div className="relative">
<ExternalLink className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="https://launchgood.com/my-campaign"
value={form.externalUrl}
onChange={(e) => setForm((f) => ({ ...f, externalUrl: e.target.value }))}
className="pl-9"
/>
<ExternalLink className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input placeholder="https://launchgood.com/my-campaign" value={form.externalUrl} onChange={e => setForm(f => ({ ...f, externalUrl: e.target.value }))} className="pl-9" />
</div>
</div>
<div className="space-y-2">
<Label>Platform</Label>
<select
value={form.externalPlatform}
onChange={(e) => setForm(f => ({ ...f, externalPlatform: e.target.value }))}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
>
<option value="">Select platform...</option>
<option value="launchgood">🌙 LaunchGood</option>
<option value="enthuse">💜 Enthuse</option>
<option value="justgiving">💛 JustGiving</option>
<option value="gofundme">💚 GoFundMe</option>
<option value="other">🔗 Other / Custom</option>
<Label className="text-xs font-bold">Platform</Label>
<select value={form.externalPlatform} onChange={e => setForm(f => ({ ...f, externalPlatform: e.target.value }))} className="w-full border border-gray-200 px-3 py-2 text-sm">
<option value="">Select...</option>
<option value="launchgood">LaunchGood</option>
<option value="enthuse">Enthuse</option>
<option value="justgiving">JustGiving</option>
<option value="gofundme">GoFundMe</option>
<option value="other">Other</option>
</select>
</div>
</div>
)}
{/* Zakat eligible toggle */}
{/* Zakat toggle */}
<button
type="button"
onClick={() => setForm(f => ({ ...f, zakatEligible: !f.zakatEligible }))}
className={`w-full flex items-center justify-between rounded-lg border-2 p-3 text-left transition-all ${
form.zakatEligible ? "border-trust-blue bg-trust-blue/5" : "border-gray-200"
className={`w-full flex items-center justify-between border-2 p-3 text-left transition-all ${
form.zakatEligible ? "border-[#1E40AF] bg-[#1E40AF]/5" : "border-gray-200"
}`}
>
<div>
<p className="text-sm font-bold">🌙 Zakat eligible</p>
<p className="text-xs text-muted-foreground">Let donors mark their pledge as Zakat</p>
<p className="text-sm font-bold text-[#111827]">Zakat eligible</p>
<p className="text-xs text-gray-500">Let donors mark their pledge as Zakat</p>
</div>
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${form.zakatEligible ? "bg-trust-blue border-trust-blue" : "border-gray-300"}`}>
<div className={`w-5 h-5 border-2 flex items-center justify-center ${form.zakatEligible ? "bg-[#1E40AF] border-[#1E40AF]" : "border-gray-300"}`}>
{form.zakatEligible && <span className="text-white text-xs font-bold"></span>}
</div>
</button>
{/* External URL for self-payment campaigns (for reference/allocation) */}
{form.paymentMode === "self" && (
<div className="space-y-2">
<Label>External fundraising page <span className="text-muted-foreground font-normal">(optional)</span></Label>
<div className="relative">
<ExternalLink className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="https://launchgood.com/my-campaign"
value={form.externalUrl}
onChange={(e) => setForm(f => ({ ...f, externalUrl: e.target.value }))}
className="pl-9"
/>
</div>
<p className="text-[10px] text-muted-foreground">Link an external page for reference. Payments still go via your bank.</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.name || creating || (form.paymentMode === "external" && !form.externalUrl)} className="flex-1">
{creating ? "Creating..." : "Create Campaign"}
</Button>
<Button variant="outline" onClick={() => setShowCreate(false)} className="flex-1">Cancel</Button>
<button
onClick={handleCreate}
disabled={!form.name || creating || (form.paymentMode === "external" && !form.externalUrl)}
className="flex-1 bg-[#111827] px-4 py-2 text-sm font-bold text-white hover:bg-gray-800 disabled:opacity-50 transition-colors"
>
{creating ? "Creating..." : "Create Appeal"}
</button>
</div>
</div>
</Dialog>

View File

@@ -1,10 +1,8 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Download, FileSpreadsheet, Webhook, Gift } from "lucide-react"
import { Download } from "lucide-react"
export default function ExportsPage() {
export default function ReportsPage() {
const handleCrmExport = () => {
const a = document.createElement("a")
a.href = "/api/exports/crm-pack"
@@ -20,94 +18,78 @@ export default function ExportsPage() {
}
return (
<div className="space-y-6">
<div className="space-y-8">
<div>
<h1 className="text-3xl font-extrabold text-gray-900">Exports</h1>
<p className="text-muted-foreground mt-1">Export data for your CRM, HMRC, and automation tools</p>
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Reports</h1>
<p className="text-sm text-gray-500 mt-0.5">Download data for your treasurer, trustees, or HMRC</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" /> CRM Export Pack
</CardTitle>
<CardDescription>
All pledges as CSV with full attribution data.
</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 text-xs">
<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
</Button>
</CardContent>
</Card>
{/* Full data download */}
<div className="bg-white border border-gray-200 p-6 space-y-4">
<div>
<h3 className="text-base font-bold text-[#111827]">Full data download</h3>
<p className="text-xs text-gray-500 mt-1">Everything in one spreadsheet donor details, amounts, statuses, attribution.</p>
</div>
<div className="border-l-2 border-[#111827] pl-3 space-y-1 text-xs text-gray-600">
<p>Donor name, email, phone</p>
<p>Amount and payment status</p>
<p>Payment method and reference</p>
<p>Appeal name and source</p>
<p>Gift Aid flag</p>
</div>
<button
onClick={handleCrmExport}
className="w-full bg-[#111827] px-4 py-2.5 text-sm font-bold text-white hover:bg-gray-800 transition-colors flex items-center justify-center gap-2"
>
<Download className="h-4 w-4" /> Download CSV
</button>
</div>
<Card className="border-success-green/30">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Gift className="h-5 w-5 text-success-green" /> Gift Aid Report
</CardTitle>
<CardDescription>
HMRC-ready Gift Aid declarations for tax reclaim.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-muted-foreground space-y-1">
<p>Includes only Gift Aid-eligible pledges:</p>
<ul className="list-disc list-inside space-y-0.5 text-xs">
<li>Donor full name (required by HMRC)</li>
<li>Donation amount and date</li>
<li>Gift Aid declaration status</li>
<li>Event and reference for audit trail</li>
</ul>
</div>
<div className="rounded-lg bg-success-green/5 border border-success-green/20 p-3">
<p className="text-xs text-success-green font-medium">
💷 Claim 25p for every £1 donated by a UK taxpayer
</p>
</div>
<Button onClick={handleGiftAidExport} className="w-full bg-success-green hover:bg-success-green/90">
<Download className="h-4 w-4 mr-2" /> Download Gift Aid Report
</Button>
</CardContent>
</Card>
{/* Gift Aid report */}
<div className="bg-white border border-[#16A34A]/30 p-6 space-y-4">
<div>
<h3 className="text-base font-bold text-[#111827]">Gift Aid report</h3>
<p className="text-xs text-gray-500 mt-1">HMRC-ready declarations for tax reclaim. Only includes donors who ticked Gift Aid.</p>
</div>
<div className="border-l-2 border-[#16A34A] pl-3 space-y-1 text-xs text-gray-600">
<p>Donor full name (required by HMRC)</p>
<p>Donation amount and date</p>
<p>Gift Aid declaration timestamp</p>
<p>Home address and postcode</p>
</div>
<div className="bg-[#16A34A]/5 border border-[#16A34A]/20 p-3">
<p className="text-xs text-[#16A34A] font-bold">
Claim 25p for every £1 donated by a UK taxpayer
</p>
</div>
<button
onClick={handleGiftAidExport}
className="w-full bg-[#16A34A] px-4 py-2.5 text-sm font-bold text-white hover:bg-[#16A34A]/90 transition-colors flex items-center justify-center gap-2"
>
<Download className="h-4 w-4" /> Download Gift Aid Report
</button>
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Webhook className="h-5 w-5" /> Webhook / API
</CardTitle>
<CardDescription>
Connect to Zapier, Make, or n8n for automation.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-muted-foreground space-y-2">
<p>Reminder endpoint:</p>
<code className="block bg-gray-100 rounded-lg p-3 text-xs font-mono break-all">
GET /api/webhooks?since=2025-01-01
</code>
<p className="text-xs">Returns pending reminders with donor contact info for external email/SMS.</p>
</div>
<div className="rounded-lg bg-trust-blue/5 border border-trust-blue/20 p-3">
<p className="text-xs text-trust-blue font-medium">
💡 Connect to Zapier or n8n to send automatic reminder emails and SMS
</p>
</div>
</CardContent>
</Card>
{/* Connect to other tools */}
<div className="bg-white border border-gray-200 p-6 space-y-4">
<div>
<h3 className="text-base font-bold text-[#111827]">Connect to other tools</h3>
<p className="text-xs text-gray-500 mt-1">Use our API to pull data into Zapier, Make, or your own systems.</p>
</div>
<div className="space-y-2">
<p className="text-xs font-bold text-gray-600">Reminder endpoint:</p>
<code className="block bg-gray-50 p-3 text-[11px] font-mono break-all border border-gray-100">
GET /api/webhooks?since=2025-01-01
</code>
<p className="text-[10px] text-gray-500">Returns pending reminders with donor contact info for external email or SMS.</p>
</div>
<div className="bg-[#1E40AF]/5 border border-[#1E40AF]/20 p-3">
<p className="text-xs text-[#1E40AF] font-bold">
Connect to Zapier or Make to send automatic reminder emails
</p>
</div>
</div>
</div>
</div>
)

View File

@@ -4,15 +4,22 @@ import Link from "next/link"
import { usePathname } from "next/navigation"
import { useSession, signOut } from "next-auth/react"
import { useState, useEffect } from "react"
import { LayoutDashboard, Megaphone, FileBarChart, Upload, Download, Settings, Plus, ExternalLink, LogOut, Shield, MessageCircle, AlertTriangle } from "lucide-react"
import { Home, Megaphone, Banknote, FileText, Settings, Plus, LogOut, Shield, AlertTriangle, MessageCircle } from "lucide-react"
import { cn } from "@/lib/utils"
/**
* Navigation: goal-oriented, not feature-oriented
* "Home" — where am I at?
* "Collect" — I want people to pledge
* "Money" — where's the money?
* "Reports" — my treasurer needs numbers
* "Settings" — connect WhatsApp, bank details
*/
const navItems = [
{ href: "/dashboard", label: "Overview", icon: LayoutDashboard },
{ href: "/dashboard/events", label: "Campaigns", icon: Megaphone },
{ href: "/dashboard/pledges", label: "Pledges", icon: FileBarChart },
{ href: "/dashboard/reconcile", label: "Reconcile", icon: Upload },
{ href: "/dashboard/exports", label: "Exports", icon: Download },
{ href: "/dashboard", label: "Home", icon: Home },
{ href: "/dashboard/collect", label: "Collect", icon: Megaphone },
{ href: "/dashboard/money", label: "Money", icon: Banknote },
{ href: "/dashboard/reports", label: "Reports", icon: FileText },
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
]
@@ -24,54 +31,61 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const user = session?.user as any
// Map old routes to new ones for active state
const isActive = (href: string) => {
if (href === "/dashboard") return pathname === "/dashboard"
if (href === "/dashboard/collect") return pathname.startsWith("/dashboard/collect") || pathname.startsWith("/dashboard/events")
if (href === "/dashboard/money") return pathname.startsWith("/dashboard/money") || pathname.startsWith("/dashboard/pledges") || pathname.startsWith("/dashboard/reconcile")
if (href === "/dashboard/reports") return pathname.startsWith("/dashboard/reports") || pathname.startsWith("/dashboard/exports")
return pathname.startsWith(href)
}
return (
<div className="min-h-screen bg-paper">
{/* Top bar — sharp, no blur */}
<div className="min-h-screen bg-[#F9FAFB]">
{/* Top bar — brand-consistent: sharp, midnight, no blur */}
<header className="sticky top-0 z-40 border-b border-gray-200 bg-white">
<div className="flex h-14 items-center gap-4 px-4 md:px-6">
<Link href="/dashboard" className="flex items-center gap-2.5">
<div className="h-7 w-7 bg-midnight flex items-center justify-center">
<div className="h-7 w-7 bg-[#111827] flex items-center justify-center">
<span className="text-white text-xs font-black">P</span>
</div>
<div className="hidden sm:block">
<span className="font-black text-sm text-midnight">{user?.orgName || "Pledge Now, Pay Later"}</span>
<span className="font-black text-sm text-[#111827] tracking-tight">{user?.orgName || "Pledge Now, Pay Later"}</span>
</div>
</Link>
<div className="flex-1" />
<Link href="/dashboard/events" className="hidden md:block">
<button className="inline-flex items-center gap-1.5 bg-midnight px-3 py-1.5 text-xs font-semibold text-white hover:bg-gray-800 transition-colors">
<Plus className="h-3 w-3" /> New Campaign
<Link href="/dashboard/collect">
<button className="hidden md:inline-flex items-center gap-1.5 bg-[#111827] px-3.5 py-1.5 text-xs font-bold text-white hover:bg-gray-800 transition-colors">
<Plus className="h-3 w-3" /> New Appeal
</button>
</Link>
<Link href="/" className="text-xs text-gray-400 hover:text-midnight transition-colors flex items-center gap-1">
<ExternalLink className="h-3 w-3" />
</Link>
{session && (
<button
onClick={() => signOut({ callbackUrl: "/login" })}
className="text-xs text-gray-400 hover:text-midnight transition-colors flex items-center gap-1"
className="text-xs text-gray-400 hover:text-[#111827] transition-colors flex items-center gap-1"
aria-label="Sign out"
>
<LogOut className="h-3 w-3" />
<LogOut className="h-3.5 w-3.5" />
</button>
)}
</div>
</header>
<div className="flex">
{/* Desktop sidebar — clean, no decorative elements */}
<aside className="hidden md:flex w-52 flex-col border-r border-gray-200 bg-white min-h-[calc(100vh-3.5rem)] py-3 px-2">
{/* Desktop sidebar — brand style: sharp, left-border active state */}
<aside className="hidden md:flex w-48 flex-col border-r border-gray-200 bg-white min-h-[calc(100vh-3.5rem)] py-4 px-2">
<nav className="space-y-0.5">
{navItems.map((item) => {
const isActive = pathname === item.href || (item.href !== "/dashboard" && pathname.startsWith(item.href))
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-2.5 px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-promise-blue/5 text-promise-blue border-l-2 border-promise-blue -ml-0.5"
: "text-gray-500 hover:bg-gray-50 hover:text-midnight"
"flex items-center gap-2.5 px-3 py-2.5 text-[13px] font-medium transition-colors",
active
? "bg-[#1E40AF]/5 text-[#1E40AF] border-l-2 border-[#1E40AF] -ml-[2px] pl-[14px]"
: "text-gray-500 hover:bg-gray-50 hover:text-[#111827]"
)}
>
<item.icon className="h-4 w-4" />
@@ -81,14 +95,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
})}
{user?.role === "super_admin" && (
<>
<div className="my-2 border-t border-gray-100" />
<div className="my-3 border-t border-gray-100" />
<Link
href={adminNav.href}
className={cn(
"flex items-center gap-2.5 px-3 py-2 text-sm font-medium transition-colors",
"flex items-center gap-2.5 px-3 py-2.5 text-[13px] font-medium transition-colors",
pathname === adminNav.href
? "bg-promise-blue/5 text-promise-blue border-l-2 border-promise-blue -ml-0.5"
: "text-gray-500 hover:bg-gray-50 hover:text-midnight"
? "bg-[#1E40AF]/5 text-[#1E40AF] border-l-2 border-[#1E40AF] -ml-[2px] pl-[14px]"
: "text-gray-500 hover:bg-gray-50 hover:text-[#111827]"
)}
>
<adminNav.icon className="h-4 w-4" />
@@ -98,41 +112,42 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
)}
</nav>
<div className="mt-auto px-2 pt-4">
<div className="border border-gray-200 p-3 space-y-1.5">
<p className="text-xs font-bold text-midnight">Need help?</p>
<p className="text-[10px] text-gray-500 leading-relaxed">
Get a fractional Head of Technology to optimise your charity&apos;s digital stack.
{/* Sidebar CTA — brand style, no emoji */}
<div className="mt-auto px-1.5 pt-4">
<div className="border-l-2 border-[#111827] pl-3 py-2">
<p className="text-xs font-bold text-[#111827]">Need expert help?</p>
<p className="text-[10px] text-gray-500 leading-relaxed mt-0.5">
Get a fractional CTO for your charity&apos;s digital stack.
</p>
<Link href="/dashboard/apply" className="inline-block text-[10px] font-semibold text-promise-blue hover:underline">
<Link href="/dashboard/apply" className="inline-block text-[10px] font-semibold text-[#1E40AF] hover:underline mt-1">
Learn more
</Link>
</div>
</div>
</aside>
{/* Mobile bottom nav */}
{/* Mobile bottom nav — 5 items, icon + label */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 border-t border-gray-200 bg-white flex justify-around py-1.5 px-1">
{navItems.slice(0, 5).map((item) => {
const isActive = pathname === item.href || (item.href !== "/dashboard" && pathname.startsWith(item.href))
{navItems.map((item) => {
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex flex-col items-center gap-0.5 py-1 px-2 transition-colors",
isActive ? "text-promise-blue" : "text-gray-400"
"flex flex-col items-center gap-0.5 py-1.5 px-2 transition-colors",
active ? "text-[#1E40AF]" : "text-gray-400"
)}
>
<item.icon className="h-5 w-5" />
<span className="text-[9px] font-medium">{item.label}</span>
<span className="text-[9px] font-semibold">{item.label}</span>
</Link>
)
})}
</nav>
{/* Main content */}
<main className="flex-1 p-4 md:p-6 pb-20 md:pb-6 max-w-6xl">
<main className="flex-1 p-4 md:p-6 lg:p-8 pb-20 md:pb-8 max-w-6xl">
<WhatsAppBanner />
{children}
</main>
@@ -148,7 +163,6 @@ function WhatsAppBanner() {
const pathname = usePathname()
useEffect(() => {
// Don't show on settings page (they're already there)
if (pathname === "/dashboard/settings") { setStatus("skip"); return }
fetch("/api/whatsapp/send")
.then(r => r.json())
@@ -159,19 +173,17 @@ function WhatsAppBanner() {
if (status === "CONNECTED" || status === "skip" || status === null || dismissed) return null
return (
<div className="mb-4 rounded-lg border-2 border-amber-200 bg-amber-50 p-4 flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-amber-100 flex items-center justify-center shrink-0 mt-0.5">
<AlertTriangle className="h-5 w-5 text-amber-600" />
</div>
<div className="mb-6 border-l-2 border-[#F59E0B] bg-[#F59E0B]/5 p-4 flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-[#F59E0B] shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-gray-900">WhatsApp not connected reminders won&apos;t send</p>
<p className="text-sm font-bold text-[#111827]">WhatsApp not connected reminders won&apos;t send</p>
<p className="text-xs text-gray-600 mt-0.5">
Connect your WhatsApp to auto-send pledge receipts and payment reminders to donors. Takes 60 seconds.
Connect your WhatsApp so donors automatically get payment reminders. Takes 60 seconds.
</p>
<div className="flex items-center gap-3 mt-2">
<div className="flex items-center gap-3 mt-2.5">
<Link
href="/dashboard/settings"
className="inline-flex items-center gap-1.5 bg-[#25D366] px-3 py-1.5 text-xs font-bold text-white hover:bg-[#25D366]/90 transition-colors rounded"
className="inline-flex items-center gap-1.5 bg-[#25D366] px-3 py-1.5 text-xs font-bold text-white hover:bg-[#25D366]/90 transition-colors"
>
<MessageCircle className="h-3.5 w-3.5" /> Connect WhatsApp
</Link>

View File

@@ -0,0 +1,5 @@
/**
* /dashboard/money — "Where's the money?"
* Merges Pledges + Reconcile into a single page with tabs.
*/
export { default } from "../pledges/page"

View File

@@ -1,134 +1,100 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import { formatPence } from "@/lib/utils"
import { TrendingUp, Users, Banknote, AlertTriangle, Calendar, Clock, CheckCircle2, ArrowRight, Loader2, MessageCircle, ExternalLink, Circle, X, Building2, Heart } from "lucide-react"
import { ArrowRight, Loader2, MessageCircle, CheckCircle2, Circle, X } from "lucide-react"
import Link from "next/link"
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; donorPhone: string | null;
eventName: string; source: string | null; giftAid: boolean;
dueDate: string | null; isDeferred: boolean; planId: string | null;
installmentNumber: number | null; installmentTotal: number | null;
createdAt: string; paidAt: string | null; nextReminder: string | null;
}>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type DashboardData = any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type OnboardingData = any
/**
* Human-readable status labels.
* These replace SaaS jargon with language a charity volunteer would use.
*/
const STATUS_LABELS: Record<string, { label: string; color: string; bg: string }> = {
new: { label: "Waiting", color: "text-gray-600", bg: "bg-gray-100" },
initiated: { label: "Said they paid", color: "text-[#F59E0B]", bg: "bg-[#F59E0B]/10" },
paid: { label: "Received", color: "text-[#16A34A]", bg: "bg-[#16A34A]/10" },
overdue: { label: "Needs a nudge", color: "text-[#DC2626]", bg: "bg-[#DC2626]/10" },
cancelled: { label: "Cancelled", color: "text-gray-400", bg: "bg-gray-50" },
}
interface OnboardingData {
steps: Array<{ id: string; label: string; desc: string; done: boolean; href: string; action?: string }>
completed: number
total: number
allDone: boolean
orgType: string | null
needsRole: boolean
orgName: string
}
// ─── Getting Started ─────────────────────────────────────────
const statusIcons: Record<string, typeof Clock> = { new: Clock, initiated: TrendingUp, paid: CheckCircle2, overdue: AlertTriangle }
const statusColors: Record<string, "secondary" | "warning" | "success" | "destructive"> = { new: "secondary", initiated: "warning", paid: "success", overdue: "destructive" }
// ─── Role Picker ────────────────────────────────────────────
function RolePicker({ onSelect }: { onSelect: (role: string) => void }) {
return (
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => onSelect("charity")}
className="rounded-lg border-2 border-gray-100 hover:border-trust-blue bg-white p-5 text-center space-y-2 transition-all hover:shadow-md group"
>
<div className="mx-auto w-12 h-12 rounded-lg bg-trust-blue/10 flex items-center justify-center group-hover:scale-110 transition-transform">
<Building2 className="h-6 w-6 text-trust-blue" />
</div>
<p className="text-sm font-bold text-gray-900">Charity / Mosque</p>
<p className="text-[11px] text-muted-foreground leading-tight">We collect donations directly via bank transfer</p>
</button>
<button
onClick={() => onSelect("fundraiser")}
className="rounded-lg border-2 border-gray-100 hover:border-warm-amber bg-white p-5 text-center space-y-2 transition-all hover:shadow-md group"
>
<div className="mx-auto w-12 h-12 rounded-lg bg-warm-amber/10 flex items-center justify-center group-hover:scale-110 transition-transform">
<Heart className="h-6 w-6 text-warm-amber" />
</div>
<p className="text-sm font-bold text-gray-900">Personal Fundraiser</p>
<p className="text-[11px] text-muted-foreground leading-tight">I have a page on LaunchGood, Enthuse, JustGiving, etc.</p>
</button>
</div>
)
}
// ─── Getting Started Banner ─────────────────────────────────
function GettingStartedBanner({
ob,
onSetRole,
dismissed,
onDismiss,
function GettingStarted({
ob, onSetRole, dismissed, onDismiss,
}: {
ob: OnboardingData
onSetRole: (role: string) => void
dismissed: boolean
onDismiss: () => void
ob: OnboardingData; onSetRole: (role: string) => void; dismissed: boolean; onDismiss: () => void
}) {
const [showRolePicker, setShowRolePicker] = useState(!ob.orgType || ob.orgType === "charity")
if (ob.allDone || dismissed) return null
// First-time: show role picker
const isFirstTime = ob.completed === 0 && (!ob.orgType || ob.orgType === "charity")
const isFirstTime = ob.completed === 0
return (
<div className="rounded-lg border border-trust-blue/20 bg-paper p-5 space-y-4 relative">
{/* Dismiss X */}
<button onClick={onDismiss} className="absolute top-3 right-3 text-muted-foreground hover:text-foreground p-1">
<div className="border-l-2 border-[#1E40AF] bg-white p-5 relative">
<button onClick={onDismiss} className="absolute top-3 right-3 text-gray-300 hover:text-gray-600 p-1">
<X className="h-4 w-4" />
</button>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-midnight flex items-center justify-center flex-shrink-0">
<span className="text-white text-lg">🤲</span>
<div className="flex items-center gap-3 mb-4">
<div className="h-8 w-8 bg-[#111827] flex items-center justify-center">
<span className="text-white text-xs font-black">P</span>
</div>
<div>
<h2 className="text-sm font-bold text-gray-900">
{isFirstTime ? "Welcome! What best describes you?" : `Getting started · ${ob.completed}/${ob.total}`}
<h2 className="text-sm font-black text-[#111827]">
{isFirstTime ? "Let's get you set up" : `Getting started ${ob.completed} of ${ob.total} done`}
</h2>
{!isFirstTime && (
<Progress value={(ob.completed / ob.total) * 100} className="h-1.5 mt-1.5 w-32" indicatorClassName="bg-promise-blue" />
<div className="flex gap-1 mt-1.5">
{ob.steps.map((step: { id: string; done: boolean }) => (
<div key={step.id} className={`h-1 w-8 ${step.done ? "bg-[#1E40AF]" : "bg-gray-200"}`} />
))}
</div>
)}
</div>
</div>
{isFirstTime && showRolePicker ? (
<RolePicker onSelect={(role) => { onSetRole(role); setShowRolePicker(false) }} />
{isFirstTime && !ob.orgType ? (
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => onSetRole("charity")}
className="border-2 border-gray-100 hover:border-[#1E40AF] bg-white p-4 text-left transition-all group"
>
<p className="text-sm font-bold text-[#111827]">Charity or Mosque</p>
<p className="text-[11px] text-gray-500 mt-1">We collect donations via bank transfer</p>
</button>
<button
onClick={() => onSetRole("fundraiser")}
className="border-2 border-gray-100 hover:border-[#F59E0B] bg-white p-4 text-left transition-all group"
>
<p className="text-sm font-bold text-[#111827]">Personal Fundraiser</p>
<p className="text-[11px] text-gray-500 mt-1">I use LaunchGood, JustGiving, etc.</p>
</button>
</div>
) : (
<div className="space-y-1.5">
{ob.steps.map((step, i) => {
const isNext = !step.done && ob.steps.slice(0, i).every(s => s.done)
<div className="space-y-1">
{ob.steps.map((step: { id: string; label: string; done: boolean; href: string }, i: number) => {
const isNext = !step.done && ob.steps.slice(0, i).every((s: { done: boolean }) => s.done)
return (
<Link key={step.id} href={step.href}>
<div className={`flex items-center gap-2.5 rounded-lg border px-3 py-2.5 transition-all ${
step.done ? "bg-success-green/5 border-success-green/20" :
isNext ? "bg-trust-blue/5 border-trust-blue/20 shadow-sm" :
"bg-white border-gray-100"
<div className={`flex items-center gap-2.5 px-3 py-2.5 transition-all ${
step.done ? "opacity-50" :
isNext ? "bg-[#1E40AF]/5 border-l-2 border-[#1E40AF] -ml-[2px] pl-[14px]" :
""
}`}>
{step.done ? (
<CheckCircle2 className="h-4 w-4 text-success-green flex-shrink-0" />
<CheckCircle2 className="h-4 w-4 text-[#16A34A] shrink-0" />
) : isNext ? (
<div className="h-4 w-4 rounded-full bg-trust-blue text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0">{i + 1}</div>
<div className="h-4 w-4 bg-[#1E40AF] text-white text-[10px] font-bold flex items-center justify-center shrink-0">{i + 1}</div>
) : (
<Circle className="h-4 w-4 text-gray-300 flex-shrink-0" />
<Circle className="h-4 w-4 text-gray-300 shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className={`text-xs font-medium truncate ${step.done ? "text-success-green line-through" : isNext ? "text-gray-900" : "text-gray-400"}`}>{step.label}</p>
</div>
{isNext && <ArrowRight className="h-3 w-3 text-trust-blue flex-shrink-0" />}
<span className={`text-xs font-medium ${step.done ? "line-through text-gray-400" : isNext ? "text-[#111827]" : "text-gray-400"}`}>
{step.label}
</span>
{isNext && <ArrowRight className="h-3 w-3 text-[#1E40AF] ml-auto shrink-0" />}
</div>
</Link>
)
@@ -140,6 +106,7 @@ function GettingStartedBanner({
}
// ─── Main Dashboard ─────────────────────────────────────────
export default function DashboardPage() {
const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
@@ -169,7 +136,6 @@ export default function DashboardPage() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orgType: role }),
})
// Refresh onboarding state
const res = await fetch("/api/onboarding")
const d = await res.json()
if (d.steps) setOb(d)
@@ -178,291 +144,227 @@ export default function DashboardPage() {
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
<Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" />
</div>
)
}
const s = data?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0, overdueRate: 0 }
const s = data?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0 }
const byStatus = data?.byStatus || {}
const topSources = data?.topSources || []
const pledges = data?.pledges || []
const upcomingPledges = pledges.filter(p =>
p.isDeferred && p.dueDate && p.status !== "paid" && p.status !== "cancelled"
).sort((a, b) => new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime())
const recentPledges = pledges.filter(p => p.status !== "cancelled").slice(0, 8)
const needsAction = [
...pledges.filter(p => p.status === "overdue"),
...upcomingPledges.filter(p => {
const due = new Date(p.dueDate!)
return due.getTime() - Date.now() < 2 * 86400000
})
const recentPledges = pledges.filter((p: { status: string }) => p.status !== "cancelled").slice(0, 8)
const needsAttention = [
...pledges.filter((p: { status: string }) => p.status === "overdue"),
...pledges.filter((p: { status: string; dueDate: string | null }) =>
p.status !== "paid" && p.status !== "cancelled" && p.dueDate &&
new Date(p.dueDate).getTime() - Date.now() < 2 * 86400000
),
].slice(0, 5)
const isEmpty = s.totalPledges === 0
return (
<div className="space-y-6">
{/* Getting-started banner — always at top, not a blocker */}
<div className="space-y-8">
{/* Onboarding */}
{ob && !ob.allDone && (
<GettingStartedBanner ob={ob} onSetRole={handleSetRole} dismissed={bannerDismissed} onDismiss={() => setBannerDismissed(true)} />
<GettingStarted ob={ob} onSetRole={handleSetRole} dismissed={bannerDismissed} onDismiss={() => setBannerDismissed(true)} />
)}
{/* Page header — brand typography */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-black text-gray-900">Dashboard</h1>
<p className="text-sm text-muted-foreground mt-0.5">
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Home</h1>
<p className="text-sm text-gray-500 mt-0.5">
{whatsappStatus !== null && (
<span className={`inline-flex items-center gap-1 mr-3 ${whatsappStatus ? "text-[#25D366]" : "text-muted-foreground"}`}>
<span className={`inline-flex items-center gap-1 mr-3 ${whatsappStatus ? "text-[#25D366]" : "text-gray-400"}`}>
<MessageCircle className="h-3 w-3" />
{whatsappStatus ? "WhatsApp connected" : "WhatsApp offline"}
</span>
)}
{isEmpty ? "Your data will appear here" : "Auto-refreshes every 15s"}
{isEmpty ? "Your numbers will appear here once donors start pledging" : "Updates every 15 seconds"}
</p>
</div>
{!isEmpty && (
<Link href="/dashboard/pledges">
<Button variant="outline" size="sm">View All Pledges <ArrowRight className="h-3 w-3 ml-1" /></Button>
<Link href="/dashboard/money" className="text-xs font-semibold text-[#1E40AF] hover:underline flex items-center gap-1">
View all <ArrowRight className="h-3 w-3" />
</Link>
)}
</div>
{/* Stats — always show, even with zeros */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<Card className={isEmpty ? "opacity-60" : ""}>
<CardContent className="pt-5 pb-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-trust-blue/10 p-2.5"><Users className="h-5 w-5 text-trust-blue" /></div>
<div>
<p className="text-2xl font-black">{s.totalPledges}</p>
<p className="text-xs text-muted-foreground">Total Pledges</p>
</div>
</div>
</CardContent>
</Card>
<Card className={isEmpty ? "opacity-60" : ""}>
<CardContent className="pt-5 pb-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-warm-amber/10 p-2.5"><Banknote className="h-5 w-5 text-warm-amber" /></div>
<div>
<p className="text-2xl font-black">{formatPence(s.totalPledgedPence)}</p>
<p className="text-xs text-muted-foreground">Total Pledged</p>
</div>
</div>
</CardContent>
</Card>
<Card className={isEmpty ? "opacity-60" : ""}>
<CardContent className="pt-5 pb-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-success-green/10 p-2.5"><TrendingUp className="h-5 w-5 text-success-green" /></div>
<div>
<p className="text-2xl font-black">{formatPence(s.totalCollectedPence)}</p>
<p className="text-xs text-muted-foreground">Collected ({s.collectionRate}%)</p>
</div>
</div>
</CardContent>
</Card>
<Card className={isEmpty ? "opacity-60" : s.overdueRate > 10 ? "border-danger-red/30" : ""}>
<CardContent className="pt-5 pb-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-danger-red/10 p-2.5"><AlertTriangle className="h-5 w-5 text-danger-red" /></div>
<div>
<p className="text-2xl font-black">{byStatus.overdue || 0}</p>
<p className="text-xs text-muted-foreground">Overdue</p>
</div>
</div>
</CardContent>
</Card>
{/* ─── Big Numbers — gap-px grid (brand pattern) ─── */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-px bg-gray-200">
{[
{ value: String(s.totalPledges), label: "Pledges", sub: isEmpty ? "—" : undefined },
{ value: formatPence(s.totalPledgedPence), label: "Promised" },
{ value: formatPence(s.totalCollectedPence), label: "Received", accent: true },
{ value: `${s.collectionRate}%`, label: "Collected" },
].map((stat) => (
<div key={stat.label} className={`bg-white p-5 ${isEmpty ? "opacity-50" : ""}`}>
<p className={`text-2xl md:text-3xl font-black tracking-tight ${stat.accent ? "text-[#16A34A]" : "text-[#111827]"}`}>
{stat.value}
</p>
<p className="text-xs text-gray-500 mt-1">{stat.label}</p>
</div>
))}
</div>
{/* Collection progress — always visible */}
<Card className={isEmpty ? "opacity-60" : ""}>
<CardContent className="pt-5 pb-4">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium">Pledged Collected</span>
<span className="text-sm font-bold text-muted-foreground">{s.collectionRate}%</span>
{/* ─── Collection Progress — brand bar ─── */}
{!isEmpty && (
<div className="bg-white p-5">
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-bold text-[#111827]">Promised Received</span>
<span className="text-sm font-black text-[#111827]">{s.collectionRate}%</span>
</div>
<Progress value={s.collectionRate} indicatorClassName="bg-promise-blue" />
<div className="flex justify-between mt-2 text-xs text-muted-foreground">
<span>{formatPence(s.totalCollectedPence)} collected</span>
<span>{formatPence(s.totalPledgedPence - s.totalCollectedPence)} outstanding</span>
<div className="h-3 bg-gray-100 overflow-hidden">
<div className="h-full bg-[#1E40AF] transition-all duration-700" style={{ width: `${s.collectionRate}%` }} />
</div>
</CardContent>
</Card>
<div className="flex justify-between mt-2 text-xs text-gray-500">
<span>{formatPence(s.totalCollectedPence)} received</span>
<span>{formatPence(s.totalPledgedPence - s.totalCollectedPence)} still to come</span>
</div>
</div>
)}
{isEmpty ? (
/* Empty state — gentle nudge, not a blocker */
<Card className="border-dashed">
<CardContent className="py-10 text-center space-y-3">
<Calendar className="h-10 w-10 text-muted-foreground/40 mx-auto" />
<h3 className="text-sm font-bold text-gray-900">Your pledge data will appear here</h3>
<p className="text-xs text-muted-foreground max-w-sm mx-auto">
Once you share your first link and donors start pledging, you&apos;ll see live stats, payment tracking, and reminders.
</p>
<div className="flex gap-2 justify-center pt-2">
<Link href="/dashboard/events">
<Button size="sm">Create a Campaign </Button>
</Link>
</div>
</CardContent>
</Card>
/* Empty state — clean, directive */
<div className="bg-white border-2 border-dashed border-gray-200 p-10 text-center">
<p className="text-4xl font-black text-gray-200 mb-3">01</p>
<h3 className="text-base font-bold text-[#111827]">Share your first pledge link</h3>
<p className="text-sm text-gray-500 mt-1 max-w-sm mx-auto">
Create an appeal, share the link with donors, and watch pledges come in here.
</p>
<Link href="/dashboard/collect">
<button className="mt-4 bg-[#111827] px-5 py-2.5 text-sm font-bold text-white hover:bg-gray-800 transition-colors">
Create an Appeal
</button>
</Link>
</div>
) : (
<>
<div className="grid lg:grid-cols-2 gap-4">
<div className="grid lg:grid-cols-5 gap-6">
{/* LEFT: Needs attention + Pipeline */}
<div className="lg:col-span-2 space-y-6">
{/* Needs attention */}
<Card className={needsAction.length > 0 ? "border-warm-amber/30" : ""}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-warm-amber" /> Needs Attention
{needsAction.length > 0 && <Badge variant="warning">{needsAction.length}</Badge>}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{needsAction.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">All clear! No urgent items.</p>
) : (
needsAction.map(p => (
<div key={p.id} className="flex items-center justify-between py-2 border-b last:border-0">
<div>
<p className="text-sm font-medium">{p.donorName || "Anonymous"}</p>
<p className="text-xs text-muted-foreground">
{formatPence(p.amountPence)} · {p.eventName}
{p.dueDate && ` · Due ${new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`}
</p>
{needsAttention.length > 0 && (
<div className="bg-white">
<div className="border-b border-gray-100 px-5 py-3 flex items-center justify-between">
<h3 className="text-sm font-bold text-[#111827]">Needs attention</h3>
<span className="text-[10px] font-bold text-white bg-[#DC2626] px-1.5 py-0.5">{needsAttention.length}</span>
</div>
<div className="divide-y divide-gray-50">
{needsAttention.map((p: { id: string; donorName: string | null; amountPence: number; eventName: string; status: string; dueDate: string | null }) => {
const sl = STATUS_LABELS[p.status] || STATUS_LABELS.new
return (
<div key={p.id} className="px-5 py-3 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[#111827]">{p.donorName || "Anonymous"}</p>
<p className="text-xs text-gray-500">{formatPence(p.amountPence)} · {p.eventName}</p>
</div>
<span className={`text-[10px] font-bold px-2 py-0.5 ${sl.bg} ${sl.color}`}>{sl.label}</span>
</div>
<Badge variant={p.status === "overdue" ? "destructive" : "warning"}>
{p.status === "overdue" ? "Overdue" : "Due soon"}
</Badge>
</div>
))
)}
{needsAction.length > 0 && (
<Link href="/dashboard/pledges?tab=overdue" className="text-xs text-trust-blue hover:underline flex items-center gap-1 pt-1">
View all <ArrowRight className="h-3 w-3" />
)
})}
</div>
<div className="px-5 py-2 border-t border-gray-50">
<Link href="/dashboard/money" className="text-xs font-semibold text-[#1E40AF] hover:underline">
View all
</Link>
)}
</CardContent>
</Card>
</div>
</div>
)}
{/* Upcoming payments */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Calendar className="h-4 w-4 text-trust-blue" /> Upcoming Payments
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{upcomingPledges.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No scheduled payments</p>
) : (
upcomingPledges.slice(0, 5).map(p => (
<div key={p.id} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-trust-blue/5 flex items-center justify-center text-xs font-bold text-trust-blue">
{new Date(p.dueDate!).getDate()}
<br />
<span className="text-[8px]">{new Date(p.dueDate!).toLocaleDateString("en-GB", { month: "short" })}</span>
</div>
<div>
<p className="text-sm font-medium">{p.donorName || "Anonymous"}</p>
<p className="text-xs text-muted-foreground">
{p.eventName}
{p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`}
</p>
</div>
</div>
<span className="font-bold text-sm">{formatPence(p.amountPence)}</span>
</div>
))
)}
</CardContent>
</Card>
</div>
{/* Pipeline + Sources */}
<div className="grid lg:grid-cols-2 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Pipeline by Status</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* How pledges are doing — gap-px grid */}
<div className="bg-white">
<div className="border-b border-gray-100 px-5 py-3">
<h3 className="text-sm font-bold text-[#111827]">How pledges are doing</h3>
</div>
<div className="divide-y divide-gray-50">
{Object.entries(byStatus).map(([status, count]) => {
const Icon = statusIcons[status] || Clock
const sl = STATUS_LABELS[status] || STATUS_LABELS.new
return (
<div key={status} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<Badge variant={statusColors[status] || "secondary"}>{status}</Badge>
</div>
<span className="font-bold">{count}</span>
</div>
)
})}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Top Sources</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{topSources.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">Create QR codes to track sources</p>
) : (
topSources.slice(0, 6).map((src, i) => (
<div key={i} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-muted-foreground w-5">{i + 1}</span>
<span className="text-sm">{src.label}</span>
<span className="text-xs text-muted-foreground">{src.count} pledges</span>
</div>
<span className="font-bold text-sm">{formatPence(src.amount)}</span>
</div>
))
)}
</CardContent>
</Card>
</div>
{/* Recent pledges */}
<Card>
<CardHeader className="pb-3 flex flex-row items-center justify-between">
<CardTitle className="text-base">Recent Pledges</CardTitle>
<Link href="/dashboard/pledges">
<Button variant="ghost" size="sm" className="text-xs">View all <ExternalLink className="h-3 w-3 ml-1" /></Button>
</Link>
</CardHeader>
<CardContent>
<div className="space-y-2">
{recentPledges.map(p => {
const sc = statusColors[p.status] || "secondary"
return (
<div key={p.id} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-trust-blue/10 flex items-center justify-center text-xs font-bold text-trust-blue">
{(p.donorName || "A")[0].toUpperCase()}
</div>
<div>
<p className="text-sm font-medium">{p.donorName || "Anonymous"}</p>
<p className="text-xs text-muted-foreground">
{p.eventName} · {new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}
{p.dueDate && !p.paidAt && ` · Due ${new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`}
{p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="font-bold text-sm">{formatPence(p.amountPence)}</span>
<Badge variant={sc}>{p.status}</Badge>
</div>
<div key={status} className="px-5 py-2.5 flex items-center justify-between">
<span className={`text-xs font-bold px-2 py-0.5 ${sl.bg} ${sl.color}`}>{sl.label}</span>
<span className="text-sm font-black text-[#111827]">{count as number}</span>
</div>
)
})}
</div>
</CardContent>
</Card>
</>
</div>
{/* Top sources */}
{topSources.length > 0 && (
<div className="bg-white">
<div className="border-b border-gray-100 px-5 py-3">
<h3 className="text-sm font-bold text-[#111827]">Where pledges come from</h3>
</div>
<div className="divide-y divide-gray-50">
{topSources.slice(0, 5).map((src: { label: string; count: number; amount: number }, i: number) => (
<div key={i} className="px-5 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span className="text-xs font-black text-gray-300 w-4">{i + 1}</span>
<span className="text-sm text-[#111827]">{src.label}</span>
<span className="text-[10px] text-gray-400">{src.count} pledges</span>
</div>
<span className="text-sm font-bold text-[#111827]">{formatPence(src.amount)}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* RIGHT: Recent pledges */}
<div className="lg:col-span-3">
<div className="bg-white">
<div className="border-b border-gray-100 px-5 py-3 flex items-center justify-between">
<h3 className="text-sm font-bold text-[#111827]">Recent pledges</h3>
<Link href="/dashboard/money" className="text-xs font-semibold text-[#1E40AF] hover:underline">
View all
</Link>
</div>
<div className="divide-y divide-gray-50">
{recentPledges.map((p: {
id: string; donorName: string | null; amountPence: number; status: string;
eventName: string; createdAt: string; donorPhone: string | null;
installmentNumber: number | null; installmentTotal: number | null;
}) => {
const sl = STATUS_LABELS[p.status] || STATUS_LABELS.new
const initial = (p.donorName || "A")[0].toUpperCase()
const daysDiff = Math.floor((Date.now() - new Date(p.createdAt).getTime()) / 86400000)
const timeLabel = daysDiff === 0 ? "Today" : daysDiff === 1 ? "Yesterday" : daysDiff < 7 ? `${daysDiff}d ago` : new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })
return (
<div key={p.id} className="px-5 py-3 flex items-center gap-3">
<div className="h-8 w-8 bg-[#1E40AF]/10 flex items-center justify-center shrink-0">
<span className="text-xs font-black text-[#1E40AF]">{initial}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-[#111827] truncate">{p.donorName || "Anonymous"}</p>
{p.donorPhone && <MessageCircle className="h-3 w-3 text-[#25D366] shrink-0" />}
</div>
<p className="text-xs text-gray-500 truncate">
{p.eventName} · {timeLabel}
{p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-black text-[#111827]">{formatPence(p.amountPence)}</p>
<span className={`text-[10px] font-bold px-1.5 py-0.5 ${sl.bg} ${sl.color}`}>{sl.label}</span>
</div>
</div>
)
})}
{recentPledges.length === 0 && (
<div className="px-5 py-8 text-center text-sm text-gray-400">
Pledges will appear here as they come in
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
)

View File

@@ -1,77 +1,55 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"
import { Progress } from "@/components/ui/progress"
import { useToast } from "@/components/ui/toast"
import {
Search, MoreVertical, Calendar, Clock, AlertTriangle,
CheckCircle2, XCircle, MessageCircle, Send, Filter,
ChevronLeft, ChevronRight, Users, Loader2
Search, MoreVertical, CheckCircle2, XCircle, MessageCircle, Send,
ChevronLeft, ChevronRight, Loader2
} from "lucide-react"
import Link from "next/link"
interface Pledge {
id: string
reference: string
amountPence: number
status: string
rail: string
donorName: string | null
donorEmail: string | null
donorPhone: string | null
giftAid: boolean
dueDate: string | null
planId: string | null
installmentNumber: number | null
installmentTotal: number | null
eventName: string
qrSourceLabel: string | null
volunteerName: string | null
createdAt: string
paidAt: string | null
id: string; reference: string; amountPence: number; status: string; rail: string
donorName: string | null; donorEmail: string | null; donorPhone: string | null
giftAid: boolean; dueDate: string | null; planId: string | null
installmentNumber: number | null; installmentTotal: number | null
eventName: string; qrSourceLabel: string | null; volunteerName: string | null
createdAt: string; paidAt: string | null
}
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "success" | "warning" | "destructive"; icon: typeof Clock }> = {
new: { label: "Pending", variant: "secondary", icon: Clock },
initiated: { label: "Initiated", variant: "warning", icon: Send },
paid: { label: "Paid", variant: "success", icon: CheckCircle2 },
overdue: { label: "Overdue", variant: "destructive", icon: AlertTriangle },
cancelled: { label: "Cancelled", variant: "secondary", icon: XCircle },
/**
* Human status labels — no SaaS jargon
*/
const STATUS: Record<string, { label: string; color: string; bg: string }> = {
new: { label: "Waiting", color: "text-gray-600", bg: "bg-gray-100" },
initiated: { label: "Said they paid", color: "text-[#F59E0B]", bg: "bg-[#F59E0B]/10" },
paid: { label: "Received ✓", color: "text-[#16A34A]", bg: "bg-[#16A34A]/10" },
overdue: { label: "Needs a nudge", color: "text-[#DC2626]", bg: "bg-[#DC2626]/10" },
cancelled: { label: "Cancelled", color: "text-gray-400", bg: "bg-gray-50" },
}
const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}`
function timeAgo(dateStr: string) {
const d = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - d.getTime()
const days = Math.floor(diff / 86400000)
const days = Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000)
if (days === 0) return "Today"
if (days === 1) return "Yesterday"
if (days < 7) return `${days}d ago`
if (days < 30) return `${Math.floor(days / 7)}w ago`
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" })
return new Date(dateStr).toLocaleDateString("en-GB", { day: "numeric", month: "short" })
}
function dueLabel(dueDate: string) {
const d = new Date(dueDate)
const now = new Date()
const diff = d.getTime() - now.getTime()
const days = Math.ceil(diff / 86400000)
const days = Math.ceil((new Date(dueDate).getTime() - Date.now()) / 86400000)
if (days < 0) return { text: `${Math.abs(days)}d overdue`, urgent: true }
if (days === 0) return { text: "Due today", urgent: true }
if (days === 1) return { text: "Due tomorrow", urgent: false }
if (days <= 7) return { text: `Due in ${days}d`, urgent: false }
return { text: d.toLocaleDateString("en-GB", { day: "numeric", month: "short" }), urgent: false }
return { text: new Date(dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "short" }), urgent: false }
}
export default function PledgesPage() {
export default function MoneyPage() {
const [pledges, setPledges] = useState<Pledge[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
@@ -82,21 +60,19 @@ export default function PledgesPage() {
const { toast } = useToast()
const pageSize = 25
// Stats
const [stats, setStats] = useState({ total: 0, pending: 0, dueSoon: 0, overdue: 0, paid: 0, totalPledged: 0, totalCollected: 0 })
const [stats, setStats] = useState({ total: 0, waiting: 0, overdue: 0, paid: 0, totalPledged: 0, totalCollected: 0 })
const fetchPledges = useCallback(async () => {
const params = new URLSearchParams()
params.set("limit", String(pageSize))
params.set("offset", String(page * pageSize))
if (tab !== "all") {
if (tab === "due-soon") params.set("dueSoon", "true")
else if (tab === "overdue") params.set("overdue", "true")
if (tab === "overdue") params.set("overdue", "true")
else params.set("status", tab)
}
if (search) params.set("search", search)
params.set("sort", tab === "due-soon" ? "dueDate" : "createdAt")
params.set("dir", tab === "due-soon" ? "asc" : "desc")
params.set("sort", "createdAt")
params.set("dir", "desc")
const res = await fetch(`/api/pledges?${params}`)
const data = await res.json()
@@ -105,30 +81,25 @@ export default function PledgesPage() {
setLoading(false)
}, [tab, search, page])
const fetchStats = useCallback(async () => {
const res = await fetch("/api/dashboard")
const data = await res.json()
if (data.summary) {
setStats({
total: data.summary.totalPledges,
pending: data.byStatus?.new || 0,
dueSoon: 0, // calculated client-side
overdue: data.byStatus?.overdue || 0,
paid: data.byStatus?.paid || 0,
totalPledged: data.summary.totalPledgedPence,
totalCollected: data.summary.totalCollectedPence,
})
}
useEffect(() => {
fetch("/api/dashboard")
.then(r => r.json())
.then(data => {
if (data.summary) {
setStats({
total: data.summary.totalPledges,
waiting: (data.byStatus?.new || 0) + (data.byStatus?.initiated || 0),
overdue: data.byStatus?.overdue || 0,
paid: data.byStatus?.paid || 0,
totalPledged: data.summary.totalPledgedPence,
totalCollected: data.summary.totalCollectedPence,
})
}
}).catch(() => {})
}, [])
useEffect(() => { fetchPledges() }, [fetchPledges])
useEffect(() => { fetchStats() }, [fetchStats])
// Auto-refresh
useEffect(() => {
const interval = setInterval(fetchPledges, 30000)
return () => clearInterval(interval)
}, [fetchPledges])
useEffect(() => { const i = setInterval(fetchPledges, 30000); return () => clearInterval(i) }, [fetchPledges])
const updateStatus = async (pledgeId: string, newStatus: string) => {
setUpdating(pledgeId)
@@ -139,39 +110,24 @@ export default function PledgesPage() {
body: JSON.stringify({ status: newStatus }),
})
setPledges(prev => prev.map(p => p.id === pledgeId ? { ...p, status: newStatus } : p))
toast(`Pledge marked as ${newStatus}`, "success")
} catch {
toast("Failed to update", "error")
}
toast(`Updated`, "success")
} catch { toast("Failed to update", "error") }
setUpdating(null)
}
const sendReminder = async (pledge: Pledge) => {
if (!pledge.donorPhone) {
toast("No phone number — can't send WhatsApp", "error")
return
}
if (!pledge.donorPhone) { toast("No phone number", "error"); return }
try {
await fetch("/api/whatsapp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "reminder",
phone: pledge.donorPhone,
data: {
donorName: pledge.donorName,
amountPounds: (pledge.amountPence / 100).toFixed(0),
eventName: pledge.eventName,
reference: pledge.reference,
daysSincePledge: Math.floor((Date.now() - new Date(pledge.createdAt).getTime()) / 86400000),
step: 1,
},
type: "reminder", phone: pledge.donorPhone,
data: { donorName: pledge.donorName, amountPounds: (pledge.amountPence / 100).toFixed(0), eventName: pledge.eventName, reference: pledge.reference, daysSincePledge: Math.floor((Date.now() - new Date(pledge.createdAt).getTime()) / 86400000), step: 1 },
}),
})
toast("Reminder sent via WhatsApp", "success")
} catch {
toast("Failed to send", "error")
}
toast("Reminder sent via WhatsApp", "success")
} catch { toast("Failed to send", "error") }
}
const collectionRate = stats.totalPledged > 0 ? Math.round((stats.totalCollected / stats.totalPledged) * 100) : 0
@@ -182,236 +138,193 @@ export default function PledgesPage() {
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-black text-gray-900">Pledges</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{stats.total} total · {formatPence(stats.totalPledged)} pledged · {collectionRate}% collected
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Money</h1>
<p className="text-sm text-gray-500 mt-0.5">
{stats.total} pledges · {formatPence(stats.totalPledged)} promised · {collectionRate}% received
</p>
</div>
<div className="flex gap-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search name, email, ref..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0) }}
className="pl-9 w-64"
/>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
placeholder="Search name, email, reference..."
value={search}
onChange={e => { setSearch(e.target.value); setPage(0) }}
className="pl-9 w-64 h-9 border border-gray-200 text-sm focus:border-[#1E40AF] focus:ring-1 focus:ring-[#1E40AF]/20 outline-none transition-colors"
/>
</div>
</div>
{/* Stats row */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setTab("all")}>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-trust-blue" />
<span className="text-xs text-muted-foreground">All</span>
</div>
<p className="text-xl font-bold mt-1">{stats.total}</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setTab("new")}>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-warm-amber" />
<span className="text-xs text-muted-foreground">Pending</span>
</div>
<p className="text-xl font-bold mt-1">{stats.pending}</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow border-warm-amber/30" onClick={() => setTab("due-soon")}>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-warm-amber" />
<span className="text-xs text-muted-foreground">Due Soon</span>
</div>
<p className="text-xl font-bold mt-1 text-warm-amber">{stats.dueSoon || "—"}</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow border-danger-red/30" onClick={() => setTab("overdue")}>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-danger-red" />
<span className="text-xs text-muted-foreground">Overdue</span>
</div>
<p className="text-xl font-bold mt-1 text-danger-red">{stats.overdue}</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setTab("paid")}>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-success-green" />
<span className="text-xs text-muted-foreground">Paid</span>
</div>
<p className="text-xl font-bold mt-1 text-success-green">{stats.paid}</p>
</CardContent>
</Card>
{/* Quick stats — gap-px */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-px bg-gray-200">
{[
{ label: "All", count: stats.total, onClick: () => setTab("all") },
{ label: "Waiting", count: stats.waiting, onClick: () => setTab("new") },
{ label: "Needs a nudge", count: stats.overdue, onClick: () => setTab("overdue"), alert: stats.overdue > 0 },
{ label: "Received", count: stats.paid, onClick: () => setTab("paid"), accent: true },
].map(s => (
<button key={s.label} onClick={s.onClick} className={`bg-white p-4 text-left hover:bg-gray-50 transition-colors ${tab === (s.label === "All" ? "all" : s.label === "Waiting" ? "new" : s.label === "Received" ? "paid" : "overdue") ? "border-b-2 border-[#1E40AF]" : ""}`}>
<p className={`text-xl font-black ${s.alert ? "text-[#DC2626]" : s.accent ? "text-[#16A34A]" : "text-[#111827]"}`}>{s.count}</p>
<p className="text-[10px] text-gray-500 mt-0.5">{s.label}</p>
</button>
))}
</div>
{/* Collection progress */}
{/* Collection bar */}
<div className="flex items-center gap-4">
<Progress value={collectionRate} className="flex-1 h-2" indicatorClassName="bg-promise-blue" />
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
{formatPence(stats.totalCollected)} / {formatPence(stats.totalPledged)}
</span>
<div className="flex-1 h-2 bg-gray-100 overflow-hidden">
<div className="h-full bg-[#1E40AF] transition-all" style={{ width: `${collectionRate}%` }} />
</div>
<span className="text-xs font-bold text-gray-500 whitespace-nowrap">{formatPence(stats.totalCollected)} / {formatPence(stats.totalPledged)}</span>
</div>
{/* Tabs + Table */}
<Tabs value={tab} onValueChange={(v) => { setTab(v); setPage(0) }}>
<TabsList className="w-full sm:w-auto overflow-x-auto">
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="new">Pending</TabsTrigger>
<TabsTrigger value="due-soon">Due Soon</TabsTrigger>
<TabsTrigger value="overdue">Overdue</TabsTrigger>
<TabsTrigger value="initiated">Initiated</TabsTrigger>
<TabsTrigger value="paid">Paid</TabsTrigger>
<TabsTrigger value="cancelled">Cancelled</TabsTrigger>
<Tabs value={tab} onValueChange={v => { setTab(v); setPage(0) }}>
<TabsList className="w-full sm:w-auto overflow-x-auto bg-transparent border-b border-gray-200 rounded-none p-0 h-auto">
{[
{ value: "all", label: "All" },
{ value: "new", label: "Waiting" },
{ value: "initiated", label: "Said they paid" },
{ value: "overdue", label: "Needs a nudge" },
{ value: "paid", label: "Received" },
{ value: "cancelled", label: "Cancelled" },
].map(t => (
<TabsTrigger key={t.value} value={t.value} className="rounded-none border-b-2 border-transparent data-[state=active]:border-[#1E40AF] data-[state=active]:bg-transparent data-[state=active]:shadow-none text-xs font-semibold px-3 py-2">
{t.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value={tab}>
<Card>
<CardContent className="p-0">
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-6 w-6 text-trust-blue animate-spin" />
</div>
) : pledges.length === 0 ? (
<div className="text-center py-16 space-y-3">
<Filter className="h-8 w-8 text-muted-foreground mx-auto" />
<p className="font-medium text-gray-900">No pledges found</p>
<p className="text-sm text-muted-foreground">
{search ? `No results for "${search}"` : "Create an event and share QR codes to start collecting pledges"}
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Donor</TableHead>
<TableHead>Amount</TableHead>
<TableHead className="hidden md:table-cell">Event</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden sm:table-cell">Due / Created</TableHead>
<TableHead className="hidden lg:table-cell">Method</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pledges.map((p) => {
const sc = statusConfig[p.status] || statusConfig.new
const due = p.dueDate ? dueLabel(p.dueDate) : null
const isInstallment = p.installmentTotal && p.installmentTotal > 1
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
) : pledges.length === 0 ? (
<div className="text-center py-16">
<p className="text-sm font-medium text-[#111827]">No pledges found</p>
<p className="text-xs text-gray-500 mt-1">{search ? `No results for "${search}"` : "Share your pledge links to start collecting"}</p>
</div>
) : (
<div className="bg-white">
{/* Table header */}
<div className="grid grid-cols-12 gap-2 px-5 py-2.5 border-b border-gray-100 text-[10px] font-bold text-gray-400 uppercase tracking-wide">
<div className="col-span-4">Donor</div>
<div className="col-span-2">Amount</div>
<div className="col-span-2 hidden md:block">Appeal</div>
<div className="col-span-2">Status</div>
<div className="col-span-1 hidden sm:block">When</div>
<div className="col-span-1"></div>
</div>
return (
<TableRow key={p.id} className={updating === p.id ? "opacity-50" : ""}>
<TableCell>
<div>
<p className="font-medium text-sm">{p.donorName || "Anonymous"}</p>
<p className="text-xs text-muted-foreground font-mono">{p.reference}</p>
{p.donorPhone && (
<p className="text-[10px] text-[#25D366] flex items-center gap-0.5 mt-0.5">
<MessageCircle className="h-2.5 w-2.5" /> WhatsApp
</p>
)}
</div>
</TableCell>
<TableCell>
<p className="font-bold">{formatPence(p.amountPence)}</p>
{p.giftAid && <span className="text-[10px] text-success-green">🎁 +Gift Aid</span>}
{isInstallment && (
<p className="text-[10px] text-warm-amber font-medium">
{p.installmentNumber}/{p.installmentTotal}
</p>
)}
</TableCell>
<TableCell className="hidden md:table-cell">
<p className="text-sm truncate max-w-[140px]">{p.eventName}</p>
{p.qrSourceLabel && (
<p className="text-[10px] text-muted-foreground">{p.qrSourceLabel}</p>
)}
</TableCell>
<TableCell>
<Badge variant={sc.variant} className="gap-1 text-[11px]">
<sc.icon className="h-3 w-3" /> {sc.label}
</Badge>
</TableCell>
<TableCell className="hidden sm:table-cell">
{due ? (
<span className={`text-xs font-medium ${due.urgent ? "text-danger-red" : "text-muted-foreground"}`}>
{due.urgent && "⚠ "}{due.text}
</span>
) : (
<span className="text-xs text-muted-foreground">{timeAgo(p.createdAt)}</span>
)}
</TableCell>
<TableCell className="hidden lg:table-cell">
<span className="text-xs capitalize text-muted-foreground">
{p.rail === "gocardless" ? "Direct Debit" : p.rail}
</span>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger className="p-1.5 rounded-lg hover:bg-muted transition-colors">
<MoreVertical className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{p.status !== "paid" && (
<DropdownMenuItem onClick={() => updateStatus(p.id, "paid")}>
<CheckCircle2 className="h-4 w-4 text-success-green" /> Mark Paid
</DropdownMenuItem>
)}
{p.status !== "initiated" && p.status !== "paid" && (
<DropdownMenuItem onClick={() => updateStatus(p.id, "initiated")}>
<Send className="h-4 w-4 text-warm-amber" /> Mark Initiated
</DropdownMenuItem>
)}
{p.donorPhone && p.status !== "paid" && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => sendReminder(p)}>
<MessageCircle className="h-4 w-4 text-[#25D366]" /> Send WhatsApp Reminder
</DropdownMenuItem>
</>
)}
{p.status !== "cancelled" && p.status !== "paid" && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem destructive onClick={() => updateStatus(p.id, "cancelled")}>
<XCircle className="h-4 w-4" /> Cancel Pledge
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Rows */}
{pledges.map(p => {
const sl = STATUS[p.status] || STATUS.new
const due = p.dueDate ? dueLabel(p.dueDate) : null
return (
<div key={p.id} className={`grid grid-cols-12 gap-2 px-5 py-3 border-b border-gray-50 items-center hover:bg-gray-50/50 transition-colors ${updating === p.id ? "opacity-50" : ""}`}>
{/* Donor */}
<div className="col-span-4">
<p className="text-sm font-medium text-[#111827] truncate">{p.donorName || "Anonymous"}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<code className="text-[10px] text-gray-400 font-mono">{p.reference}</code>
{p.donorPhone && <MessageCircle className="h-2.5 w-2.5 text-[#25D366]" />}
</div>
</div>
{/* Amount */}
<div className="col-span-2">
<p className="text-sm font-black text-[#111827]">{formatPence(p.amountPence)}</p>
{p.giftAid && <span className="text-[9px] text-[#16A34A] font-bold">+Gift Aid</span>}
{p.installmentTotal && p.installmentTotal > 1 && (
<p className="text-[9px] text-[#F59E0B] font-bold">{p.installmentNumber}/{p.installmentTotal}</p>
)}
</div>
{/* Appeal */}
<div className="col-span-2 hidden md:block">
<p className="text-xs text-gray-600 truncate">{p.eventName}</p>
{p.qrSourceLabel && <p className="text-[10px] text-gray-400 truncate">{p.qrSourceLabel}</p>}
</div>
{/* Status */}
<div className="col-span-2">
<span className={`text-[10px] font-bold px-1.5 py-0.5 inline-block ${sl.bg} ${sl.color}`}>{sl.label}</span>
</div>
{/* When */}
<div className="col-span-1 hidden sm:block">
{due ? (
<span className={`text-xs ${due.urgent ? "font-bold text-[#DC2626]" : "text-gray-500"}`}>{due.text}</span>
) : (
<span className="text-xs text-gray-500">{timeAgo(p.createdAt)}</span>
)}
</div>
{/* Actions */}
<div className="col-span-1 text-right">
<DropdownMenu>
<DropdownMenuTrigger className="p-1.5 hover:bg-gray-100 transition-colors">
<MoreVertical className="h-4 w-4 text-gray-400" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{p.status !== "paid" && (
<DropdownMenuItem onClick={() => updateStatus(p.id, "paid")}>
<CheckCircle2 className="h-4 w-4 text-[#16A34A]" /> Mark as received
</DropdownMenuItem>
)}
{p.status !== "initiated" && p.status !== "paid" && (
<DropdownMenuItem onClick={() => updateStatus(p.id, "initiated")}>
<Send className="h-4 w-4 text-[#F59E0B]" /> Mark as &quot;said they paid&quot;
</DropdownMenuItem>
)}
{p.donorPhone && p.status !== "paid" && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => sendReminder(p)}>
<MessageCircle className="h-4 w-4 text-[#25D366]" /> Send WhatsApp reminder
</DropdownMenuItem>
</>
)}
{p.status !== "cancelled" && p.status !== "paid" && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem destructive onClick={() => updateStatus(p.id, "cancelled")}>
<XCircle className="h-4 w-4" /> Cancel pledge
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
})}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-3">
<p className="text-sm text-muted-foreground">
Showing {page * pageSize + 1}{Math.min((page + 1) * pageSize, total)} of {total}
</p>
<p className="text-xs text-gray-500">{page * pageSize + 1}{Math.min((page + 1) * pageSize, total)} of {total}</p>
<div className="flex gap-1">
<Button variant="outline" size="sm" disabled={page === 0} onClick={() => setPage(p => p - 1)}>
<button disabled={page === 0} onClick={() => setPage(p => p - 1)} className="border border-gray-200 p-1.5 disabled:opacity-30 hover:bg-gray-50">
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages - 1} onClick={() => setPage(p => p + 1)}>
</button>
<button disabled={page >= totalPages - 1} onClick={() => setPage(p => p + 1)} className="border border-gray-200 p-1.5 disabled:opacity-30 hover:bg-gray-50">
<ChevronRight className="h-4 w-4" />
</Button>
</button>
</div>
</div>
)}
</TabsContent>
</Tabs>
{/* Match payments link */}
<div className="border-l-2 border-[#1E40AF] pl-4 py-2">
<Link href="/dashboard/reconcile" className="text-sm font-bold text-[#111827] hover:text-[#1E40AF] transition-colors">
Match bank payments
</Link>
<p className="text-xs text-gray-500">Upload a bank statement to automatically match payments to pledges</p>
</div>
</div>
)
}

View File

@@ -1,48 +1,74 @@
"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"
import { Upload, CheckCircle2, AlertCircle, HelpCircle, ArrowLeft } from "lucide-react"
import Link from "next/link"
interface MatchResult {
bankRow: {
date: string
description: string
amount: number
reference: string
}
pledgeId: string | null
pledgeReference: string | null
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
matchedAmount: number; autoConfirmed: boolean
}
export default function ReconcilePage() {
export default function MatchPaymentsPage() {
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 [bankName, setBankName] = useState("")
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 [mapping, setMapping] = useState({
dateCol: "Date",
descriptionCol: "Description",
creditCol: "Credit",
referenceCol: "Reference",
})
// Try auto-detecting bank format
const autoDetect = async (f: File) => {
const text = await f.text()
const firstLine = text.split("\n")[0]
// Try bank preset detection
try {
const res = await fetch("/api/imports/presets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ headers: firstLine.split(",").map(h => h.replace(/"/g, "").trim()) }),
})
const data = await res.json()
if (data.detected && data.preset) {
setBankName(data.preset.bankName)
setMapping({
dateCol: data.preset.dateCol,
descriptionCol: data.preset.descriptionCol,
creditCol: data.preset.creditCol || data.preset.amountCol || "Credit",
referenceCol: data.preset.referenceCol || "Reference",
})
return
}
} catch { /* fallback to AI */ }
// Try AI column mapping
try {
const headers = firstLine.split(",").map(h => h.replace(/"/g, "").trim())
const rows = text.split("\n").slice(1, 4).map(row => row.split(",").map(c => c.replace(/"/g, "").trim()))
const res = await fetch("/api/ai/map-columns", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ headers, sampleRows: rows }),
})
const data = await res.json()
if (data.dateCol) {
setBankName(data.source === "ai" ? "Auto-detected" : "")
setMapping({
dateCol: data.dateCol,
descriptionCol: data.descriptionCol || "Description",
creditCol: data.creditCol || data.amountCol || "Credit",
referenceCol: data.referenceCol || "Reference",
})
}
} catch { /* keep defaults */ }
}
const handleFileSelect = (f: File) => {
setFile(f)
autoDetect(f)
}
const handleUpload = async () => {
if (!file) return
@@ -51,187 +77,147 @@ export default function ReconcilePage() {
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: { },
body: formData,
})
const res = await fetch("/api/imports/bank-statement", { method: "POST", body: formData })
const data = await res.json()
if (data.summary) {
setResults(data)
}
} catch {
// handle error
}
if (data.summary) setResults(data)
} catch { /* */ }
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" />
case "exact": return <CheckCircle2 className="h-4 w-4 text-[#16A34A]" />
case "partial": return <AlertCircle className="h-4 w-4 text-[#F59E0B]" />
default: return <HelpCircle className="h-4 w-4 text-gray-300" />
}
}
return (
<div className="space-y-6">
<div className="space-y-8">
<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>
<Link href="/dashboard/money" className="text-xs text-gray-500 hover:text-[#111827] transition-colors inline-flex items-center gap-1 mb-2">
<ArrowLeft className="h-3 w-3" /> Back to Money
</Link>
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Match Payments</h1>
<p className="text-sm text-gray-500 mt-0.5">Upload your bank statement and we&apos;ll match payments to pledges automatically</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"
/>
{/* Upload section */}
<div className="bg-white border border-gray-200 p-6 space-y-5">
<div>
<h3 className="text-base font-bold text-[#111827]">Upload bank statement</h3>
<p className="text-xs text-gray-500 mt-1">
Download a CSV from your bank&apos;s website and upload it here. We recognise formats from Barclays, HSBC, Lloyds, NatWest, Monzo, Starling, and more.
</p>
</div>
{/* File drop zone */}
<div className="border-2 border-dashed border-gray-200 p-8 text-center hover:border-[#1E40AF]/50 transition-colors">
<Upload className="h-8 w-8 text-gray-300 mx-auto mb-3" />
<input type="file" accept=".csv" onChange={e => e.target.files?.[0] && handleFileSelect(e.target.files[0])} className="hidden" id="csv-upload" />
<label htmlFor="csv-upload" className="cursor-pointer">
<p className="text-sm font-medium text-[#111827]">{file ? file.name : "Click to choose a CSV file"}</p>
<p className="text-[10px] text-gray-500 mt-1">CSV file from your online banking</p>
</label>
</div>
{/* Column mapping — auto-detected, editable */}
{file && (
<div>
<div className="flex items-center gap-2 mb-3">
<p className="text-xs font-bold text-gray-600">Column mapping</p>
{bankName && <span className="text-[10px] font-bold text-[#16A34A] bg-[#16A34A]/10 px-1.5 py-0.5">{bankName} detected</span>}
</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 className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ key: "dateCol", label: "Date column" },
{ key: "descriptionCol", label: "Description column" },
{ key: "creditCol", label: "Amount / Credit column" },
{ key: "referenceCol", label: "Reference column" },
].map(col => (
<div key={col.key} className="space-y-1">
<label className="text-[10px] font-bold text-gray-500">{col.label}</label>
<input
value={mapping[col.key as keyof typeof mapping]}
onChange={e => setMapping(m => ({ ...m, [col.key]: e.target.value }))}
className="w-full h-8 px-2 border border-gray-200 text-xs focus:border-[#1E40AF] outline-none"
/>
</div>
))}
</div>
</div>
)}
{/* File upload */}
<div className="border-2 border-dashed border-gray-200 rounded-lg 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>
<button
onClick={handleUpload}
disabled={!file || uploading}
className="w-full bg-[#111827] px-4 py-2.5 text-sm font-bold text-white hover:bg-gray-800 disabled:opacity-50 transition-colors"
>
{uploading ? "Matching payments..." : "Upload & Match"}
</button>
</div>
{/* 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>
{/* Summary — gap-px grid */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-px bg-gray-200">
{[
{ value: results.summary.totalRows, label: "Rows" },
{ value: results.summary.credits, label: "Incoming payments" },
{ value: results.summary.exactMatches, label: "Matched", accent: "text-[#16A34A]" },
{ value: results.summary.partialMatches, label: "Possible matches", accent: "text-[#F59E0B]" },
{ value: results.summary.autoConfirmed, label: "Auto-confirmed", accent: "text-[#16A34A]" },
].map(s => (
<div key={s.label} className="bg-white p-4 text-center">
<p className={`text-2xl font-black ${s.accent || "text-[#111827]"}`}>{s.value}</p>
<p className="text-[10px] text-gray-500 mt-0.5">{s.label}</p>
</div>
))}
</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>
<div className="bg-white border border-gray-200">
<div className="border-b border-gray-100 px-5 py-3">
<h3 className="text-sm font-bold text-[#111827]">Results</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100">
<th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Match</th>
<th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Date</th>
<th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Description</th>
<th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Amount</th>
<th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Pledge</th>
<th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{results.matches.map((m, i) => (
<tr key={i} className="hover:bg-gray-50/50">
<td className="px-5 py-3">{confidenceIcon(m.confidence)}</td>
<td className="px-5 py-3 text-xs text-gray-600">{m.bankRow.date}</td>
<td className="px-5 py-3 text-xs text-gray-600 max-w-[200px] truncate">{m.bankRow.description}</td>
<td className="px-5 py-3 text-sm font-black text-[#111827]">£{m.matchedAmount.toFixed(2)}</td>
<td className="px-5 py-3 text-xs font-mono text-gray-500">{m.pledgeReference || "—"}</td>
<td className="px-5 py-3">
{m.autoConfirmed ? (
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-[#16A34A]/10 text-[#16A34A]">Auto-confirmed</span>
) : m.confidence === "partial" ? (
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-[#F59E0B]/10 text-[#F59E0B]">Check this</span>
) : m.confidence === "none" ? (
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-gray-100 text-gray-500">No match</span>
) : (
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-[#16A34A]/10 text-[#16A34A]">Matched</span>
)}
</td>
</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>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</div>

View File

@@ -0,0 +1,5 @@
/**
* /dashboard/reports — "My treasurer needs numbers"
* Renamed exports page.
*/
export { default } from "../exports/page"

View File

@@ -1,27 +1,16 @@
"use client"
import { useState, useEffect, useCallback } 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 {
Building2, CreditCard, Palette, Check, Loader2, AlertCircle,
MessageCircle, Radio, QrCode, RefreshCw, Smartphone, Wifi, WifiOff
Check, Loader2, AlertCircle,
MessageCircle, Radio, RefreshCw, Smartphone, Wifi, WifiOff, QrCode
} from "lucide-react"
interface OrgSettings {
name: string
bankName: string
bankSortCode: string
bankAccountNo: string
bankAccountName: string
refPrefix: string
primaryColor: string
gcAccessToken: string
gcEnvironment: string
orgType: string
name: string; bankName: string; bankSortCode: string; bankAccountNo: string
bankAccountName: string; refPrefix: string; primaryColor: string
gcAccessToken: string; gcEnvironment: string; orgType: string
}
export default function SettingsPage() {
@@ -33,281 +22,226 @@ export default function SettingsPage() {
useEffect(() => {
fetch("/api/settings")
.then((r) => r.json())
.then((data) => { if (data.name) setSettings(data) })
.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)
setSaving(section); setError(null)
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, 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>
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
if (!settings) return <div className="text-center py-20"><AlertCircle className="h-6 w-6 text-[#DC2626] mx-auto mb-2" /><p className="text-sm text-gray-500">Failed to load settings</p></div>
const update = (key: keyof OrgSettings, value: string) => setSettings((s) => s ? { ...s, [key]: value } : s)
const update = (key: keyof OrgSettings, value: string) => setSettings(s => s ? { ...s, [key]: value } : s)
const SaveButton = ({ section, data }: { section: string; data: Record<string, string> }) => (
<button
onClick={() => save(section, data)}
disabled={saving === section}
className="bg-[#111827] px-4 py-2 text-xs font-bold text-white hover:bg-gray-800 disabled:opacity-50 transition-colors"
>
{saving === section ? <><Loader2 className="h-3 w-3 mr-1.5 animate-spin inline" /> Saving</> : saved === section ? <><Check className="h-3 w-3 mr-1.5 inline" /> Saved!</> : "Save"}
</button>
)
return (
<div className="space-y-6 max-w-2xl">
<div className="space-y-8 max-w-2xl">
<div>
<h1 className="text-2xl font-black text-gray-900">Settings</h1>
<p className="text-sm text-muted-foreground mt-0.5">Configure your organisation&apos;s payment details and integrations</p>
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Settings</h1>
<p className="text-sm text-gray-500 mt-0.5">Your charity details, bank account, and connections</p>
</div>
{error && <div className="rounded-lg bg-danger-red/10 border border-danger-red/20 p-3 text-sm text-danger-red">{error}</div>}
{error && <div className="border-l-2 border-[#DC2626] bg-[#DC2626]/5 p-3 text-sm text-[#DC2626]">{error}</div>}
{/* WhatsApp — MOST IMPORTANT, first */}
{/* WhatsApp — most important, always first */}
<WhatsAppPanel />
{/* Bank Details */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2"><Building2 className="h-4 w-4 text-trust-blue" /> Bank Account</CardTitle>
<CardDescription className="text-xs">Shown to donors who choose bank transfer. Each pledge gets a unique reference.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div><Label className="text-xs">Bank Name</Label><Input value={settings.bankName} onChange={(e) => update("bankName", e.target.value)} placeholder="e.g. Barclays" /></div>
<div><Label className="text-xs">Account Name</Label><Input value={settings.bankAccountName} onChange={(e) => update("bankAccountName", e.target.value)} /></div>
</div>
<div className="grid grid-cols-2 gap-3">
<div><Label className="text-xs">Sort Code</Label><Input value={settings.bankSortCode} onChange={(e) => update("bankSortCode", e.target.value)} placeholder="20-30-80" /></div>
<div><Label className="text-xs">Account Number</Label><Input value={settings.bankAccountNo} onChange={(e) => update("bankAccountNo", e.target.value)} placeholder="12345678" /></div>
</div>
<div><Label className="text-xs">Reference Prefix</Label><Input value={settings.refPrefix} onChange={(e) => update("refPrefix", e.target.value)} maxLength={4} className="w-24" /><p className="text-[10px] text-muted-foreground mt-1">e.g. {settings.refPrefix}-XXXX-50</p></div>
<Button size="sm" 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-3.5 w-3.5 mr-1.5 animate-spin" /> Saving</> : saved === "bank" ? <><Check className="h-3.5 w-3.5 mr-1.5" /> Saved!</> : "Save Bank Details"}
</Button>
</CardContent>
</Card>
{/* Bank account */}
<div className="bg-white border border-gray-200 p-6 space-y-4">
<div>
<h3 className="text-base font-bold text-[#111827]">Bank account</h3>
<p className="text-xs text-gray-500 mt-0.5">These details are shown to donors so they can transfer money to you. Each pledge gets a unique reference code.</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div><label className="text-[10px] font-bold text-gray-500 block mb-1">Bank name</label><Input value={settings.bankName} onChange={e => update("bankName", e.target.value)} placeholder="e.g. Barclays" /></div>
<div><label className="text-[10px] font-bold text-gray-500 block mb-1">Account name</label><Input value={settings.bankAccountName} onChange={e => update("bankAccountName", e.target.value)} /></div>
</div>
<div className="grid grid-cols-2 gap-3">
<div><label className="text-[10px] font-bold text-gray-500 block mb-1">Sort code</label><Input value={settings.bankSortCode} onChange={e => update("bankSortCode", e.target.value)} placeholder="20-30-80" /></div>
<div><label className="text-[10px] font-bold text-gray-500 block mb-1">Account number</label><Input value={settings.bankAccountNo} onChange={e => update("bankAccountNo", e.target.value)} placeholder="12345678" /></div>
</div>
<div>
<label className="text-[10px] font-bold text-gray-500 block mb-1">Reference code prefix</label>
<Input value={settings.refPrefix} onChange={e => update("refPrefix", e.target.value)} maxLength={4} className="w-24" />
<p className="text-[10px] text-gray-400 mt-1">Donors will see references like <strong>{settings.refPrefix}-XXXX-50</strong></p>
</div>
<SaveButton section="bank" data={{ bankName: settings.bankName, bankSortCode: settings.bankSortCode, bankAccountNo: settings.bankAccountNo, bankAccountName: settings.bankAccountName, refPrefix: settings.refPrefix }} />
</div>
{/* GoCardless */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2"><CreditCard className="h-4 w-4 text-trust-blue" /> GoCardless (Direct Debit)</CardTitle>
<CardDescription className="text-xs">Enable Direct Debit collection protected by the DD Guarantee.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div><Label className="text-xs">Access Token</Label><Input type="password" value={settings.gcAccessToken} onChange={(e) => update("gcAccessToken", e.target.value)} placeholder="sandbox_xxxxx or live_xxxxx" /></div>
<div>
<Label className="text-xs">Environment</Label>
<div className="flex gap-2 mt-1">
{["sandbox", "live"].map((env) => (
<button key={env} onClick={() => update("gcEnvironment", env)} className={`px-3 py-1.5 rounded-lg text-xs 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"}`}>
{env.charAt(0).toUpperCase() + env.slice(1)} {env === "live" && settings.gcEnvironment === "live" && "⚠️"}
</button>
))}
</div>
{/* Direct Debit */}
<div className="bg-white border border-gray-200 p-6 space-y-4">
<div>
<h3 className="text-base font-bold text-[#111827]">Direct Debit</h3>
<p className="text-xs text-gray-500 mt-0.5">Accept Direct Debit payments via GoCardless. Donors set up a mandate and payments are collected automatically.</p>
</div>
<div>
<label className="text-[10px] font-bold text-gray-500 block mb-1">GoCardless access token</label>
<Input type="password" value={settings.gcAccessToken} onChange={e => update("gcAccessToken", e.target.value)} placeholder="sandbox_xxxxx or live_xxxxx" />
</div>
<div>
<label className="text-[10px] font-bold text-gray-500 block mb-1">Mode</label>
<div className="flex gap-2 mt-1">
{["sandbox", "live"].map(env => (
<button key={env} onClick={() => update("gcEnvironment", env)} className={`px-3 py-1.5 text-xs font-bold border-2 transition-colors ${settings.gcEnvironment === env ? env === "live" ? "border-[#DC2626] bg-[#DC2626]/5 text-[#DC2626]" : "border-[#1E40AF] bg-[#1E40AF]/5 text-[#1E40AF]" : "border-gray-200 text-gray-400"}`}>
{env === "sandbox" ? "Test mode" : "Live mode"}
</button>
))}
</div>
<Button size="sm" onClick={() => save("gc", { gcAccessToken: settings.gcAccessToken, gcEnvironment: settings.gcEnvironment })} disabled={saving === "gc"}>
{saving === "gc" ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> Saving</> : saved === "gc" ? <><Check className="h-3.5 w-3.5 mr-1.5" /> Saved!</> : "Save GoCardless"}
</Button>
</CardContent>
</Card>
</div>
<SaveButton section="gc" data={{ gcAccessToken: settings.gcAccessToken, gcEnvironment: settings.gcEnvironment }} />
</div>
{/* Branding */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2"><Palette className="h-4 w-4 text-trust-blue" /> Branding</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div><Label className="text-xs">Organisation Name</Label><Input value={settings.name} onChange={(e) => update("name", e.target.value)} /></div>
<div>
<Label className="text-xs">Primary Colour</Label>
<div className="flex gap-2 mt-1"><Input type="color" value={settings.primaryColor} onChange={(e) => update("primaryColor", e.target.value)} className="w-12 h-9 p-0.5" /><Input value={settings.primaryColor} onChange={(e) => update("primaryColor", e.target.value)} className="flex-1" /></div>
<div className="bg-white border border-gray-200 p-6 space-y-4">
<div>
<h3 className="text-base font-bold text-[#111827]">Your charity</h3>
<p className="text-xs text-gray-500 mt-0.5">This name and colour appear on pledge pages and WhatsApp messages.</p>
</div>
<div><label className="text-[10px] font-bold text-gray-500 block mb-1">Charity name</label><Input value={settings.name} onChange={e => update("name", e.target.value)} /></div>
<div>
<label className="text-[10px] font-bold text-gray-500 block mb-1">Brand colour</label>
<div className="flex gap-2 mt-1">
<Input type="color" value={settings.primaryColor} onChange={e => update("primaryColor", e.target.value)} className="w-12 h-9 p-0.5" />
<Input value={settings.primaryColor} onChange={e => update("primaryColor", e.target.value)} className="flex-1" />
</div>
<Button size="sm" onClick={() => save("brand", { name: settings.name, primaryColor: settings.primaryColor })} disabled={saving === "brand"}>
{saving === "brand" ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> Saving</> : saved === "brand" ? <><Check className="h-3.5 w-3.5 mr-1.5" /> Saved!</> : "Save Branding"}
</Button>
</CardContent>
</Card>
</div>
<SaveButton section="brand" data={{ name: settings.name, primaryColor: settings.primaryColor }} />
</div>
</div>
)
}
// ─── WhatsApp Connection Panel ───────────────────────────────────
// ─── WhatsApp Connection Panel ───────────────────────────────
function WhatsAppPanel() {
const [status, setStatus] = useState<string>("loading")
const [qrImage, setQrImage] = useState<string | null>(null)
const [phone, setPhone] = useState<string>("")
const [pushName, setPushName] = useState<string>("")
const [starting, setStarting] = useState(false)
const [showQr, setShowQr] = useState(false) // only true after user clicks Connect
const [phone, setPhone] = useState(""); const [pushName, setPushName] = useState("")
const [starting, setStarting] = useState(false); const [showQr, setShowQr] = useState(false)
const checkStatus = useCallback(async () => {
try {
const res = await fetch("/api/whatsapp/qr")
const data = await res.json()
const res = await fetch("/api/whatsapp/qr"); const data = await res.json()
setStatus(data.status)
if (data.screenshot) setQrImage(data.screenshot)
if (data.phone) setPhone(data.phone)
if (data.pushName) setPushName(data.pushName)
// Auto-show QR panel once connected (user paired successfully)
if (data.status === "CONNECTED") setShowQr(false)
} catch {
setStatus("ERROR")
}
} catch { setStatus("ERROR") }
}, [])
// On mount: just check if already connected. Don't start polling yet.
useEffect(() => { checkStatus() }, [checkStatus])
// Poll only when user has clicked Connect and we're waiting for scan
useEffect(() => {
if (!showQr) return
const interval = setInterval(checkStatus, 5000)
return () => clearInterval(interval)
}, [showQr, checkStatus])
useEffect(() => { if (!showQr) return; const i = setInterval(checkStatus, 5000); return () => clearInterval(i) }, [showQr, checkStatus])
const startSession = async () => {
setStarting(true)
setShowQr(true)
try {
await fetch("/api/whatsapp/qr", { method: "POST" })
await new Promise(r => setTimeout(r, 3000))
await checkStatus()
} catch { /* ignore */ }
setStarting(true); setShowQr(true)
try { await fetch("/api/whatsapp/qr", { method: "POST" }); await new Promise(r => setTimeout(r, 3000)); await checkStatus() } catch { /* */ }
setStarting(false)
}
if (status === "CONNECTED") {
return (
<Card className="border-[#25D366]/30 bg-[#25D366]/[0.02]">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<MessageCircle className="h-4 w-4 text-[#25D366]" /> WhatsApp
<Badge variant="success" className="gap-1 ml-1"><Radio className="h-2.5 w-2.5" /> Connected</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-[#25D366]/10 flex items-center justify-center">
<Smartphone className="h-6 w-6 text-[#25D366]" />
</div>
<div>
<p className="font-medium text-sm">{pushName || "WhatsApp Business"}</p>
<p className="text-xs text-muted-foreground">+{phone}</p>
</div>
<div className="ml-auto">
<Wifi className="h-5 w-5 text-[#25D366]" />
</div>
<div className="bg-white border border-[#25D366]/30 p-6">
<div className="flex items-center gap-2 mb-4">
<h3 className="text-base font-bold text-[#111827]">WhatsApp</h3>
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-[#25D366]/10 text-[#25D366] flex items-center gap-1"><Radio className="h-2.5 w-2.5" /> Connected</span>
</div>
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-[#25D366]/10 flex items-center justify-center"><Smartphone className="h-5 w-5 text-[#25D366]" /></div>
<div>
<p className="text-sm font-medium text-[#111827]">{pushName || "WhatsApp"}</p>
<p className="text-xs text-gray-500">+{phone}</p>
</div>
<div className="mt-4 pt-3 border-t border-[#25D366]/10 grid grid-cols-3 gap-3">
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Auto-Sends</p>
<p className="text-xs font-medium">Receipts</p>
<Wifi className="h-5 w-5 text-[#25D366] ml-auto" />
</div>
<div className="mt-4 pt-3 border-t border-[#25D366]/10 grid grid-cols-3 gap-3">
{[
{ label: "Receipts", desc: "Auto-sends when someone pledges" },
{ label: "Reminders", desc: "4-step reminder sequence" },
{ label: "Chatbot", desc: "Donors reply PAID, HELP, etc." },
].map(f => (
<div key={f.label} className="text-center">
<p className="text-xs font-bold text-[#111827]">{f.label}</p>
<p className="text-[9px] text-gray-500 mt-0.5">{f.desc}</p>
</div>
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Reminders</p>
<p className="text-xs font-medium">4-step</p>
</div>
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Chatbot</p>
<p className="text-xs font-medium">PAID / HELP</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}
if (status === "SCAN_QR_CODE" && showQr) {
return (
<Card className="border-warm-amber/30">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<MessageCircle className="h-4 w-4 text-[#25D366]" /> WhatsApp
<Badge variant="warning" className="gap-1 ml-1"><QrCode className="h-2.5 w-2.5" /> Scan QR</Badge>
</CardTitle>
<CardDescription className="text-xs">Open WhatsApp on your phone Settings Linked Devices Link a Device</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center gap-4">
{qrImage ? (
<div className="relative">
{/* Crop to QR area: the screenshot shows full WhatsApp web page.
QR code is roughly in center. We use overflow hidden + object positioning. */}
<div className="w-72 h-72 rounded-lg border-2 border-[#25D366]/20 overflow-hidden bg-white">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={qrImage}
alt="WhatsApp QR Code"
className="w-[200%] h-auto max-w-none"
style={{ marginLeft: "-30%", marginTop: "-35%" }}
/>
</div>
<div className="absolute -bottom-2 -right-2 w-8 h-8 rounded-full bg-[#25D366] flex items-center justify-center shadow-lg">
<MessageCircle className="h-4 w-4 text-white" />
</div>
</div>
) : (
<div className="w-72 h-72 rounded-lg border-2 border-dashed border-muted flex items-center justify-center">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
</div>
)}
<div className="text-center space-y-1">
<p className="text-sm font-medium">Scan with WhatsApp</p>
<p className="text-xs text-muted-foreground">Open WhatsApp Settings Linked Devices Link a Device</p>
<p className="text-xs text-muted-foreground">Auto-refreshes every 5 seconds</p>
<div className="bg-white border border-[#F59E0B]/30 p-6">
<div className="flex items-center gap-2 mb-4">
<h3 className="text-base font-bold text-[#111827]">WhatsApp</h3>
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-[#F59E0B]/10 text-[#F59E0B] flex items-center gap-1"><QrCode className="h-2.5 w-2.5" /> Scan QR code</span>
</div>
<div className="flex flex-col items-center gap-4">
{qrImage ? (
<div className="w-64 h-64 border-2 border-[#25D366]/20 overflow-hidden bg-white">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qrImage} alt="WhatsApp QR Code" className="w-[200%] h-auto max-w-none" style={{ marginLeft: "-30%", marginTop: "-35%" }} />
</div>
<Button variant="outline" size="sm" onClick={checkStatus} className="gap-1.5">
<RefreshCw className="h-3 w-3" /> Refresh
</Button>
) : (
<div className="w-64 h-64 border-2 border-dashed border-gray-200 flex items-center justify-center">
<Loader2 className="h-6 w-6 text-gray-400 animate-spin" />
</div>
)}
<div className="text-center space-y-1">
<p className="text-sm font-bold text-[#111827]">Scan with your phone</p>
<p className="text-xs text-gray-500">Open WhatsApp Settings Linked Devices Link a Device</p>
<p className="text-[10px] text-gray-400">Auto-refreshes every 5 seconds</p>
</div>
</CardContent>
</Card>
<button onClick={checkStatus} className="border border-gray-200 px-3 py-1.5 text-xs font-semibold text-gray-600 hover:bg-gray-50 flex items-center gap-1.5">
<RefreshCw className="h-3 w-3" /> Refresh
</button>
</div>
</div>
)
}
// NO_SESSION or STARTING or ERROR
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<MessageCircle className="h-4 w-4 text-muted-foreground" /> WhatsApp
<Badge variant="secondary" className="gap-1 ml-1"><WifiOff className="h-2.5 w-2.5" /> Offline</Badge>
</CardTitle>
<CardDescription className="text-xs">
Connect WhatsApp to auto-send pledge receipts, payment reminders, and enable a chatbot for donors.
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg bg-muted/50 p-4 space-y-3">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-[#25D366]/10 flex items-center justify-center flex-shrink-0">
<Smartphone className="h-5 w-5 text-[#25D366]" />
</div>
<div>
<p className="text-sm font-medium">Connect your WhatsApp number</p>
<ul className="text-xs text-muted-foreground mt-1 space-y-0.5">
<li>📨 Pledge receipts with bank transfer details</li>
<li> Automatic reminders (2d before due day 3d after 10d final)</li>
<li>🤖 Donor chatbot: reply PAID, HELP, CANCEL, STATUS</li>
<li>📊 Volunteer notifications when someone pledges at their table</li>
</ul>
</div>
</div>
<Button onClick={startSession} disabled={starting} className="w-full bg-[#25D366] hover:bg-[#25D366]/90 text-white">
{starting ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Starting session...</> : <><MessageCircle className="h-4 w-4 mr-2" /> Connect WhatsApp</>}
</Button>
<p className="text-[10px] text-muted-foreground text-center">
Uses WAHA (WhatsApp HTTP API) · No WhatsApp Business API required · Free tier
</p>
<div className="bg-white border border-gray-200 p-6">
<div className="flex items-center gap-2 mb-4">
<h3 className="text-base font-bold text-[#111827]">WhatsApp</h3>
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-gray-100 text-gray-500 flex items-center gap-1"><WifiOff className="h-2.5 w-2.5" /> Not connected</span>
</div>
<div className="border-l-2 border-[#25D366] pl-4 space-y-2">
<p className="text-sm font-medium text-[#111827]">Connect your WhatsApp number</p>
<div className="text-xs text-gray-500 space-y-0.5">
<p>When you connect, donors automatically receive:</p>
<p className="font-medium text-gray-600"> Pledge receipts with bank details</p>
<p className="font-medium text-gray-600"> Payment reminders on a 4-step schedule</p>
<p className="font-medium text-gray-600"> A chatbot (they reply PAID, HELP, or CANCEL)</p>
<p className="font-medium text-gray-600"> Volunteer notifications on each pledge</p>
</div>
</CardContent>
</Card>
</div>
<button onClick={startSession} disabled={starting} className="mt-4 w-full bg-[#25D366] px-4 py-2.5 text-sm font-bold text-white hover:bg-[#25D366]/90 disabled:opacity-50 transition-colors flex items-center justify-center gap-2">
{starting ? <><Loader2 className="h-4 w-4 animate-spin" /> Starting...</> : <><MessageCircle className="h-4 w-4" /> Connect WhatsApp</>}
</button>
<p className="text-[10px] text-gray-400 text-center mt-2">
Free no WhatsApp Business API required
</p>
</div>
)
}

View File

@@ -0,0 +1,276 @@
<?php
namespace App\Services;
use App\Models\Appeal;
use App\Models\ApprovalQueue;
use App\Models\DonationCountry;
use App\Models\DonationType;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class AIAppealReviewService
{
/**
* Review an appeal and return a structured verdict.
*/
public function review(Appeal $appeal): array
{
// First, run fast deterministic checks (no AI needed)
$preflightResult = $this->preflightChecks($appeal);
if ($preflightResult !== null) {
return $preflightResult;
}
// Run AI review for content that passes preflight
return $this->aiReview($appeal);
}
/**
* Bulk review all pending approvals.
*/
public function reviewAllPending(): array
{
$pending = ApprovalQueue::where('status', 'pending')
->with('appeal')
->get();
$stats = ['reviewed' => 0, 'approved' => 0, 'rejected' => 0, 'flagged' => 0];
foreach ($pending as $item) {
if (!$item->appeal) {
$item->update(['status' => 'confirmed', 'message' => 'Auto-closed: fundraiser was deleted']);
$stats['reviewed']++;
$stats['rejected']++;
continue;
}
$result = $this->review($item->appeal);
$stats['reviewed']++;
$item->update([
'extra_data' => json_encode([
'ai_review' => $result,
'previous_extra_data' => $item->extra_data,
]),
]);
if ($result['decision'] === 'approve') {
app(ApprovalQueueService::class)->approveAppeal($item);
$stats['approved']++;
} elseif ($result['decision'] === 'reject') {
$item->update([
'status' => 'change_requested',
'message' => 'Auto-flagged: ' . implode('; ', $result['reasons']),
]);
$appeal = $item->appeal;
$appeal->status = 'change_requested';
$appeal->save();
$stats['rejected']++;
} else {
$stats['flagged']++;
}
}
return $stats;
}
/**
* Fast deterministic checks that don't need AI.
*/
protected function preflightChecks(Appeal $appeal): ?array
{
$flags = [];
$reasons = [];
// 1. Check for duplicate names by same user
$duplicateCount = Appeal::where('id', '!=', $appeal->id)
->where('name', $appeal->name)
->where('user_id', $appeal->user_id)
->count();
if ($duplicateCount > 0) {
return [
'decision' => 'reject',
'confidence' => 0.99,
'reasons' => ['Duplicate fundraiser — same person already created an identical page'],
'summary' => 'This is a duplicate of an existing fundraiser by the same person.',
'flags' => ['duplicate'],
];
}
// 2. Check for obvious spam keywords
$spamKeywords = [
'buy adderall', 'buy xanax', 'buy viagra', 'buy oxycodone', 'buy tramadol',
'casino', 'gambling', 'poker online', 'sports betting',
'forex trading', 'crypto trading', 'bitcoin investment',
'weight loss pill', 'diet pill',
'seo service', 'web development service', 'digital marketing agency',
];
$nameLower = strtolower($appeal->name ?? '');
$storyLower = strtolower(strip_tags($appeal->story ?? ''));
foreach ($spamKeywords as $keyword) {
if (str_contains($nameLower, $keyword) || str_contains($storyLower, $keyword)) {
return [
'decision' => 'reject',
'confidence' => 0.99,
'reasons' => ["Contains prohibited content: {$keyword}"],
'summary' => 'This fundraiser contains spam/prohibited content.',
'flags' => ['spam', 'prohibited-content'],
];
}
}
// 3. Check for excessive external links (SEO spam pattern)
$externalLinkCount = preg_match_all(
'/href=["\'](?!https?:\/\/(www\.)?charityright)/i',
$appeal->story ?? '',
$matches
);
if ($externalLinkCount >= 3) {
$flags[] = 'excessive-external-links';
$reasons[] = "Contains {$externalLinkCount} external links (possible SEO spam)";
}
// 4. Check if story is too short
$plainStory = trim(strip_tags($appeal->story ?? ''));
if (strlen($plainStory) < 50) {
$flags[] = 'story-too-short';
$reasons[] = 'Story is very short (' . strlen($plainStory) . ' characters)';
}
// 5. Check if donation type exists
if (!$appeal->donation_type_id || !DonationType::find($appeal->donation_type_id)) {
$flags[] = 'invalid-donation-type';
$reasons[] = 'No valid cause selected';
}
if (count($flags) > 0) {
return [
'decision' => 'review',
'confidence' => 0.7,
'reasons' => $reasons,
'summary' => 'Automatic checks flagged potential issues that need human review.',
'flags' => $flags,
];
}
return null;
}
/**
* AI-powered content review using Claude.
*/
protected function aiReview(Appeal $appeal): array
{
$apiKey = config('services.anthropic.api_key');
if (!$apiKey) {
Log::warning('AIAppealReview: No Anthropic API key configured');
return $this->fallbackResult('No API key configured');
}
$donationTypes = DonationType::pluck('display_name')->join(', ');
$donationCountries = DonationCountry::pluck('name')->join(', ');
$plainStory = strip_tags($appeal->story ?? '');
$appealOwner = $appeal->user?->name ?? 'Unknown';
$appealOwnerEmail = $appeal->user?->email ?? 'Unknown';
$isInMemory = $appeal->is_in_memory ? 'Yes' : 'No';
$prompt = "You are a compliance reviewer for CharityRight, a UK-registered Muslim charity (Charity Commission regulated).\n\n";
$prompt .= "CharityRight's mission: Providing food (school meals, family meals), emergency humanitarian relief, and Islamic religious observance programs (Qurbani, Zakat, Fidya, Kaffarah) across Muslim-majority countries affected by poverty and conflict.\n\n";
$prompt .= "CHARITY'S ACTIVE PROGRAMS: {$donationTypes}\n";
$prompt .= "COUNTRIES OF OPERATION: {$donationCountries}\n\n";
$prompt .= "A member of the public has created a fundraising page on our platform. You must decide if this fundraising page should be approved to go live on our charity's website.\n\n";
$prompt .= "FUNDRAISER DETAILS:\n";
$prompt .= "- Title: {$appeal->name}\n";
$prompt .= "- Description: {$appeal->description}\n";
$prompt .= "- Created by: {$appealOwner} ({$appealOwnerEmail})\n";
$prompt .= "- Cause: " . ($appeal->donationType?->display_name ?? 'None') . "\n";
$prompt .= "- Target amount: £{$appeal->amount_to_raise}\n";
$prompt .= "- In memory of someone: {$isInMemory}\n";
$prompt .= "- Story/content:\n{$plainStory}\n\n";
$prompt .= "REVIEW CRITERIA — Check ALL of these:\n\n";
$prompt .= "1. RELEVANCE: Is this fundraiser related to CharityRight's charitable purposes? (food aid, emergency relief, Islamic programs, humanitarian causes)\n";
$prompt .= "2. LEGITIMACY: Does this look like a genuine fundraising effort, not spam, SEO content, or marketing?\n";
$prompt .= "3. COMPLIANCE: Does the content comply with UK Charity Commission regulations? No political campaigning, no commercial activity, no personal enrichment.\n";
$prompt .= "4. CONTENT QUALITY: Is the story coherent and appropriate for a charity website?\n";
$prompt .= "5. SAFETY: No hate speech, no extremism, no content that could damage the charity's reputation.\n";
$prompt .= "6. LINKS: Are there suspicious external links that suggest SEO spam or affiliate marketing?\n\n";
$prompt .= "RESPOND IN THIS EXACT JSON FORMAT (no markdown, no code blocks, just raw JSON):\n";
$prompt .= "{\"decision\": \"approve\" or \"reject\" or \"review\", \"confidence\": 0.0 to 1.0, \"reasons\": [\"reason 1\"], \"summary\": \"One sentence\", \"flags\": [\"flag1\"]}\n\n";
$prompt .= "Rules:\n";
$prompt .= "- \"approve\" = clearly legitimate fundraiser aligned with charity's mission\n";
$prompt .= "- \"reject\" = clearly spam, prohibited content, or violates charity regulations\n";
$prompt .= "- \"review\" = uncertain, needs human judgment (use this when confidence < 0.7)\n";
$prompt .= "- Be strict about spam but generous with genuine supporters who may have imperfect English\n";
$prompt .= "- A sincere person raising money for food/meals/emergency relief = approve\n";
$prompt .= "- Blog-style content with external links about travel/umrah packages/quran apps = reject (SEO spam)\n";
$prompt .= "- Drug sales, commercial products, gambling = reject immediately\n";
try {
$response = Http::withHeaders([
'x-api-key' => $apiKey,
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
])
->timeout(30)
->post('https://api.anthropic.com/v1/messages', [
'model' => 'claude-sonnet-4-20250514',
'max_tokens' => 500,
'messages' => [
['role' => 'user', 'content' => $prompt],
],
]);
if (!$response->successful()) {
Log::error('AIAppealReview: API error', ['status' => $response->status(), 'body' => $response->body()]);
return $this->fallbackResult('API error: ' . $response->status());
}
$content = $response->json('content.0.text', '');
$content = trim($content);
$content = preg_replace('/^```json?\s*/m', '', $content);
$content = preg_replace('/```\s*$/m', '', $content);
$content = trim($content);
$result = json_decode($content, true);
if (!$result || !isset($result['decision'])) {
Log::warning('AIAppealReview: Could not parse response', ['content' => $content]);
return $this->fallbackResult('Could not parse AI response');
}
if (!in_array($result['decision'], ['approve', 'reject', 'review'])) {
$result['decision'] = 'review';
}
return [
'decision' => $result['decision'],
'confidence' => (float) ($result['confidence'] ?? 0.5),
'reasons' => (array) ($result['reasons'] ?? []),
'summary' => (string) ($result['summary'] ?? ''),
'flags' => (array) ($result['flags'] ?? []),
];
} catch (\Throwable $e) {
Log::error('AIAppealReview: Exception', ['message' => $e->getMessage()]);
return $this->fallbackResult($e->getMessage());
}
}
protected function fallbackResult(string $reason): array
{
return [
'decision' => 'review',
'confidence' => 0.0,
'reasons' => ["AI review failed: {$reason}"],
'summary' => 'Automated review unavailable. Needs manual review.',
'flags' => ['ai-error'],
];
}
}

View File

@@ -0,0 +1,330 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ApprovalQueueResource\Pages;
use App\Models\ApprovalQueue;
use App\Services\AIAppealReviewService;
use App\Services\ApprovalQueueService;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\HtmlString;
class ApprovalQueueResource extends Resource
{
protected static ?string $model = ApprovalQueue::class;
protected static ?string $navigationIcon = 'heroicon-o-shield-check';
protected static ?string $navigationLabel = 'Fundraiser Review';
protected static ?string $label = 'Fundraiser Review';
protected static ?string $pluralLabel = 'Fundraiser Reviews';
protected static ?string $navigationGroup = 'Campaigns';
protected static ?int $navigationSort = 2;
/** Show pending count as badge in sidebar */
public static function getNavigationBadge(): ?string
{
$count = ApprovalQueue::where('status', 'pending')->count();
return $count > 0 ? (string) $count : null;
}
public static function getNavigationBadgeColor(): ?string
{
$count = ApprovalQueue::where('status', 'pending')->count();
return $count > 10 ? 'danger' : ($count > 0 ? 'warning' : 'success');
}
public static function form(Form $form): Form
{
return $form->schema([]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('status')
->label('Status')
->sortable()
->badge()
->color(fn (string $state) => match ($state) {
'pending' => 'warning',
'confirmed' => 'success',
'change_requested' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (string $state) => match ($state) {
'pending' => 'Needs Review',
'confirmed' => 'Approved',
'change_requested' => 'Changes Needed',
default => ucfirst($state),
}),
Tables\Columns\TextColumn::make('action')
->label('Type')
->badge()
->color(fn (string $state) => match ($state) {
'Create' => 'info',
'Update' => 'gray',
default => 'gray',
})
->formatStateUsing(fn (string $state) => match ($state) {
'Create' => 'New Fundraiser',
'Update' => 'Edit',
default => $state,
}),
Tables\Columns\TextColumn::make('appeal.name')
->label('Fundraiser Name')
->sortable()
->searchable()
->limit(50)
->tooltip(fn (ApprovalQueue $r) => $r->appeal?->name),
Tables\Columns\TextColumn::make('appeal.user.name')
->label('Created By')
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('appeal.donationType.display_name')
->label('Cause')
->badge()
->color('success'),
Tables\Columns\TextColumn::make('appeal.amount_to_raise')
->label('Goal')
->formatStateUsing(fn ($state) => $state ? '£' . number_format($state, 0) : '—')
->sortable(),
Tables\Columns\TextColumn::make('ai_verdict')
->label('AI Review')
->getStateUsing(function (ApprovalQueue $record) {
$extra = json_decode($record->extra_data, true);
$ai = $extra['ai_review'] ?? null;
if (!$ai) return '—';
return $ai['decision'] ?? '—';
})
->badge()
->color(fn ($state) => match ($state) {
'approve' => 'success',
'reject' => 'danger',
'review' => 'warning',
default => 'gray',
})
->formatStateUsing(fn ($state) => match ($state) {
'approve' => '✓ Safe',
'reject' => '✗ Flagged',
'review' => '? Uncertain',
default => '—',
})
->tooltip(function (ApprovalQueue $record) {
$extra = json_decode($record->extra_data, true);
$ai = $extra['ai_review'] ?? null;
if (!$ai) return 'No AI review yet';
return ($ai['summary'] ?? '') . "\n\nConfidence: " . round(($ai['confidence'] ?? 0) * 100) . '%';
}),
Tables\Columns\TextColumn::make('message')
->label('Notes')
->limit(40)
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')
->label('Submitted')
->since()
->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->label('Status')
->options([
'pending' => 'Needs Review',
'confirmed' => 'Approved',
'change_requested' => 'Changes Needed',
])
->default('pending'),
Tables\Filters\SelectFilter::make('action')
->label('Type')
->options([
'Create' => 'New Fundraiser',
'Update' => 'Edit',
]),
])
->actions([
Tables\Actions\Action::make('quick_approve')
->label('Approve')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->modalHeading('Approve this fundraiser?')
->modalDescription(fn (ApprovalQueue $r) => "This will make \"{$r->appeal?->name}\" live on the website.")
->visible(fn (ApprovalQueue $r) => $r->status === 'pending')
->action(function (ApprovalQueue $record) {
app(ApprovalQueueService::class)->approveAppeal($record);
Notification::make()->title('Fundraiser approved')->success()->send();
}),
Tables\Actions\Action::make('quick_reject')
->label('Request Changes')
->icon('heroicon-o-x-circle')
->color('danger')
->visible(fn (ApprovalQueue $r) => $r->status === 'pending')
->form([
\Filament\Forms\Components\Textarea::make('message')
->label('What needs to change?')
->placeholder('Tell the fundraiser what to fix...')
->required()
->rows(3),
])
->action(function (ApprovalQueue $record, array $data) {
$record->update(['message' => $data['message']]);
app(ApprovalQueueService::class)->requestChange($record);
Notification::make()->title('Change request sent')->warning()->send();
}),
Tables\Actions\Action::make('view_fundraiser')
->label('View')
->icon('heroicon-o-eye')
->url(fn (ApprovalQueue $r) => $r->appeal_id
? AppealResource::getUrl('edit', ['record' => $r->appeal_id])
: null)
->openUrlInNewTab(),
])
->headerActions([
Tables\Actions\Action::make('ai_review_all')
->label('AI Review All Pending')
->icon('heroicon-o-sparkles')
->color('info')
->requiresConfirmation()
->modalHeading('Run AI Review on All Pending Fundraisers?')
->modalDescription('This will use AI to review all pending fundraisers. Obvious spam will be auto-rejected, clear fundraisers will be auto-approved, and uncertain ones will be flagged for your review.')
->modalSubmitActionLabel('Start AI Review')
->action(function () {
try {
$service = app(AIAppealReviewService::class);
$stats = $service->reviewAllPending();
Notification::make()
->title('AI Review Complete')
->body(
"Reviewed: {$stats['reviewed']}\n" .
"✓ Auto-approved: {$stats['approved']}\n" .
"✗ Auto-rejected: {$stats['rejected']}\n" .
"? Needs your review: {$stats['flagged']}"
)
->success()
->persistent()
->send();
} catch (\Throwable $e) {
Log::error('AI Review failed', ['error' => $e->getMessage()]);
Notification::make()
->title('AI Review Failed')
->body($e->getMessage())
->danger()
->send();
}
}),
Tables\Actions\Action::make('bulk_approve_safe')
->label('Approve All AI-Safe')
->icon('heroicon-o-check-badge')
->color('success')
->requiresConfirmation()
->modalHeading('Approve all AI-verified fundraisers?')
->modalDescription('This will approve all pending fundraisers that the AI marked as safe. Only high-confidence approvals will be processed.')
->visible(function () {
return ApprovalQueue::where('status', 'pending')
->where('extra_data', 'like', '%"decision":"approve"%')
->exists();
})
->action(function () {
$items = ApprovalQueue::where('status', 'pending')
->where('extra_data', 'like', '%"decision":"approve"%')
->with('appeal')
->get();
$count = 0;
foreach ($items as $item) {
if ($item->appeal) {
app(ApprovalQueueService::class)->approveAppeal($item);
$count++;
}
}
Notification::make()
->title("{$count} fundraisers approved")
->success()
->send();
}),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\BulkAction::make('bulk_approve')
->label('Approve Selected')
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->action(function ($records) {
$count = 0;
foreach ($records as $record) {
if ($record->status === 'pending' && $record->appeal) {
app(ApprovalQueueService::class)->approveAppeal($record);
$count++;
}
}
Notification::make()->title("{$count} fundraisers approved")->success()->send();
}),
Tables\Actions\BulkAction::make('bulk_reject')
->label('Reject Selected')
->icon('heroicon-o-x-circle')
->color('danger')
->requiresConfirmation()
->form([
\Filament\Forms\Components\Textarea::make('message')
->label('Rejection reason')
->required(),
])
->action(function ($records, array $data) {
$count = 0;
foreach ($records as $record) {
if ($record->status === 'pending') {
$record->update(['message' => $data['message']]);
app(ApprovalQueueService::class)->requestChange($record);
$count++;
}
}
Notification::make()->title("{$count} fundraisers rejected")->warning()->send();
}),
]),
])
->defaultSort('created_at', 'desc')
->poll('30s');
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\ListApprovalQueues::route('/'),
'edit' => Pages\EditApprovalQueue::route('/{record}/edit'),
];
}
}