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" "use client"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { formatPence } from "@/lib/utils" 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" import Link from "next/link"
interface EventSummary { interface EventSummary {
id: string id: string; name: string; slug: string; eventDate: string | null
name: string location: string | null; goalAmount: number | null; status: string
slug: string pledgeCount: number; qrSourceCount: number; totalPledged: number; totalCollected: number
eventDate: string | null paymentMode?: string; externalPlatform?: string
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> = { const platformNames: Record<string, string> = {
launchgood: "🌙 LaunchGood", launchgood: "LaunchGood", enthuse: "Enthuse", justgiving: "JustGiving",
enthuse: "💜 Enthuse", gofundme: "GoFundMe", other: "External",
justgiving: "💛 JustGiving",
gofundme: "💚 GoFundMe",
other: "🔗 External",
} }
export default function EventsPage() { export default function CollectPage() {
const [events, setEvents] = useState<EventSummary[]>([]) const [events, setEvents] = useState<EventSummary[]>([])
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false) 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(() => { useEffect(() => {
fetch("/api/events") fetch("/api/events")
.then((r) => r.json()) .then(r => r.json())
.then((data) => { .then(data => { if (Array.isArray(data)) setEvents(data) })
if (Array.isArray(data)) setEvents(data)
})
.catch(() => {}) .catch(() => {})
.finally(() => setLoading(false)) .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(() => { useEffect(() => {
fetch("/api/onboarding").then(r => r.json()).then(d => { fetch("/api/onboarding").then(r => r.json()).then(d => {
if (d.orgType) setOrgType(d.orgType) if (d.orgType) setOrgType(d.orgType)
// Auto-set external mode for fundraisers
if (d.orgType === "fundraiser") setForm(f => ({ ...f, paymentMode: "external" })) if (d.orgType === "fundraiser") setForm(f => ({ ...f, paymentMode: "external" }))
}).catch(() => {}) }).catch(() => {})
}, []) }, [])
@@ -71,274 +55,239 @@ export default function EventsPage() {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
name: form.name, name: form.name, description: form.description || undefined,
description: form.description || undefined,
location: form.location || undefined, location: form.location || undefined,
goalAmount: form.goalAmount ? Math.round(parseFloat(form.goalAmount) * 100) : undefined, goalAmount: form.goalAmount ? Math.round(parseFloat(form.goalAmount) * 100) : undefined,
eventDate: form.eventDate ? new Date(form.eventDate).toISOString() : undefined, eventDate: form.eventDate ? new Date(form.eventDate).toISOString() : undefined,
paymentMode: form.paymentMode, paymentMode: form.paymentMode, externalUrl: form.externalUrl || undefined,
externalUrl: form.externalUrl || undefined,
externalPlatform: form.paymentMode === "external" ? (form.externalPlatform || "other") : undefined, externalPlatform: form.paymentMode === "external" ? (form.externalPlatform || "other") : undefined,
zakatEligible: form.zakatEligible, zakatEligible: form.zakatEligible,
}), }),
}) })
if (res.ok) { if (res.ok) {
const event = await res.json() 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) setShowCreate(false)
setForm({ name: "", description: "", location: "", eventDate: "", goalAmount: "", paymentMode: orgType === "fundraiser" ? "external" : "self", externalUrl: "", externalPlatform: "", zakatEligible: false }) setForm({ name: "", description: "", location: "", eventDate: "", goalAmount: "", paymentMode: orgType === "fundraiser" ? "external" : "self", externalUrl: "", externalPlatform: "", zakatEligible: false })
} }
} catch { } catch { /* */ }
// handle error
}
setCreating(false) setCreating(false)
} }
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-extrabold text-gray-900">Campaigns</h1> <h1 className="text-3xl font-black text-[#111827] tracking-tight">Collect</h1>
<p className="text-muted-foreground mt-1">Create campaigns, share pledge links, and track donations</p> <p className="text-sm text-gray-500 mt-0.5">Create appeals, share pledge links, and track who&apos;s pledged</p>
</div> </div>
<Button onClick={() => setShowCreate(true)}> <button
<Plus className="h-4 w-4 mr-2" /> New Campaign onClick={() => setShowCreate(true)}
</Button> 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> </div>
{/* Event cards */} {/* 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"> <div className="grid md:grid-cols-2 gap-4">
{events.map((event) => { {events.map(event => {
const progress = event.goalAmount ? Math.round((event.totalPledged / event.goalAmount) * 100) : 0 const progress = event.goalAmount ? Math.min(100, Math.round((event.totalPledged / event.goalAmount) * 100)) : 0
return ( return (
<Card key={event.id} className="hover:shadow-md transition-shadow"> <div key={event.id} className="bg-white border border-gray-200 hover:border-gray-300 transition-colors">
<CardHeader> <div className="p-5 space-y-4">
{/* Header */}
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<CardTitle className="text-lg">{event.name}</CardTitle> <h3 className="text-base font-bold text-[#111827]">{event.name}</h3>
<CardDescription className="flex items-center gap-3 mt-1"> <div className="flex items-center gap-3 text-xs text-gray-500 mt-1">
{event.eventDate && ( {event.eventDate && <span>{new Date(event.eventDate).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })}</span>}
<span className="flex items-center gap-1"> {event.location && <span>{event.location}</span>}
<Calendar className="h-3 w-3" /> </div>
{new Date(event.eventDate).toLocaleDateString("en-GB")}
</span>
)}
{event.location && (
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{event.location}
</span>
)}
</CardDescription>
</div> </div>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
{event.paymentMode === "external" && event.externalPlatform && ( {event.paymentMode === "external" && event.externalPlatform && (
<Badge variant="outline" className="text-[10px]">{platformNames[event.externalPlatform] || "External"}</Badge> <span className="text-[10px] font-bold text-gray-500 border border-gray-200 px-1.5 py-0.5">
{platformNames[event.externalPlatform] || "External"}
</span>
)} )}
<Badge variant={event.status === "active" ? "success" : "secondary"}> <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} {event.status === "active" ? "Live" : 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> </span>
</div> </div>
<div className="h-2 rounded-full bg-gray-100 overflow-hidden"> </div>
<div
className="h-full rounded-full bg-trust-blue transition-all" {/* Stats — gap-px grid */}
style={{ width: `${Math.min(progress, 100)}%` }} <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>
{/* 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>
</div> </div>
)} )}
{/* Actions */}
<div className="flex gap-2"> <div className="flex gap-2">
<Link href={`/dashboard/events/${event.id}`} className="flex-1"> <Link href={`/dashboard/events/${event.id}`} className="flex-1">
<Button variant="outline" size="sm" className="w-full"> <button className="w-full border border-gray-200 px-3 py-2 text-xs font-semibold text-[#111827] hover:bg-gray-50 transition-colors">
<QrCode className="h-4 w-4 mr-1" /> Pledge Links ({event.qrSourceCount}) Pledge Links ({event.qrSourceCount})
</Button> </button>
</Link> </Link>
<Link href={`/dashboard/pledges?event=${event.id}`} className="flex-1"> <Link href={`/dashboard/money?event=${event.id}`} className="flex-1">
<Button variant="outline" size="sm" className="w-full"> <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 View Pledges
</Button> </button>
</Link> </Link>
</div> </div>
</CardContent> </div>
</Card> </div>
) )
})} })}
</div> </div>
)}
{/* Create dialog */} {/* Create dialog */}
<Dialog open={showCreate} onOpenChange={setShowCreate}> <Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogHeader> <DialogHeader>
<DialogTitle>New Campaign</DialogTitle> <DialogTitle className="font-black text-[#111827]">New Appeal</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Campaign Name *</Label> <Label className="text-xs font-bold">What are you raising for? *</Label>
<Input <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} value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Description</Label> <Label className="text-xs font-bold">Description <span className="font-normal text-gray-400">(optional)</span></Label>
<Textarea <Textarea placeholder="Brief description for donors..." value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} />
placeholder="Brief description..."
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
/>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Date</Label> <Label className="text-xs font-bold">Date <span className="font-normal text-gray-400">(optional)</span></Label>
<Input <Input type="datetime-local" value={form.eventDate} onChange={e => setForm(f => ({ ...f, eventDate: e.target.value }))} />
type="datetime-local"
value={form.eventDate}
onChange={(e) => setForm((f) => ({ ...f, eventDate: e.target.value }))}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Goal (£)</Label> <Label className="text-xs font-bold">Target amount (£) <span className="font-normal text-gray-400">(optional)</span></Label>
<Input <Input type="number" placeholder="50000" value={form.goalAmount} onChange={e => setForm(f => ({ ...f, goalAmount: e.target.value }))} />
type="number"
placeholder="50000"
value={form.goalAmount}
onChange={(e) => setForm((f) => ({ ...f, goalAmount: e.target.value }))}
/>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Location</Label> <Label className="text-xs font-bold">Venue <span className="font-normal text-gray-400">(optional)</span></Label>
<Input <Input placeholder="Venue name and address" value={form.location} onChange={e => setForm(f => ({ ...f, location: e.target.value }))} />
placeholder="Venue name and address"
value={form.location}
onChange={(e) => setForm((f) => ({ ...f, location: e.target.value }))}
/>
</div> </div>
{/* Payment mode toggle */} {/* How do donors pay? */}
<div className="space-y-2"> <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"> <div className="grid grid-cols-2 gap-2">
<button <button
type="button" type="button"
onClick={() => setForm(f => ({ ...f, paymentMode: "self" }))} 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="font-bold block">Bank transfer</span>
<span className="text-muted-foreground">We show our bank details</span> <span className="text-gray-500">We show our bank details</span>
</button> </button>
<button <button
type="button" type="button"
onClick={() => setForm(f => ({ ...f, paymentMode: "external" }))} 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="font-bold block">External page</span>
<span className="text-muted-foreground">LaunchGood, Enthuse, etc.</span> <span className="text-gray-500">LaunchGood, Enthuse, etc.</span>
</button> </button>
</div> </div>
</div> </div>
{form.paymentMode === "external" && ( {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"> <div className="space-y-2">
<Label>Fundraising page URL *</Label> <Label className="text-xs font-bold">Fundraising page URL *</Label>
<div className="relative"> <div className="relative">
<ExternalLink className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" /> <ExternalLink className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input <Input placeholder="https://launchgood.com/my-campaign" value={form.externalUrl} onChange={e => setForm(f => ({ ...f, externalUrl: e.target.value }))} className="pl-9" />
placeholder="https://launchgood.com/my-campaign"
value={form.externalUrl}
onChange={(e) => setForm((f) => ({ ...f, externalUrl: e.target.value }))}
className="pl-9"
/>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Platform</Label> <Label className="text-xs font-bold">Platform</Label>
<select <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">
value={form.externalPlatform} <option value="">Select...</option>
onChange={(e) => setForm(f => ({ ...f, externalPlatform: e.target.value }))} <option value="launchgood">LaunchGood</option>
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm" <option value="enthuse">Enthuse</option>
> <option value="justgiving">JustGiving</option>
<option value="">Select platform...</option> <option value="gofundme">GoFundMe</option>
<option value="launchgood">🌙 LaunchGood</option> <option value="other">Other</option>
<option value="enthuse">💜 Enthuse</option>
<option value="justgiving">💛 JustGiving</option>
<option value="gofundme">💚 GoFundMe</option>
<option value="other">🔗 Other / Custom</option>
</select> </select>
</div> </div>
</div> </div>
)} )}
{/* Zakat eligible toggle */} {/* Zakat toggle */}
<button <button
type="button" type="button"
onClick={() => setForm(f => ({ ...f, zakatEligible: !f.zakatEligible }))} 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 ${ className={`w-full flex items-center justify-between border-2 p-3 text-left transition-all ${
form.zakatEligible ? "border-trust-blue bg-trust-blue/5" : "border-gray-200" form.zakatEligible ? "border-[#1E40AF] bg-[#1E40AF]/5" : "border-gray-200"
}`} }`}
> >
<div> <div>
<p className="text-sm font-bold">🌙 Zakat eligible</p> <p className="text-sm font-bold text-[#111827]">Zakat eligible</p>
<p className="text-xs text-muted-foreground">Let donors mark their pledge as Zakat</p> <p className="text-xs text-gray-500">Let donors mark their pledge as Zakat</p>
</div> </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>} {form.zakatEligible && <span className="text-white text-xs font-bold"></span>}
</div> </div>
</button> </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"> <div className="flex gap-3 pt-2">
<Button variant="outline" onClick={() => setShowCreate(false)} className="flex-1"> <Button variant="outline" onClick={() => setShowCreate(false)} className="flex-1">Cancel</Button>
Cancel <button
</Button> onClick={handleCreate}
<Button onClick={handleCreate} disabled={!form.name || creating || (form.paymentMode === "external" && !form.externalUrl)} className="flex-1"> disabled={!form.name || creating || (form.paymentMode === "external" && !form.externalUrl)}
{creating ? "Creating..." : "Create Campaign"} className="flex-1 bg-[#111827] px-4 py-2 text-sm font-bold text-white hover:bg-gray-800 disabled:opacity-50 transition-colors"
</Button> >
{creating ? "Creating..." : "Create Appeal"}
</button>
</div> </div>
</div> </div>
</Dialog> </Dialog>

View File

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

View File

@@ -4,15 +4,22 @@ import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { useSession, signOut } from "next-auth/react" import { useSession, signOut } from "next-auth/react"
import { useState, useEffect } from "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" 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 = [ const navItems = [
{ href: "/dashboard", label: "Overview", icon: LayoutDashboard }, { href: "/dashboard", label: "Home", icon: Home },
{ href: "/dashboard/events", label: "Campaigns", icon: Megaphone }, { href: "/dashboard/collect", label: "Collect", icon: Megaphone },
{ href: "/dashboard/pledges", label: "Pledges", icon: FileBarChart }, { href: "/dashboard/money", label: "Money", icon: Banknote },
{ href: "/dashboard/reconcile", label: "Reconcile", icon: Upload }, { href: "/dashboard/reports", label: "Reports", icon: FileText },
{ href: "/dashboard/exports", label: "Exports", icon: Download },
{ href: "/dashboard/settings", label: "Settings", icon: Settings }, { 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const user = session?.user as 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 ( return (
<div className="min-h-screen bg-paper"> <div className="min-h-screen bg-[#F9FAFB]">
{/* Top bar — sharp, no blur */} {/* Top bar — brand-consistent: sharp, midnight, no blur */}
<header className="sticky top-0 z-40 border-b border-gray-200 bg-white"> <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"> <div className="flex h-14 items-center gap-4 px-4 md:px-6">
<Link href="/dashboard" className="flex items-center gap-2.5"> <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> <span className="text-white text-xs font-black">P</span>
</div> </div>
<div className="hidden sm:block"> <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> </div>
</Link> </Link>
<div className="flex-1" /> <div className="flex-1" />
<Link href="/dashboard/events" className="hidden md:block"> <Link href="/dashboard/collect">
<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"> <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 Campaign <Plus className="h-3 w-3" /> New Appeal
</button> </button>
</Link> </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 && ( {session && (
<button <button
onClick={() => signOut({ callbackUrl: "/login" })} 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> </button>
)} )}
</div> </div>
</header> </header>
<div className="flex"> <div className="flex">
{/* Desktop sidebar — clean, no decorative elements */} {/* Desktop sidebar — brand style: sharp, left-border active state */}
<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"> <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"> <nav className="space-y-0.5">
{navItems.map((item) => { {navItems.map((item) => {
const isActive = pathname === item.href || (item.href !== "/dashboard" && pathname.startsWith(item.href)) const active = isActive(item.href)
return ( return (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
className={cn( 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",
isActive active
? "bg-promise-blue/5 text-promise-blue border-l-2 border-promise-blue -ml-0.5" ? "bg-[#1E40AF]/5 text-[#1E40AF] border-l-2 border-[#1E40AF] -ml-[2px] pl-[14px]"
: "text-gray-500 hover:bg-gray-50 hover:text-midnight" : "text-gray-500 hover:bg-gray-50 hover:text-[#111827]"
)} )}
> >
<item.icon className="h-4 w-4" /> <item.icon className="h-4 w-4" />
@@ -81,14 +95,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
})} })}
{user?.role === "super_admin" && ( {user?.role === "super_admin" && (
<> <>
<div className="my-2 border-t border-gray-100" /> <div className="my-3 border-t border-gray-100" />
<Link <Link
href={adminNav.href} href={adminNav.href}
className={cn( 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 pathname === adminNav.href
? "bg-promise-blue/5 text-promise-blue border-l-2 border-promise-blue -ml-0.5" ? "bg-[#1E40AF]/5 text-[#1E40AF] border-l-2 border-[#1E40AF] -ml-[2px] pl-[14px]"
: "text-gray-500 hover:bg-gray-50 hover:text-midnight" : "text-gray-500 hover:bg-gray-50 hover:text-[#111827]"
)} )}
> >
<adminNav.icon className="h-4 w-4" /> <adminNav.icon className="h-4 w-4" />
@@ -98,41 +112,42 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
)} )}
</nav> </nav>
<div className="mt-auto px-2 pt-4"> {/* Sidebar CTA — brand style, no emoji */}
<div className="border border-gray-200 p-3 space-y-1.5"> <div className="mt-auto px-1.5 pt-4">
<p className="text-xs font-bold text-midnight">Need help?</p> <div className="border-l-2 border-[#111827] pl-3 py-2">
<p className="text-[10px] text-gray-500 leading-relaxed"> <p className="text-xs font-bold text-[#111827]">Need expert help?</p>
Get a fractional Head of Technology to optimise your charity&apos;s digital stack. <p className="text-[10px] text-gray-500 leading-relaxed mt-0.5">
Get a fractional CTO for your charity&apos;s digital stack.
</p> </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 Learn more
</Link> </Link>
</div> </div>
</div> </div>
</aside> </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"> <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) => { {navItems.map((item) => {
const isActive = pathname === item.href || (item.href !== "/dashboard" && pathname.startsWith(item.href)) const active = isActive(item.href)
return ( return (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
className={cn( className={cn(
"flex flex-col items-center gap-0.5 py-1 px-2 transition-colors", "flex flex-col items-center gap-0.5 py-1.5 px-2 transition-colors",
isActive ? "text-promise-blue" : "text-gray-400" active ? "text-[#1E40AF]" : "text-gray-400"
)} )}
> >
<item.icon className="h-5 w-5" /> <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> </Link>
) )
})} })}
</nav> </nav>
{/* Main content */} {/* 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 /> <WhatsAppBanner />
{children} {children}
</main> </main>
@@ -148,7 +163,6 @@ function WhatsAppBanner() {
const pathname = usePathname() const pathname = usePathname()
useEffect(() => { useEffect(() => {
// Don't show on settings page (they're already there)
if (pathname === "/dashboard/settings") { setStatus("skip"); return } if (pathname === "/dashboard/settings") { setStatus("skip"); return }
fetch("/api/whatsapp/send") fetch("/api/whatsapp/send")
.then(r => r.json()) .then(r => r.json())
@@ -159,19 +173,17 @@ function WhatsAppBanner() {
if (status === "CONNECTED" || status === "skip" || status === null || dismissed) return null if (status === "CONNECTED" || status === "skip" || status === null || dismissed) return null
return ( return (
<div className="mb-4 rounded-lg border-2 border-amber-200 bg-amber-50 p-4 flex items-start gap-3"> <div className="mb-6 border-l-2 border-[#F59E0B] bg-[#F59E0B]/5 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-[#F59E0B] shrink-0 mt-0.5" />
<AlertTriangle className="h-5 w-5 text-amber-600" />
</div>
<div className="flex-1 min-w-0"> <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"> <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> </p>
<div className="flex items-center gap-3 mt-2"> <div className="flex items-center gap-3 mt-2.5">
<Link <Link
href="/dashboard/settings" 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 <MessageCircle className="h-3.5 w-3.5" /> Connect WhatsApp
</Link> </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" "use client"
import { useState, useEffect, useCallback } from "react" 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 { 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" import Link from "next/link"
interface DashboardData { // eslint-disable-next-line @typescript-eslint/no-explicit-any
summary: { totalPledges: number; totalPledgedPence: number; totalCollectedPence: number; collectionRate: number; overdueRate: number } type DashboardData = any
byStatus: Record<string, number> // eslint-disable-next-line @typescript-eslint/no-explicit-any
byRail: Record<string, number> type OnboardingData = any
topSources: Array<{ label: string; count: number; amount: number }>
pledges: Array<{ /**
id: string; reference: string; amountPence: number; status: string; rail: string; * Human-readable status labels.
donorName: string | null; donorEmail: string | null; donorPhone: string | null; * These replace SaaS jargon with language a charity volunteer would use.
eventName: string; source: string | null; giftAid: boolean; */
dueDate: string | null; isDeferred: boolean; planId: string | null; const STATUS_LABELS: Record<string, { label: string; color: string; bg: string }> = {
installmentNumber: number | null; installmentTotal: number | null; new: { label: "Waiting", color: "text-gray-600", bg: "bg-gray-100" },
createdAt: string; paidAt: string | null; nextReminder: string | null; 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 { // ─── Getting Started ─────────────────────────────────────────
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
}
const statusIcons: Record<string, typeof Clock> = { new: Clock, initiated: TrendingUp, paid: CheckCircle2, overdue: AlertTriangle } function GettingStarted({
const statusColors: Record<string, "secondary" | "warning" | "success" | "destructive"> = { new: "secondary", initiated: "warning", paid: "success", overdue: "destructive" } ob, onSetRole, dismissed, onDismiss,
// ─── 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,
}: { }: {
ob: OnboardingData ob: OnboardingData; onSetRole: (role: string) => void; dismissed: boolean; onDismiss: () => void
onSetRole: (role: string) => void
dismissed: boolean
onDismiss: () => void
}) { }) {
const [showRolePicker, setShowRolePicker] = useState(!ob.orgType || ob.orgType === "charity")
if (ob.allDone || dismissed) return null if (ob.allDone || dismissed) return null
const isFirstTime = ob.completed === 0
// First-time: show role picker
const isFirstTime = ob.completed === 0 && (!ob.orgType || ob.orgType === "charity")
return ( return (
<div className="rounded-lg border border-trust-blue/20 bg-paper p-5 space-y-4 relative"> <div className="border-l-2 border-[#1E40AF] bg-white p-5 relative">
{/* Dismiss X */} <button onClick={onDismiss} className="absolute top-3 right-3 text-gray-300 hover:text-gray-600 p-1">
<button onClick={onDismiss} className="absolute top-3 right-3 text-muted-foreground hover:text-foreground p-1">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 mb-4">
<div className="h-10 w-10 rounded-lg bg-midnight flex items-center justify-center flex-shrink-0"> <div className="h-8 w-8 bg-[#111827] flex items-center justify-center">
<span className="text-white text-lg">🤲</span> <span className="text-white text-xs font-black">P</span>
</div> </div>
<div> <div>
<h2 className="text-sm font-bold text-gray-900"> <h2 className="text-sm font-black text-[#111827]">
{isFirstTime ? "Welcome! What best describes you?" : `Getting started · ${ob.completed}/${ob.total}`} {isFirstTime ? "Let's get you set up" : `Getting started ${ob.completed} of ${ob.total} done`}
</h2> </h2>
{!isFirstTime && ( {!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>
</div> </div>
{isFirstTime && showRolePicker ? ( {isFirstTime && !ob.orgType ? (
<RolePicker onSelect={(role) => { onSetRole(role); setShowRolePicker(false) }} /> <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"> <div className="space-y-1">
{ob.steps.map((step, i) => { {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 => s.done) const isNext = !step.done && ob.steps.slice(0, i).every((s: { done: boolean }) => s.done)
return ( return (
<Link key={step.id} href={step.href}> <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 ${ <div className={`flex items-center gap-2.5 px-3 py-2.5 transition-all ${
step.done ? "bg-success-green/5 border-success-green/20" : step.done ? "opacity-50" :
isNext ? "bg-trust-blue/5 border-trust-blue/20 shadow-sm" : isNext ? "bg-[#1E40AF]/5 border-l-2 border-[#1E40AF] -ml-[2px] pl-[14px]" :
"bg-white border-gray-100" ""
}`}> }`}>
{step.done ? ( {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 ? ( ) : 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"> <span className={`text-xs font-medium ${step.done ? "line-through text-gray-400" : isNext ? "text-[#111827]" : "text-gray-400"}`}>
<p className={`text-xs font-medium truncate ${step.done ? "text-success-green line-through" : isNext ? "text-gray-900" : "text-gray-400"}`}>{step.label}</p> {step.label}
</div> </span>
{isNext && <ArrowRight className="h-3 w-3 text-trust-blue flex-shrink-0" />} {isNext && <ArrowRight className="h-3 w-3 text-[#1E40AF] ml-auto shrink-0" />}
</div> </div>
</Link> </Link>
) )
@@ -140,6 +106,7 @@ function GettingStartedBanner({
} }
// ─── Main Dashboard ───────────────────────────────────────── // ─── Main Dashboard ─────────────────────────────────────────
export default function DashboardPage() { export default function DashboardPage() {
const [data, setData] = useState<DashboardData | null>(null) const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -169,7 +136,6 @@ export default function DashboardPage() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orgType: role }), body: JSON.stringify({ orgType: role }),
}) })
// Refresh onboarding state
const res = await fetch("/api/onboarding") const res = await fetch("/api/onboarding")
const d = await res.json() const d = await res.json()
if (d.steps) setOb(d) if (d.steps) setOb(d)
@@ -178,291 +144,227 @@ export default function DashboardPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-20"> <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> </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 byStatus = data?.byStatus || {}
const topSources = data?.topSources || [] const topSources = data?.topSources || []
const pledges = data?.pledges || [] const pledges = data?.pledges || []
const upcomingPledges = pledges.filter(p =>
p.isDeferred && p.dueDate && p.status !== "paid" && p.status !== "cancelled" const recentPledges = pledges.filter((p: { status: string }) => p.status !== "cancelled").slice(0, 8)
).sort((a, b) => new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime()) const needsAttention = [
const recentPledges = pledges.filter(p => p.status !== "cancelled").slice(0, 8) ...pledges.filter((p: { status: string }) => p.status === "overdue"),
const needsAction = [ ...pledges.filter((p: { status: string; dueDate: string | null }) =>
...pledges.filter(p => p.status === "overdue"), p.status !== "paid" && p.status !== "cancelled" && p.dueDate &&
...upcomingPledges.filter(p => { new Date(p.dueDate).getTime() - Date.now() < 2 * 86400000
const due = new Date(p.dueDate!) ),
return due.getTime() - Date.now() < 2 * 86400000
})
].slice(0, 5) ].slice(0, 5)
const isEmpty = s.totalPledges === 0 const isEmpty = s.totalPledges === 0
return ( return (
<div className="space-y-6"> <div className="space-y-8">
{/* Getting-started banner — always at top, not a blocker */} {/* Onboarding */}
{ob && !ob.allDone && ( {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 className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-black text-gray-900">Dashboard</h1> <h1 className="text-3xl font-black text-[#111827] tracking-tight">Home</h1>
<p className="text-sm text-muted-foreground mt-0.5"> <p className="text-sm text-gray-500 mt-0.5">
{whatsappStatus !== null && ( {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" /> <MessageCircle className="h-3 w-3" />
{whatsappStatus ? "WhatsApp connected" : "WhatsApp offline"} {whatsappStatus ? "WhatsApp connected" : "WhatsApp offline"}
</span> </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> </p>
</div> </div>
{!isEmpty && ( {!isEmpty && (
<Link href="/dashboard/pledges"> <Link href="/dashboard/money" className="text-xs font-semibold text-[#1E40AF] hover:underline flex items-center gap-1">
<Button variant="outline" size="sm">View All Pledges <ArrowRight className="h-3 w-3 ml-1" /></Button>
</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>
</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>
</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>
</CardContent>
</Card>
{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>
) : (
<>
<div className="grid lg:grid-cols-2 gap-4">
{/* 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>
</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" /> View all <ArrowRight className="h-3 w-3" />
</Link> </Link>
)} )}
</CardContent>
</Card>
{/* 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>
<div>
<p className="text-sm font-medium">{p.donorName || "Anonymous"}</p> {/* ─── Big Numbers — gap-px grid (brand pattern) ─── */}
<p className="text-xs text-muted-foreground"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-px bg-gray-200">
{p.eventName} {[
{p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`} { 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>
<p className="text-xs text-gray-500 mt-1">{stat.label}</p>
</div>
))}
</div>
{/* ─── 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>
<div className="h-3 bg-gray-100 overflow-hidden">
<div className="h-full bg-[#1E40AF] transition-all duration-700" style={{ width: `${s.collectionRate}%` }} />
</div>
<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>
</div> </div>
<span className="font-bold text-sm">{formatPence(p.amountPence)}</span>
</div>
))
)} )}
</CardContent>
</Card>
</div>
{/* Pipeline + Sources */} {isEmpty ? (
<div className="grid lg:grid-cols-2 gap-4"> /* Empty state — clean, directive */
<Card> <div className="bg-white border-2 border-dashed border-gray-200 p-10 text-center">
<CardHeader className="pb-3"> <p className="text-4xl font-black text-gray-200 mb-3">01</p>
<CardTitle className="text-base">Pipeline by Status</CardTitle> <h3 className="text-base font-bold text-[#111827]">Share your first pledge link</h3>
</CardHeader> <p className="text-sm text-gray-500 mt-1 max-w-sm mx-auto">
<CardContent className="space-y-3"> Create an appeal, share the link with donors, and watch pledges come in here.
{Object.entries(byStatus).map(([status, count]) => { </p>
const Icon = statusIcons[status] || Clock <Link href="/dashboard/collect">
return ( <button className="mt-4 bg-[#111827] px-5 py-2.5 text-sm font-bold text-white hover:bg-gray-800 transition-colors">
<div key={status} className="flex items-center justify-between"> Create an Appeal
<div className="flex items-center gap-2"> </button>
<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> </Link>
</CardHeader> </div>
<CardContent> ) : (
<div className="space-y-2"> <div className="grid lg:grid-cols-5 gap-6">
{recentPledges.map(p => { {/* LEFT: Needs attention + Pipeline */}
const sc = statusColors[p.status] || "secondary" <div className="lg:col-span-2 space-y-6">
{/* Needs attention */}
{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 ( return (
<div key={p.id} className="flex items-center justify-between py-2 border-b last:border-0"> <div key={p.id} className="px-5 py-3 flex items-center justify-between">
<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> <div>
<p className="text-sm font-medium">{p.donorName || "Anonymous"}</p> <p className="text-sm font-medium text-[#111827]">{p.donorName || "Anonymous"}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-gray-500">{formatPence(p.amountPence)} · {p.eventName}</p>
{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>
<span className={`text-[10px] font-bold px-2 py-0.5 ${sl.bg} ${sl.color}`}>{sl.label}</span>
</div> </div>
) )
})} })}
</div> </div>
</CardContent> <div className="px-5 py-2 border-t border-gray-50">
</Card> <Link href="/dashboard/money" className="text-xs font-semibold text-[#1E40AF] hover:underline">
</> View all
</Link>
</div>
</div>
)}
{/* 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 sl = STATUS_LABELS[status] || STATUS_LABELS.new
return (
<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>
</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> </div>
) )

View File

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

View File

@@ -1,48 +1,74 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { Upload, CheckCircle2, AlertCircle, HelpCircle, ArrowLeft } from "lucide-react"
import { Button } from "@/components/ui/button" import Link from "next/link"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Upload, CheckCircle2, AlertCircle, HelpCircle, FileSpreadsheet } from "lucide-react"
interface MatchResult { interface MatchResult {
bankRow: { bankRow: { date: string; description: string; amount: number; reference: string }
date: string pledgeId: string | null; pledgeReference: string | null
description: string
amount: number
reference: string
}
pledgeId: string | null
pledgeReference: string | null
confidence: "exact" | "partial" | "amount_only" | "none" confidence: "exact" | "partial" | "amount_only" | "none"
matchedAmount: number matchedAmount: number; autoConfirmed: boolean
autoConfirmed: boolean
} }
export default function ReconcilePage() { export default function MatchPaymentsPage() {
const [file, setFile] = useState<File | null>(null) const [file, setFile] = useState<File | null>(null)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [results, setResults] = useState<{ const [bankName, setBankName] = useState("")
summary: { const [results, setResults] = useState<{ summary: { totalRows: number; credits: number; exactMatches: number; partialMatches: number; unmatched: number; autoConfirmed: number }; matches: MatchResult[] } | null>(null)
totalRows: number const [mapping, setMapping] = useState({ dateCol: "Date", descriptionCol: "Description", creditCol: "Credit", referenceCol: "Reference" })
credits: number
exactMatches: number
partialMatches: number
unmatched: number
autoConfirmed: number
}
matches: MatchResult[]
} | null>(null)
const [mapping, setMapping] = useState({ // Try auto-detecting bank format
dateCol: "Date", const autoDetect = async (f: File) => {
descriptionCol: "Description", const text = await f.text()
creditCol: "Credit", const firstLine = text.split("\n")[0]
referenceCol: "Reference",
// 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 () => { const handleUpload = async () => {
if (!file) return if (!file) return
@@ -51,178 +77,139 @@ export default function ReconcilePage() {
const formData = new FormData() const formData = new FormData()
formData.append("file", file) formData.append("file", file)
formData.append("mapping", JSON.stringify(mapping)) formData.append("mapping", JSON.stringify(mapping))
const res = await fetch("/api/imports/bank-statement", { method: "POST", body: formData })
const res = await fetch("/api/imports/bank-statement", {
method: "POST",
headers: { },
body: formData,
})
const data = await res.json() const data = await res.json()
if (data.summary) { if (data.summary) setResults(data)
setResults(data) } catch { /* */ }
}
} catch {
// handle error
}
setUploading(false) setUploading(false)
} }
const confidenceIcon = (c: string) => { const confidenceIcon = (c: string) => {
switch (c) { switch (c) {
case "exact": return <CheckCircle2 className="h-4 w-4 text-success-green" /> case "exact": return <CheckCircle2 className="h-4 w-4 text-[#16A34A]" />
case "partial": return <AlertCircle className="h-4 w-4 text-warm-amber" /> case "partial": return <AlertCircle className="h-4 w-4 text-[#F59E0B]" />
default: return <HelpCircle className="h-4 w-4 text-muted-foreground" /> default: return <HelpCircle className="h-4 w-4 text-gray-300" />
} }
} }
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<div> <div>
<h1 className="text-3xl font-extrabold text-gray-900">Reconcile Payments</h1> <Link href="/dashboard/money" className="text-xs text-gray-500 hover:text-[#111827] transition-colors inline-flex items-center gap-1 mb-2">
<p className="text-muted-foreground mt-1">Upload a bank statement CSV to automatically match payments to pledges</p> <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> </div>
{/* Upload card */} {/* Upload section */}
<Card> <div className="bg-white border border-gray-200 p-6 space-y-5">
<CardHeader> <div>
<CardTitle className="text-lg flex items-center gap-2"> <h3 className="text-base font-bold text-[#111827]">Upload bank statement</h3>
<FileSpreadsheet className="h-5 w-5" /> Bank Statement Import <p className="text-xs text-gray-500 mt-1">
</CardTitle> 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.
<CardDescription> </p>
Export your bank statement as CSV and upload it here. We&apos;ll match payment references automatically.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Column mapping */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-1">
<Label className="text-xs">Date Column</Label>
<Input
value={mapping.dateCol}
onChange={(e) => setMapping((m) => ({ ...m, dateCol: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Description Column</Label>
<Input
value={mapping.descriptionCol}
onChange={(e) => setMapping((m) => ({ ...m, descriptionCol: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Credit/Amount Column</Label>
<Input
value={mapping.creditCol}
onChange={(e) => setMapping((m) => ({ ...m, creditCol: e.target.value }))}
className="h-9 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Reference Column</Label>
<Input
value={mapping.referenceCol}
onChange={(e) => setMapping((m) => ({ ...m, referenceCol: e.target.value }))}
className="h-9 text-sm"
/>
</div>
</div> </div>
{/* File upload */} {/* File drop zone */}
<div className="border-2 border-dashed border-gray-200 rounded-lg p-8 text-center hover:border-trust-blue/50 transition-colors"> <div className="border-2 border-dashed border-gray-200 p-8 text-center hover:border-[#1E40AF]/50 transition-colors">
<Upload className="h-10 w-10 text-muted-foreground mx-auto mb-3" /> <Upload className="h-8 w-8 text-gray-300 mx-auto mb-3" />
<input <input type="file" accept=".csv" onChange={e => e.target.files?.[0] && handleFileSelect(e.target.files[0])} className="hidden" id="csv-upload" />
type="file"
accept=".csv"
onChange={(e) => setFile(e.target.files?.[0] || null)}
className="hidden"
id="csv-upload"
/>
<label htmlFor="csv-upload" className="cursor-pointer"> <label htmlFor="csv-upload" className="cursor-pointer">
<p className="font-medium">{file ? file.name : "Click to upload CSV"}</p> <p className="text-sm font-medium text-[#111827]">{file ? file.name : "Click to choose a CSV file"}</p>
<p className="text-xs text-muted-foreground mt-1">CSV file from your bank</p> <p className="text-[10px] text-gray-500 mt-1">CSV file from your online banking</p>
</label> </label>
</div> </div>
<Button onClick={handleUpload} disabled={!file || uploading} className="w-full"> {/* Column mapping — auto-detected, editable */}
{uploading ? "Processing..." : "Upload & Match"} {file && (
</Button> <div>
</CardContent> <div className="flex items-center gap-2 mb-3">
</Card> <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="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>
)}
<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 */}
{results && ( {results && (
<> <>
{/* Summary */} {/* Summary — gap-px grid */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4"> <div className="grid grid-cols-2 md:grid-cols-5 gap-px bg-gray-200">
<Card> {[
<CardContent className="pt-6 text-center"> { value: results.summary.totalRows, label: "Rows" },
<p className="text-2xl font-bold">{results.summary.totalRows}</p> { value: results.summary.credits, label: "Incoming payments" },
<p className="text-xs text-muted-foreground">Total Rows</p> { value: results.summary.exactMatches, label: "Matched", accent: "text-[#16A34A]" },
</CardContent> { value: results.summary.partialMatches, label: "Possible matches", accent: "text-[#F59E0B]" },
</Card> { value: results.summary.autoConfirmed, label: "Auto-confirmed", accent: "text-[#16A34A]" },
<Card> ].map(s => (
<CardContent className="pt-6 text-center"> <div key={s.label} className="bg-white p-4 text-center">
<p className="text-2xl font-bold">{results.summary.credits}</p> <p className={`text-2xl font-black ${s.accent || "text-[#111827]"}`}>{s.value}</p>
<p className="text-xs text-muted-foreground">Credits</p> <p className="text-[10px] text-gray-500 mt-0.5">{s.label}</p>
</CardContent> </div>
</Card> ))}
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold text-success-green">{results.summary.exactMatches}</p>
<p className="text-xs text-muted-foreground">Exact Matches</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold text-warm-amber">{results.summary.partialMatches}</p>
<p className="text-xs text-muted-foreground">Partial</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold text-success-green">{results.summary.autoConfirmed}</p>
<p className="text-xs text-muted-foreground">Auto-confirmed</p>
</CardContent>
</Card>
</div> </div>
{/* Match table */} {/* Match table */}
<Card> <div className="bg-white border border-gray-200">
<CardHeader> <div className="border-b border-gray-100 px-5 py-3">
<CardTitle className="text-lg">Match Results</CardTitle> <h3 className="text-sm font-bold text-[#111827]">Results</h3>
</CardHeader> </div>
<CardContent>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b text-left"> <tr className="border-b border-gray-100">
<th className="pb-3 font-medium text-muted-foreground">Confidence</th> <th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Match</th>
<th className="pb-3 font-medium text-muted-foreground">Date</th> <th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Date</th>
<th className="pb-3 font-medium text-muted-foreground">Description</th> <th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Description</th>
<th className="pb-3 font-medium text-muted-foreground">Amount</th> <th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Amount</th>
<th className="pb-3 font-medium text-muted-foreground">Matched Pledge</th> <th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Pledge</th>
<th className="pb-3 font-medium text-muted-foreground">Status</th> <th className="px-5 py-2.5 text-left text-[10px] font-bold text-gray-400 uppercase tracking-wide">Status</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y"> <tbody className="divide-y divide-gray-50">
{results.matches.map((m, i) => ( {results.matches.map((m, i) => (
<tr key={i} className="hover:bg-gray-50/50"> <tr key={i} className="hover:bg-gray-50/50">
<td className="py-3">{confidenceIcon(m.confidence)}</td> <td className="px-5 py-3">{confidenceIcon(m.confidence)}</td>
<td className="py-3">{m.bankRow.date}</td> <td className="px-5 py-3 text-xs text-gray-600">{m.bankRow.date}</td>
<td className="py-3 max-w-[200px] truncate">{m.bankRow.description}</td> <td className="px-5 py-3 text-xs text-gray-600 max-w-[200px] truncate">{m.bankRow.description}</td>
<td className="py-3 font-bold">£{m.matchedAmount.toFixed(2)}</td> <td className="px-5 py-3 text-sm font-black text-[#111827]">£{m.matchedAmount.toFixed(2)}</td>
<td className="py-3 font-mono">{m.pledgeReference || "—"}</td> <td className="px-5 py-3 text-xs font-mono text-gray-500">{m.pledgeReference || "—"}</td>
<td className="py-3"> <td className="px-5 py-3">
{m.autoConfirmed ? ( {m.autoConfirmed ? (
<Badge variant="success">Auto-confirmed</Badge> <span className="text-[10px] font-bold px-1.5 py-0.5 bg-[#16A34A]/10 text-[#16A34A]">Auto-confirmed</span>
) : m.confidence === "partial" ? ( ) : m.confidence === "partial" ? (
<Badge variant="warning">Review needed</Badge> <span className="text-[10px] font-bold px-1.5 py-0.5 bg-[#F59E0B]/10 text-[#F59E0B]">Check this</span>
) : m.confidence === "none" ? ( ) : m.confidence === "none" ? (
<Badge variant="outline">No match</Badge> <span className="text-[10px] font-bold px-1.5 py-0.5 bg-gray-100 text-gray-500">No match</span>
) : ( ) : (
<Badge variant="success">Matched</Badge> <span className="text-[10px] font-bold px-1.5 py-0.5 bg-[#16A34A]/10 text-[#16A34A]">Matched</span>
)} )}
</td> </td>
</tr> </tr>
@@ -230,8 +217,7 @@ export default function ReconcilePage() {
</tbody> </tbody>
</table> </table>
</div> </div>
</CardContent> </div>
</Card>
</> </>
)} )}
</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" "use client"
import { useState, useEffect, useCallback } from "react" 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 { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { import {
Building2, CreditCard, Palette, Check, Loader2, AlertCircle, Check, Loader2, AlertCircle,
MessageCircle, Radio, QrCode, RefreshCw, Smartphone, Wifi, WifiOff MessageCircle, Radio, RefreshCw, Smartphone, Wifi, WifiOff, QrCode
} from "lucide-react" } from "lucide-react"
interface OrgSettings { interface OrgSettings {
name: string name: string; bankName: string; bankSortCode: string; bankAccountNo: string
bankName: string bankAccountName: string; refPrefix: string; primaryColor: string
bankSortCode: string gcAccessToken: string; gcEnvironment: string; orgType: string
bankAccountNo: string
bankAccountName: string
refPrefix: string
primaryColor: string
gcAccessToken: string
gcEnvironment: string
orgType: string
} }
export default function SettingsPage() { export default function SettingsPage() {
@@ -33,281 +22,226 @@ export default function SettingsPage() {
useEffect(() => { useEffect(() => {
fetch("/api/settings") fetch("/api/settings")
.then((r) => r.json()) .then(r => r.json())
.then((data) => { if (data.name) setSettings(data) }) .then(data => { if (data.name) setSettings(data) })
.catch(() => setError("Failed to load settings")) .catch(() => setError("Failed to load settings"))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, []) }, [])
const save = async (section: string, data: Record<string, string>) => { const save = async (section: string, data: Record<string, string>) => {
setSaving(section) setSaving(section); setError(null)
setError(null)
try { try {
const res = await fetch("/api/settings", { const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) })
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
if (res.ok) { setSaved(section); setTimeout(() => setSaved(null), 2000) } if (res.ok) { setSaved(section); setTimeout(() => setSaved(null), 2000) }
else setError("Failed to save") else setError("Failed to save")
} catch { setError("Failed to save") } } catch { setError("Failed to save") }
setSaving(null) 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 (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-8 w-8 text-danger-red mx-auto mb-2" /><p className="text-muted-foreground">Failed to load settings</p></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 ( return (
<div className="space-y-6 max-w-2xl"> <div className="space-y-8 max-w-2xl">
<div> <div>
<h1 className="text-2xl font-black text-gray-900">Settings</h1> <h1 className="text-3xl font-black text-[#111827] tracking-tight">Settings</h1>
<p className="text-sm text-muted-foreground mt-0.5">Configure your organisation&apos;s payment details and integrations</p> <p className="text-sm text-gray-500 mt-0.5">Your charity details, bank account, and connections</p>
</div> </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 /> <WhatsAppPanel />
{/* Bank Details */} {/* Bank account */}
<Card> <div className="bg-white border border-gray-200 p-6 space-y-4">
<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>
{/* 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> <div>
<Label className="text-xs">Environment</Label> <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>
{/* 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"> <div className="flex gap-2 mt-1">
{["sandbox", "live"].map((env) => ( {["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"}`}> <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.charAt(0).toUpperCase() + env.slice(1)} {env === "live" && settings.gcEnvironment === "live" && "⚠️"} {env === "sandbox" ? "Test mode" : "Live mode"}
</button> </button>
))} ))}
</div> </div>
</div> </div>
<Button size="sm" onClick={() => save("gc", { gcAccessToken: settings.gcAccessToken, gcEnvironment: settings.gcEnvironment })} disabled={saving === "gc"}> <SaveButton section="gc" data={{ gcAccessToken: settings.gcAccessToken, gcEnvironment: settings.gcEnvironment }} />
{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"} </div>
</Button>
</CardContent>
</Card>
{/* Branding */} {/* Branding */}
<Card> <div className="bg-white border border-gray-200 p-6 space-y-4">
<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> <div>
<Label className="text-xs">Primary Colour</Label> <h3 className="text-base font-bold text-[#111827]">Your charity</h3>
<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> <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>
</div>
<SaveButton section="brand" data={{ name: settings.name, primaryColor: settings.primaryColor }} />
</div> </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> </div>
) )
} }
// ─── WhatsApp Connection Panel ─────────────────────────────────── // ─── WhatsApp Connection Panel ───────────────────────────────
function WhatsAppPanel() { function WhatsAppPanel() {
const [status, setStatus] = useState<string>("loading") const [status, setStatus] = useState<string>("loading")
const [qrImage, setQrImage] = useState<string | null>(null) const [qrImage, setQrImage] = useState<string | null>(null)
const [phone, setPhone] = useState<string>("") const [phone, setPhone] = useState(""); const [pushName, setPushName] = useState("")
const [pushName, setPushName] = useState<string>("") const [starting, setStarting] = useState(false); const [showQr, setShowQr] = useState(false)
const [starting, setStarting] = useState(false)
const [showQr, setShowQr] = useState(false) // only true after user clicks Connect
const checkStatus = useCallback(async () => { const checkStatus = useCallback(async () => {
try { try {
const res = await fetch("/api/whatsapp/qr") const res = await fetch("/api/whatsapp/qr"); const data = await res.json()
const data = await res.json()
setStatus(data.status) setStatus(data.status)
if (data.screenshot) setQrImage(data.screenshot) if (data.screenshot) setQrImage(data.screenshot)
if (data.phone) setPhone(data.phone) if (data.phone) setPhone(data.phone)
if (data.pushName) setPushName(data.pushName) if (data.pushName) setPushName(data.pushName)
// Auto-show QR panel once connected (user paired successfully)
if (data.status === "CONNECTED") setShowQr(false) if (data.status === "CONNECTED") setShowQr(false)
} catch { } catch { setStatus("ERROR") }
setStatus("ERROR")
}
}, []) }, [])
// On mount: just check if already connected. Don't start polling yet.
useEffect(() => { checkStatus() }, [checkStatus]) useEffect(() => { checkStatus() }, [checkStatus])
useEffect(() => { if (!showQr) return; const i = setInterval(checkStatus, 5000); return () => clearInterval(i) }, [showQr, 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])
const startSession = async () => { const startSession = async () => {
setStarting(true) setStarting(true); setShowQr(true)
setShowQr(true) try { await fetch("/api/whatsapp/qr", { method: "POST" }); await new Promise(r => setTimeout(r, 3000)); await checkStatus() } catch { /* */ }
try {
await fetch("/api/whatsapp/qr", { method: "POST" })
await new Promise(r => setTimeout(r, 3000))
await checkStatus()
} catch { /* ignore */ }
setStarting(false) setStarting(false)
} }
if (status === "CONNECTED") { if (status === "CONNECTED") {
return ( return (
<Card className="border-[#25D366]/30 bg-[#25D366]/[0.02]"> <div className="bg-white border border-[#25D366]/30 p-6">
<CardHeader className="pb-3"> <div className="flex items-center gap-2 mb-4">
<CardTitle className="text-base flex items-center gap-2"> <h3 className="text-base font-bold text-[#111827]">WhatsApp</h3>
<MessageCircle className="h-4 w-4 text-[#25D366]" /> WhatsApp <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>
<Badge variant="success" className="gap-1 ml-1"><Radio className="h-2.5 w-2.5" /> Connected</Badge> </div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-[#25D366]/10 flex items-center justify-center"> <div className="w-10 h-10 bg-[#25D366]/10 flex items-center justify-center"><Smartphone className="h-5 w-5 text-[#25D366]" /></div>
<Smartphone className="h-6 w-6 text-[#25D366]" />
</div>
<div> <div>
<p className="font-medium text-sm">{pushName || "WhatsApp Business"}</p> <p className="text-sm font-medium text-[#111827]">{pushName || "WhatsApp"}</p>
<p className="text-xs text-muted-foreground">+{phone}</p> <p className="text-xs text-gray-500">+{phone}</p>
</div>
<div className="ml-auto">
<Wifi className="h-5 w-5 text-[#25D366]" />
</div> </div>
<Wifi className="h-5 w-5 text-[#25D366] ml-auto" />
</div> </div>
<div className="mt-4 pt-3 border-t border-[#25D366]/10 grid grid-cols-3 gap-3"> <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> { label: "Receipts", desc: "Auto-sends when someone pledges" },
<p className="text-xs font-medium">Receipts</p> { 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>
<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>
</div> </div>
</CardContent>
</Card>
) )
} }
if (status === "SCAN_QR_CODE" && showQr) { if (status === "SCAN_QR_CODE" && showQr) {
return ( return (
<Card className="border-warm-amber/30"> <div className="bg-white border border-[#F59E0B]/30 p-6">
<CardHeader className="pb-3"> <div className="flex items-center gap-2 mb-4">
<CardTitle className="text-base flex items-center gap-2"> <h3 className="text-base font-bold text-[#111827]">WhatsApp</h3>
<MessageCircle className="h-4 w-4 text-[#25D366]" /> WhatsApp <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>
<Badge variant="warning" className="gap-1 ml-1"><QrCode className="h-2.5 w-2.5" /> Scan QR</Badge> </div>
</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"> <div className="flex flex-col items-center gap-4">
{qrImage ? ( {qrImage ? (
<div className="relative"> <div className="w-64 h-64 border-2 border-[#25D366]/20 overflow-hidden bg-white">
{/* 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 */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img src={qrImage} alt="WhatsApp QR Code" className="w-[200%] h-auto max-w-none" style={{ marginLeft: "-30%", marginTop: "-35%" }} />
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>
) : ( ) : (
<div className="w-72 h-72 rounded-lg border-2 border-dashed border-muted flex items-center justify-center"> <div className="w-64 h-64 border-2 border-dashed border-gray-200 flex items-center justify-center">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" /> <Loader2 className="h-6 w-6 text-gray-400 animate-spin" />
</div> </div>
)} )}
<div className="text-center space-y-1"> <div className="text-center space-y-1">
<p className="text-sm font-medium">Scan with WhatsApp</p> <p className="text-sm font-bold text-[#111827]">Scan with your phone</p>
<p className="text-xs text-muted-foreground">Open WhatsApp Settings Linked Devices Link a Device</p> <p className="text-xs text-gray-500">Open WhatsApp Settings Linked Devices Link a Device</p>
<p className="text-xs text-muted-foreground">Auto-refreshes every 5 seconds</p> <p className="text-[10px] text-gray-400">Auto-refreshes every 5 seconds</p>
</div> </div>
<Button variant="outline" size="sm" onClick={checkStatus} className="gap-1.5"> <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 <RefreshCw className="h-3 w-3" /> Refresh
</Button> </button>
</div>
</div> </div>
</CardContent>
</Card>
) )
} }
// NO_SESSION or STARTING or ERROR
return ( return (
<Card> <div className="bg-white border border-gray-200 p-6">
<CardHeader className="pb-3"> <div className="flex items-center gap-2 mb-4">
<CardTitle className="text-base flex items-center gap-2"> <h3 className="text-base font-bold text-[#111827]">WhatsApp</h3>
<MessageCircle className="h-4 w-4 text-muted-foreground" /> WhatsApp <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>
<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>
<div> <div className="border-l-2 border-[#25D366] pl-4 space-y-2">
<p className="text-sm font-medium">Connect your WhatsApp number</p> <p className="text-sm font-medium text-[#111827]">Connect your WhatsApp number</p>
<ul className="text-xs text-muted-foreground mt-1 space-y-0.5"> <div className="text-xs text-gray-500 space-y-0.5">
<li>📨 Pledge receipts with bank transfer details</li> <p>When you connect, donors automatically receive:</p>
<li> Automatic reminders (2d before due day 3d after 10d final)</li> <p className="font-medium text-gray-600"> Pledge receipts with bank details</p>
<li>🤖 Donor chatbot: reply PAID, HELP, CANCEL, STATUS</li> <p className="font-medium text-gray-600"> Payment reminders on a 4-step schedule</p>
<li>📊 Volunteer notifications when someone pledges at their table</li> <p className="font-medium text-gray-600"> A chatbot (they reply PAID, HELP, or CANCEL)</p>
</ul> <p className="font-medium text-gray-600"> Volunteer notifications on each pledge</p>
</div> </div>
</div> </div>
<Button onClick={startSession} disabled={starting} className="w-full bg-[#25D366] hover:bg-[#25D366]/90 text-white"> <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 mr-2 animate-spin" /> Starting session...</> : <><MessageCircle className="h-4 w-4 mr-2" /> Connect WhatsApp</>} {starting ? <><Loader2 className="h-4 w-4 animate-spin" /> Starting...</> : <><MessageCircle className="h-4 w-4" /> Connect WhatsApp</>}
</Button> </button>
<p className="text-[10px] text-muted-foreground text-center"> <p className="text-[10px] text-gray-400 text-center mt-2">
Uses WAHA (WhatsApp HTTP API) · No WhatsApp Business API required · Free tier Free no WhatsApp Business API required
</p> </p>
</div> </div>
</CardContent>
</Card>
) )
} }

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'),
];
}
}