feat: add improved pi agent with observatory, dashboard, and pledge-now-pay-later

This commit is contained in:
Azreen Jamal
2026-03-01 23:41:24 +08:00
parent ae242436c9
commit f832b913d5
99 changed files with 20949 additions and 74 deletions

View File

@@ -0,0 +1,156 @@
import { NextRequest, NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { resolveOrgId } from "@/lib/org"
interface PledgeRow {
id: string
reference: string
amountPence: number
status: string
rail: string
donorName: string | null
donorEmail: string | null
donorPhone: string | null
giftAid: boolean
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 resolveOrgId(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[]]
const totalPledged = pledges.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,
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,
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 })
}
}