fundraiser mode: external platforms, role-aware onboarding, show-don't-gate
SCHEMA: - Organization.orgType: 'charity' | 'fundraiser' - Organization.whatsappConnected: boolean - Event.paymentMode: 'self' (bank transfer) | 'external' (redirect to URL) - Event.externalUrl: fundraising page URL - Event.externalPlatform: launchgood, enthuse, justgiving, gofundme, other ONBOARDING (role-aware): - Dashboard shows getting-started banner AT TOP, not full-page blocker - First-time users see role picker: 'Charity/Mosque' vs 'Personal Fundraiser' - POST /api/onboarding sets orgType - Charity checklist: bank details → WhatsApp → create fundraiser → share link - Fundraiser checklist: add fundraising page → WhatsApp → share pledge link → first pledge - WhatsApp is now a core onboarding step for both types - Banner is dismissable via X button - Dashboard always shows stats (with zeros), progress bar, empty-state card SHOW DON'T GATE: - Stats cards show immediately (with zeros, slightly faded) - Collection progress bar always visible - Empty-state card says 'Your pledge data will appear here' - Getting started is a guidance banner, not a lock screen EXTERNAL PAYMENT FLOW: - Events can be paymentMode='external' with externalUrl - Pledge flow: amount → identity → 'Donate on LaunchGood' redirect (skips schedule + payment method) - ExternalRedirectStep: branded per platform (LaunchGood green, Enthuse purple, etc.) - Marks pledge as 'initiated' when donor clicks through - WhatsApp sends donation link instead of bank details - Share button shares the external URL EVENT CREATION: - Payment mode toggle: 'Bank transfer' vs 'External page' - External shows URL input + platform dropdown - Fundraiser orgs default to external mode - Platform badge on event cards PLATFORMS SUPPORTED: 🌙 LaunchGood, 💜 Enthuse, 💛 JustGiving, 💚 GoFundMe, 🔗 Other/Custom
This commit is contained in:
@@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { formatPence } from "@/lib/utils"
|
||||
import { TrendingUp, Users, Banknote, AlertTriangle, Calendar, Clock, CheckCircle2, ArrowRight, Loader2, MessageCircle, ExternalLink, Circle } from "lucide-react"
|
||||
import { TrendingUp, Users, Banknote, AlertTriangle, Calendar, Clock, CheckCircle2, ArrowRight, Loader2, MessageCircle, ExternalLink, Circle, X, Building2, Heart } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
interface DashboardData {
|
||||
@@ -24,14 +24,128 @@ interface DashboardData {
|
||||
}>
|
||||
}
|
||||
|
||||
interface OnboardingData {
|
||||
steps: Array<{ id: string; label: string; desc: string; done: boolean; href: string; action?: string }>
|
||||
completed: number
|
||||
total: number
|
||||
allDone: boolean
|
||||
orgType: string | null
|
||||
needsRole: boolean
|
||||
orgName: string
|
||||
}
|
||||
|
||||
const statusIcons: Record<string, typeof Clock> = { new: Clock, initiated: TrendingUp, paid: CheckCircle2, overdue: AlertTriangle }
|
||||
const statusColors: Record<string, "secondary" | "warning" | "success" | "destructive"> = { new: "secondary", initiated: "warning", paid: "success", overdue: "destructive" }
|
||||
|
||||
// ─── Role Picker ────────────────────────────────────────────
|
||||
function RolePicker({ onSelect }: { onSelect: (role: string) => void }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => onSelect("charity")}
|
||||
className="rounded-2xl 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-xl 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-2xl 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-xl 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
|
||||
onSetRole: (role: string) => void
|
||||
dismissed: boolean
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
const [showRolePicker, setShowRolePicker] = useState(!ob.orgType || ob.orgType === "charity")
|
||||
|
||||
if (ob.allDone || dismissed) return null
|
||||
|
||||
// First-time: show role picker
|
||||
const isFirstTime = ob.completed === 0 && (!ob.orgType || ob.orgType === "charity")
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-trust-blue/20 bg-gradient-to-r from-trust-blue/5 via-white to-warm-amber/5 p-5 space-y-4 relative">
|
||||
{/* Dismiss X */}
|
||||
<button onClick={onDismiss} className="absolute top-3 right-3 text-muted-foreground hover:text-foreground p-1">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-trust-blue to-blue-600 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-white text-lg">🤲</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-gray-900">
|
||||
{isFirstTime ? "Welcome! What best describes you?" : `Getting started · ${ob.completed}/${ob.total}`}
|
||||
</h2>
|
||||
{!isFirstTime && (
|
||||
<Progress value={(ob.completed / ob.total) * 100} className="h-1.5 mt-1.5 w-32" indicatorClassName="bg-gradient-to-r from-trust-blue to-success-green" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFirstTime && showRolePicker ? (
|
||||
<RolePicker onSelect={(role) => { onSetRole(role); setShowRolePicker(false) }} />
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{ob.steps.map((step, i) => {
|
||||
const isNext = !step.done && ob.steps.slice(0, i).every(s => s.done)
|
||||
return (
|
||||
<Link key={step.id} href={step.href}>
|
||||
<div className={`flex items-center gap-2.5 rounded-xl border px-3 py-2.5 transition-all ${
|
||||
step.done ? "bg-success-green/5 border-success-green/20" :
|
||||
isNext ? "bg-trust-blue/5 border-trust-blue/20 shadow-sm" :
|
||||
"bg-white border-gray-100"
|
||||
}`}>
|
||||
{step.done ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-success-green flex-shrink-0" />
|
||||
) : isNext ? (
|
||||
<div className="h-4 w-4 rounded-full bg-trust-blue text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0">{i + 1}</div>
|
||||
) : (
|
||||
<Circle className="h-4 w-4 text-gray-300 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-xs font-medium truncate ${step.done ? "text-success-green line-through" : isNext ? "text-gray-900" : "text-gray-400"}`}>{step.label}</p>
|
||||
</div>
|
||||
{isNext && <ArrowRight className="h-3 w-3 text-trust-blue flex-shrink-0" />}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Dashboard ─────────────────────────────────────────
|
||||
export default function DashboardPage() {
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [whatsappStatus, setWhatsappStatus] = useState<boolean | null>(null)
|
||||
const [onboarding, setOnboarding] = useState<{ steps: Array<{ id: string; label: string; desc: string; done: boolean; href: string }>; completed: number; total: number; allDone: boolean } | null>(null)
|
||||
const [ob, setOb] = useState<OnboardingData | null>(null)
|
||||
const [bannerDismissed, setBannerDismissed] = useState(false)
|
||||
|
||||
const fetchData = useCallback(() => {
|
||||
fetch("/api/dashboard")
|
||||
@@ -44,11 +158,23 @@ export default function DashboardPage() {
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
fetch("/api/whatsapp/send").then(r => r.json()).then(d => setWhatsappStatus(d.connected)).catch(() => {})
|
||||
fetch("/api/onboarding").then(r => r.json()).then(d => { if (d.steps) setOnboarding(d) }).catch(() => {})
|
||||
fetch("/api/onboarding").then(r => r.json()).then(d => { if (d.steps) setOb(d) }).catch(() => {})
|
||||
const interval = setInterval(fetchData, 15000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchData])
|
||||
|
||||
const handleSetRole = async (role: string) => {
|
||||
await fetch("/api/onboarding", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ orgType: role }),
|
||||
})
|
||||
// Refresh onboarding state
|
||||
const res = await fetch("/api/onboarding")
|
||||
const d = await res.json()
|
||||
if (d.steps) setOb(d)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@@ -57,78 +183,31 @@ export default function DashboardPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || (data.summary.totalPledges === 0 && onboarding && !onboarding.allDone)) {
|
||||
// Show getting-started checklist
|
||||
const ob = onboarding
|
||||
return (
|
||||
<div className="max-w-lg mx-auto space-y-6 py-4">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="inline-flex h-14 w-14 rounded-2xl bg-gradient-to-br from-trust-blue to-blue-600 items-center justify-center shadow-lg shadow-trust-blue/20">
|
||||
<span className="text-white text-2xl">🤲</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-black text-gray-900">Let's get you set up</h1>
|
||||
<p className="text-sm text-muted-foreground">4 quick steps, then you're collecting pledges</p>
|
||||
</div>
|
||||
|
||||
{ob && (
|
||||
<>
|
||||
<Progress value={(ob.completed / ob.total) * 100} className="h-2" indicatorClassName="bg-gradient-to-r from-trust-blue to-success-green" />
|
||||
<p className="text-xs text-center text-muted-foreground">{ob.completed} of {ob.total} done</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{ob.steps.map((step, i) => {
|
||||
const isNext = !step.done && ob.steps.slice(0, i).every(s => s.done)
|
||||
return (
|
||||
<Link key={step.id} href={step.href}>
|
||||
<div className={`flex items-center gap-3 rounded-xl border p-4 transition-all ${
|
||||
step.done ? "bg-success-green/5 border-success-green/20" :
|
||||
isNext ? "bg-trust-blue/5 border-trust-blue/20 shadow-sm" :
|
||||
"bg-white border-gray-100"
|
||||
}`}>
|
||||
{step.done ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-success-green flex-shrink-0" />
|
||||
) : isNext ? (
|
||||
<div className="h-5 w-5 rounded-full bg-trust-blue text-white text-xs font-bold flex items-center justify-center flex-shrink-0">{i + 1}</div>
|
||||
) : (
|
||||
<Circle className="h-5 w-5 text-gray-300 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className={`text-sm font-medium ${step.done ? "text-success-green line-through" : isNext ? "text-gray-900" : "text-gray-400"}`}>{step.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{step.desc}</p>
|
||||
</div>
|
||||
{isNext && <ArrowRight className="h-4 w-4 text-trust-blue flex-shrink-0" />}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(!ob || ob.completed === 0) && (
|
||||
<div className="bg-warm-amber/5 rounded-xl border border-warm-amber/20 p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
💡 <strong>Tip:</strong> Add your bank details first — that's the only thing you need before donors can pledge.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const s = data.summary
|
||||
const upcomingPledges = data.pledges.filter(p =>
|
||||
const s = data?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0, overdueRate: 0 }
|
||||
const byStatus = data?.byStatus || {}
|
||||
const topSources = data?.topSources || []
|
||||
const pledges = data?.pledges || []
|
||||
const upcomingPledges = pledges.filter(p =>
|
||||
p.isDeferred && p.dueDate && p.status !== "paid" && p.status !== "cancelled"
|
||||
).sort((a, b) => new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime())
|
||||
const recentPledges = data.pledges.filter(p => p.status !== "cancelled").slice(0, 8)
|
||||
const overduePledges = data.pledges.filter(p => p.status === "overdue")
|
||||
const needsAction = [...overduePledges, ...upcomingPledges.filter(p => {
|
||||
const due = new Date(p.dueDate!)
|
||||
return due.getTime() - Date.now() < 2 * 86400000 // due in 2 days
|
||||
})].slice(0, 5)
|
||||
const recentPledges = pledges.filter(p => p.status !== "cancelled").slice(0, 8)
|
||||
const needsAction = [
|
||||
...pledges.filter(p => p.status === "overdue"),
|
||||
...upcomingPledges.filter(p => {
|
||||
const due = new Date(p.dueDate!)
|
||||
return due.getTime() - Date.now() < 2 * 86400000
|
||||
})
|
||||
].slice(0, 5)
|
||||
|
||||
const isEmpty = s.totalPledges === 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Getting-started banner — always at top, not a blocker */}
|
||||
{ob && !ob.allDone && (
|
||||
<GettingStartedBanner ob={ob} onSetRole={handleSetRole} dismissed={bannerDismissed} onDismiss={() => setBannerDismissed(true)} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-gray-900">Dashboard</h1>
|
||||
@@ -139,17 +218,19 @@ export default function DashboardPage() {
|
||||
{whatsappStatus ? "WhatsApp connected" : "WhatsApp offline"}
|
||||
</span>
|
||||
)}
|
||||
Auto-refreshes every 15s
|
||||
{isEmpty ? "Your data will appear here" : "Auto-refreshes every 15s"}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/dashboard/pledges">
|
||||
<Button variant="outline" size="sm">View All Pledges <ArrowRight className="h-3 w-3 ml-1" /></Button>
|
||||
</Link>
|
||||
{!isEmpty && (
|
||||
<Link href="/dashboard/pledges">
|
||||
<Button variant="outline" size="sm">View All Pledges <ArrowRight className="h-3 w-3 ml-1" /></Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{/* Stats — always show, even with zeros */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Card>
|
||||
<Card className={isEmpty ? "opacity-60" : ""}>
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-trust-blue/10 p-2.5"><Users className="h-5 w-5 text-trust-blue" /></div>
|
||||
@@ -160,7 +241,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card className={isEmpty ? "opacity-60" : ""}>
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-warm-amber/10 p-2.5"><Banknote className="h-5 w-5 text-warm-amber" /></div>
|
||||
@@ -171,7 +252,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card className={isEmpty ? "opacity-60" : ""}>
|
||||
<CardContent className="pt-5 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-success-green/10 p-2.5"><TrendingUp className="h-5 w-5 text-success-green" /></div>
|
||||
@@ -182,12 +263,12 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className={s.overdueRate > 10 ? "border-danger-red/30" : ""}>
|
||||
<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-xl bg-danger-red/10 p-2.5"><AlertTriangle className="h-5 w-5 text-danger-red" /></div>
|
||||
<div>
|
||||
<p className="text-2xl font-black">{data.byStatus.overdue || 0}</p>
|
||||
<p className="text-2xl font-black">{byStatus.overdue || 0}</p>
|
||||
<p className="text-xs text-muted-foreground">Overdue</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -195,8 +276,8 @@ export default function DashboardPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Collection progress */}
|
||||
<Card>
|
||||
{/* 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>
|
||||
@@ -210,159 +291,179 @@ export default function DashboardPage() {
|
||||
</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" />
|
||||
{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'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 Fundraiser →</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</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 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>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{p.donorName || "Anonymous"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{p.eventName}
|
||||
{p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-bold text-sm">{formatPence(p.amountPence)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{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" />
|
||||
</Link>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pipeline + Sources */}
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Pipeline by Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Object.entries(data.byStatus).map(([status, count]) => {
|
||||
const Icon = statusIcons[status] || Clock
|
||||
return (
|
||||
<div key={status} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<Badge variant={statusColors[status] || "secondary"}>{status}</Badge>
|
||||
</div>
|
||||
<span className="font-bold">{count}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Top Sources</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.topSources.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">Create QR codes to track sources</p>
|
||||
) : (
|
||||
data.topSources.slice(0, 6).map((src, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-muted-foreground w-5">{i + 1}</span>
|
||||
<span className="text-sm">{src.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{src.count} pledges</span>
|
||||
</div>
|
||||
<span className="font-bold text-sm">{formatPence(src.amount)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent pledges */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Recent Pledges</CardTitle>
|
||||
<Link href="/dashboard/pledges">
|
||||
<Button variant="ghost" size="sm" className="text-xs">View all <ExternalLink className="h-3 w-3 ml-1" /></Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{recentPledges.map(p => {
|
||||
const sc = statusColors[p.status] || "secondary"
|
||||
return (
|
||||
<div key={p.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-trust-blue/10 flex items-center justify-center text-xs font-bold text-trust-blue">
|
||||
{(p.donorName || "A")[0].toUpperCase()}
|
||||
{/* Upcoming payments */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-trust-blue" /> Upcoming Payments
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{upcomingPledges.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No scheduled payments</p>
|
||||
) : (
|
||||
upcomingPledges.slice(0, 5).map(p => (
|
||||
<div key={p.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-trust-blue/5 flex items-center justify-center text-xs font-bold text-trust-blue">
|
||||
{new Date(p.dueDate!).getDate()}
|
||||
<br />
|
||||
<span className="text-[8px]">{new Date(p.dueDate!).toLocaleDateString("en-GB", { month: "short" })}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{p.donorName || "Anonymous"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{p.eventName}
|
||||
{p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-bold text-sm">{formatPence(p.amountPence)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{p.donorName || "Anonymous"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{p.eventName} · {new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}
|
||||
{p.dueDate && !p.paidAt && ` · Due ${new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`}
|
||||
{p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-sm">{formatPence(p.amountPence)}</span>
|
||||
<Badge variant={sc}>{p.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pipeline + Sources */}
|
||||
<div className="grid lg:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Pipeline by Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Object.entries(byStatus).map(([status, count]) => {
|
||||
const Icon = statusIcons[status] || Clock
|
||||
return (
|
||||
<div key={status} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<Badge variant={statusColors[status] || "secondary"}>{status}</Badge>
|
||||
</div>
|
||||
<span className="font-bold">{count}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Top Sources</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{topSources.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">Create QR codes to track sources</p>
|
||||
) : (
|
||||
topSources.slice(0, 6).map((src, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-muted-foreground w-5">{i + 1}</span>
|
||||
<span className="text-sm">{src.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{src.count} pledges</span>
|
||||
</div>
|
||||
<span className="font-bold text-sm">{formatPence(src.amount)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent pledges */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Recent Pledges</CardTitle>
|
||||
<Link href="/dashboard/pledges">
|
||||
<Button variant="ghost" size="sm" className="text-xs">View all <ExternalLink className="h-3 w-3 ml-1" /></Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{recentPledges.map(p => {
|
||||
const sc = statusColors[p.status] || "secondary"
|
||||
return (
|
||||
<div key={p.id} className="flex items-center justify-between py-2 border-b last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-trust-blue/10 flex items-center justify-center text-xs font-bold text-trust-blue">
|
||||
{(p.donorName || "A")[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{p.donorName || "Anonymous"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{p.eventName} · {new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}
|
||||
{p.dueDate && !p.paidAt && ` · Due ${new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`}
|
||||
{p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-sm">{formatPence(p.amountPence)}</span>
|
||||
<Badge variant={sc}>{p.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user