production: reminder cron, dashboard overhaul, shadcn components, setup wizard

- /api/cron/reminders: processes pending reminders every 15min, sends WhatsApp with email fallback
- /api/cron/overdue: marks overdue pledges daily (7d deferred, 14d immediate)
- /api/pledges: GET handler with filtering, search, pagination, sort by dueDate
- Dashboard overview: stats, collection progress bar, needs attention, upcoming payments
- Dashboard pledges: proper table with status tabs, search, actions, pagination
- New shadcn components: Table, Tabs, DropdownMenu, Progress
- Setup wizard: 4-step onboarding (org → bank → event → QR code)
- Settings API: PUT handler for org create/update
- Org resolver: single-tenant fallback to first org
- Cron jobs installed: reminders every 15min, overdue check at 6am
- Auto-generates installment dates when not provided
- HOSTNAME=0.0.0.0 in compose for multi-network binding
This commit is contained in:
2026-03-03 05:11:17 +08:00
parent 250221b530
commit c79b9bcabc
61 changed files with 3547 additions and 534 deletions

View File

@@ -5,6 +5,90 @@ import { generateReference } from "@/lib/reference"
import { calculateReminderSchedule } from "@/lib/reminders"
import { sendPledgeReceipt } from "@/lib/whatsapp"
export async function GET(request: NextRequest) {
try {
if (!prisma) return NextResponse.json({ pledges: [] })
const sp = request.nextUrl.searchParams
const eventId = sp.get("eventId")
const status = sp.get("status")
const limit = parseInt(sp.get("limit") || "50")
const offset = parseInt(sp.get("offset") || "0")
const sort = sp.get("sort") || "createdAt"
const dir = sp.get("dir") === "asc" ? "asc" as const : "desc" as const
const dueSoon = sp.get("dueSoon") === "true" // pledges due in next 7 days
const overdue = sp.get("overdue") === "true"
const search = sp.get("search")
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = {}
if (eventId) where.eventId = eventId
if (status && status !== "all") where.status = status
if (overdue) where.status = "overdue"
if (dueSoon) {
const now = new Date()
const weekFromNow = new Date(now.getTime() + 7 * 86400000)
where.dueDate = { gte: now, lte: weekFromNow }
where.status = { in: ["new", "initiated"] }
}
if (search) {
where.OR = [
{ donorName: { contains: search, mode: "insensitive" } },
{ donorEmail: { contains: search, mode: "insensitive" } },
{ reference: { contains: search, mode: "insensitive" } },
{ donorPhone: { contains: search } },
]
}
const orderBy = sort === "dueDate" ? { dueDate: dir } :
sort === "amountPence" ? { amountPence: dir } :
{ createdAt: dir }
const [pledges, total] = await Promise.all([
prisma.pledge.findMany({
where,
include: {
event: { select: { name: true } },
qrSource: { select: { label: true, volunteerName: true } },
},
orderBy,
take: limit,
skip: offset,
}),
prisma.pledge.count({ where }),
])
return NextResponse.json({
pledges: pledges.map(p => ({
id: p.id,
reference: p.reference,
amountPence: p.amountPence,
status: p.status,
rail: p.rail,
donorName: p.donorName,
donorEmail: p.donorEmail,
donorPhone: p.donorPhone,
giftAid: p.giftAid,
dueDate: p.dueDate,
planId: p.planId,
installmentNumber: p.installmentNumber,
installmentTotal: p.installmentTotal,
eventName: p.event.name,
qrSourceLabel: p.qrSource?.label || null,
volunteerName: p.qrSource?.volunteerName || null,
createdAt: p.createdAt,
paidAt: p.paidAt,
})),
total,
limit,
offset,
})
} catch (error) {
console.error("Pledges GET error:", error)
return NextResponse.json({ pledges: [], total: 0, error: "Failed to load pledges" })
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
@@ -36,7 +120,17 @@ export async function POST(request: NextRequest) {
const org = event.organization
// --- INSTALLMENT MODE: create N linked pledges ---
if (scheduleMode === "installments" && installmentCount && installmentCount > 1 && installmentDates?.length) {
// Auto-generate dates if not provided (1st of each month starting next month)
let resolvedDates = installmentDates
if (scheduleMode === "installments" && installmentCount && installmentCount > 1 && !resolvedDates?.length) {
resolvedDates = []
const now = new Date()
for (let i = 0; i < installmentCount; i++) {
const d = new Date(now.getFullYear(), now.getMonth() + 1 + i, 1)
resolvedDates.push(d.toISOString().split("T")[0])
}
}
if (scheduleMode === "installments" && installmentCount && installmentCount > 1 && resolvedDates?.length) {
const perInstallment = Math.ceil(amountPence / installmentCount)
const planId = `plan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
let firstRef = ""
@@ -54,7 +148,7 @@ export async function POST(request: NextRequest) {
}
if (i === 0) firstRef = ref
const installmentDue = new Date(installmentDates[i])
const installmentDue = new Date(resolvedDates[i])
const p = await tx.pledge.create({
data: {
@@ -99,7 +193,7 @@ export async function POST(request: NextRequest) {
const name = donorName?.split(" ")[0] || "there"
const { sendWhatsAppMessage } = await import("@/lib/whatsapp")
sendWhatsAppMessage(donorPhone,
`🤲 *Pledge Confirmed!*\n\nThank you, ${name}!\n\n💷 *£${(amountPence / 100).toFixed(0)}* pledged to *${event.name}*\n📆 *${installmentCount} monthly payments* of *£${(perInstallment / 100).toFixed(0)}*\n\nFirst payment: ${new Date(installmentDates[0]).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}\n\nWe'll send you payment details before each due date.\n\nReply *STATUS* anytime to see your pledges.`
`🤲 *Pledge Confirmed!*\n\nThank you, ${name}!\n\n💷 *£${(amountPence / 100).toFixed(0)}* pledged to *${event.name}*\n📆 *${installmentCount} monthly payments* of *£${(perInstallment / 100).toFixed(0)}*\n\nFirst payment: ${new Date(resolvedDates[0]).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}\n\nWe'll send you payment details before each due date.\n\nReply *STATUS* anytime to see your pledges.`
).catch(err => console.error("[WAHA] Installment receipt failed:", err))
}