- Schema: isConditional, conditionType, conditionText, conditionThreshold, conditionMet, conditionMetAt on Pledge - Pledge form: 'This is a match pledge' toggle after amount selection - Two modes: threshold (if target is reached) and match (match funding) - Goal amount passed through from event - Auto-trigger: when total raised hits threshold, conditional pledges unlock automatically - WhatsApp notification sent to donor when unlocked - Threshold check runs after every pledge creation AND every status change - Cron: skips conditional pledges until conditionMet=true (no premature reminders) - Dashboard Home: progress bar shows conditional segment (amber), stats grid adds Conditional column - Dashboard Money: conditional/unlocked badge on pledge rows - Dashboard Collect: hero shows conditional total in amber - Dashboard Reports: financial summary shows conditional breakdown - Donor 'My Pledges': conditional card with condition text + activation status - Confirmation step: specialized messaging for match pledges - CRM export: includes is_conditional, condition_type, condition_text, condition_met columns - Status guide: conditional status explained in human language
179 lines
6.3 KiB
TypeScript
179 lines
6.3 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server"
|
|
import prisma from "@/lib/prisma"
|
|
import { getOrgId } from "@/lib/session"
|
|
|
|
interface PledgeRow {
|
|
id: string
|
|
reference: string
|
|
amountPence: number
|
|
status: string
|
|
rail: string
|
|
donorName: string | null
|
|
donorEmail: string | null
|
|
donorPhone: string | null
|
|
giftAid: boolean
|
|
dueDate: Date | null
|
|
planId: string | null
|
|
installmentNumber: number | null
|
|
installmentTotal: number | null
|
|
createdAt: Date
|
|
paidAt: Date | null
|
|
event: { name: string }
|
|
qrSource: { label: string; volunteerName: string | null; tableName: string | null } | null
|
|
reminders: Array<{ step: number; status: string; scheduledAt: Date }>
|
|
}
|
|
|
|
interface AnalyticsRow {
|
|
eventType: string
|
|
_count: number
|
|
}
|
|
|
|
interface ReminderRow {
|
|
step: number
|
|
status: string
|
|
scheduledAt: Date
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
if (!prisma) {
|
|
return NextResponse.json({
|
|
summary: {
|
|
totalPledges: 12,
|
|
totalPledgedPence: 2450000,
|
|
totalCollectedPence: 1820000,
|
|
collectionRate: 74,
|
|
overdueRate: 8,
|
|
},
|
|
byStatus: { paid: 8, pending: 2, overdue: 1, cancelled: 1 },
|
|
byRail: { bank_transfer: 10, card: 2 },
|
|
topSources: [
|
|
{ label: "Table 1 - Ahmed", count: 4, amount: 850000 },
|
|
{ label: "Table 2 - Fatima", count: 3, amount: 620000 },
|
|
],
|
|
funnel: { qr_scan: 45, pledge_started: 32, pledge_completed: 12 },
|
|
pledges: [],
|
|
})
|
|
}
|
|
|
|
const orgId = await getOrgId(request.headers.get("x-org-id"))
|
|
if (!orgId) {
|
|
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
|
|
}
|
|
const eventId = request.nextUrl.searchParams.get("eventId")
|
|
|
|
const where = {
|
|
organizationId: orgId,
|
|
...(eventId ? { eventId } : {}),
|
|
}
|
|
|
|
const [pledges, analytics] = await Promise.all([
|
|
prisma.pledge.findMany({
|
|
where,
|
|
include: {
|
|
event: { select: { name: true } },
|
|
qrSource: { select: { label: true, volunteerName: true, tableName: true } },
|
|
reminders: { select: { step: true, status: true, scheduledAt: true } },
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
}),
|
|
prisma.analyticsEvent.groupBy({
|
|
by: ["eventType"],
|
|
where: eventId ? { eventId } : {},
|
|
_count: true,
|
|
}),
|
|
]) as [PledgeRow[], AnalyticsRow[]]
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const confirmedPledges = pledges.filter((p: any) => !p.isConditional || p.conditionMet)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const conditionalPledges = pledges.filter((p: any) => p.isConditional && !p.conditionMet)
|
|
const totalPledged = confirmedPledges.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
|
|
const totalConditional = conditionalPledges.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
|
|
const totalCollected = pledges
|
|
.filter((p: PledgeRow) => p.status === "paid")
|
|
.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
|
|
const collectionRate = totalPledged > 0 ? totalCollected / totalPledged : 0
|
|
const overdueCount = pledges.filter((p: PledgeRow) => p.status === "overdue").length
|
|
const overdueRate = pledges.length > 0 ? overdueCount / pledges.length : 0
|
|
|
|
// Status breakdown
|
|
const byStatus: Record<string, number> = {}
|
|
pledges.forEach((p: PledgeRow) => {
|
|
byStatus[p.status] = (byStatus[p.status] || 0) + 1
|
|
})
|
|
|
|
// Rail breakdown
|
|
const byRail: Record<string, number> = {}
|
|
pledges.forEach((p: PledgeRow) => {
|
|
byRail[p.rail] = (byRail[p.rail] || 0) + 1
|
|
})
|
|
|
|
// Top QR sources
|
|
const qrStats: Record<string, { label: string; count: number; amount: number }> = {}
|
|
pledges.forEach((p: PledgeRow) => {
|
|
if (p.qrSource) {
|
|
const key = p.qrSource.label
|
|
if (!qrStats[key]) qrStats[key] = { label: key, count: 0, amount: 0 }
|
|
qrStats[key].count++
|
|
qrStats[key].amount += p.amountPence
|
|
}
|
|
})
|
|
|
|
// Funnel from analytics
|
|
const funnel = Object.fromEntries(analytics.map((a: AnalyticsRow) => [a.eventType, a._count]))
|
|
|
|
return NextResponse.json({
|
|
summary: {
|
|
totalPledges: pledges.length,
|
|
totalPledgedPence: totalPledged,
|
|
totalCollectedPence: totalCollected,
|
|
totalConditionalPence: totalConditional,
|
|
conditionalCount: conditionalPledges.length,
|
|
collectionRate: Math.round(collectionRate * 100),
|
|
overdueRate: Math.round(overdueRate * 100),
|
|
},
|
|
byStatus,
|
|
byRail,
|
|
topSources: Object.values(qrStats).sort((a: { amount: number }, b: { amount: number }) => b.amount - a.amount).slice(0, 10),
|
|
funnel,
|
|
pledges: pledges.map((p: PledgeRow) => ({
|
|
id: p.id,
|
|
reference: p.reference,
|
|
amountPence: p.amountPence,
|
|
status: p.status,
|
|
rail: p.rail,
|
|
donorName: p.donorName,
|
|
donorEmail: p.donorEmail,
|
|
donorPhone: p.donorPhone,
|
|
eventName: p.event.name,
|
|
source: p.qrSource?.label || null,
|
|
volunteerName: p.qrSource?.volunteerName || null,
|
|
giftAid: p.giftAid,
|
|
dueDate: p.dueDate,
|
|
planId: p.planId,
|
|
installmentNumber: p.installmentNumber,
|
|
installmentTotal: p.installmentTotal,
|
|
isDeferred: !!p.dueDate,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
isConditional: (p as any).isConditional || false,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
conditionText: (p as any).conditionText || null,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
conditionMet: (p as any).conditionMet || false,
|
|
createdAt: p.createdAt,
|
|
paidAt: p.paidAt,
|
|
nextReminder: p.reminders
|
|
.filter((r: ReminderRow) => r.status === "pending")
|
|
.sort((a: ReminderRow, b: ReminderRow) => a.scheduledAt.getTime() - b.scheduledAt.getTime())[0]?.scheduledAt || null,
|
|
lastTouch: p.reminders
|
|
.filter((r: ReminderRow) => r.status === "sent")
|
|
.sort((a: ReminderRow, b: ReminderRow) => b.scheduledAt.getTime() - a.scheduledAt.getTime())[0]?.scheduledAt || null,
|
|
})),
|
|
})
|
|
} catch (error) {
|
|
console.error("Dashboard error:", error)
|
|
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
|
}
|
|
}
|