feat: conditional & match funding pledges — deeply integrated across entire product
- 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
This commit is contained in:
@@ -135,6 +135,16 @@ model Pledge {
|
|||||||
iPaidClickedAt DateTime?
|
iPaidClickedAt DateTime?
|
||||||
notes String?
|
notes String?
|
||||||
|
|
||||||
|
// --- Conditional / Match Funding ---
|
||||||
|
// A conditional pledge activates only when a condition is met.
|
||||||
|
// e.g. "I'll give £5,000 if you raise £20,000" or "I'll match up to £5,000"
|
||||||
|
isConditional Boolean @default(false)
|
||||||
|
conditionType String? // "threshold" | "match" | "custom"
|
||||||
|
conditionText String? // Human-readable: "If £20,000 is raised"
|
||||||
|
conditionThreshold Int? // pence — when event total reaches this, condition is met
|
||||||
|
conditionMet Boolean @default(false)
|
||||||
|
conditionMetAt DateTime?
|
||||||
|
|
||||||
// Payment scheduling — the core of "pledge now, pay later"
|
// Payment scheduling — the core of "pledge now, pay later"
|
||||||
dueDate DateTime? // null = pay now, set = promise to pay on this date
|
dueDate DateTime? // null = pay now, set = promise to pay on this date
|
||||||
planId String? // groups installments together
|
planId String? // groups installments together
|
||||||
|
|||||||
BIN
pledge-now-pay-later/screenshots/collect-new.png
Normal file
BIN
pledge-now-pay-later/screenshots/collect-new.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
BIN
pledge-now-pay-later/screenshots/collect-with-data.png
Normal file
BIN
pledge-now-pay-later/screenshots/collect-with-data.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
@@ -46,6 +46,11 @@ export async function GET(request: NextRequest) {
|
|||||||
dueDate: { gte: todayStart, lt: todayEnd },
|
dueDate: { gte: todayStart, lt: todayEnd },
|
||||||
reminderSentForDueDate: false,
|
reminderSentForDueDate: false,
|
||||||
status: { notIn: ["paid", "cancelled"] },
|
status: { notIn: ["paid", "cancelled"] },
|
||||||
|
// Don't send reminders for conditional pledges that haven't been met
|
||||||
|
OR: [
|
||||||
|
{ isConditional: false },
|
||||||
|
{ isConditional: true, conditionMet: true },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
event: {
|
event: {
|
||||||
@@ -185,6 +190,11 @@ export async function GET(request: NextRequest) {
|
|||||||
scheduledAt: { lte: now },
|
scheduledAt: { lte: now },
|
||||||
pledge: {
|
pledge: {
|
||||||
status: { notIn: ["paid", "cancelled"] },
|
status: { notIn: ["paid", "cancelled"] },
|
||||||
|
// Don't send reminders for unmet conditional pledges
|
||||||
|
OR: [
|
||||||
|
{ isConditional: false },
|
||||||
|
{ isConditional: true, conditionMet: true },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -84,7 +84,12 @@ export async function GET(request: NextRequest) {
|
|||||||
}),
|
}),
|
||||||
]) as [PledgeRow[], AnalyticsRow[]]
|
]) as [PledgeRow[], AnalyticsRow[]]
|
||||||
|
|
||||||
const totalPledged = pledges.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
|
// 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
|
const totalCollected = pledges
|
||||||
.filter((p: PledgeRow) => p.status === "paid")
|
.filter((p: PledgeRow) => p.status === "paid")
|
||||||
.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
|
.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0)
|
||||||
@@ -123,6 +128,8 @@ export async function GET(request: NextRequest) {
|
|||||||
totalPledges: pledges.length,
|
totalPledges: pledges.length,
|
||||||
totalPledgedPence: totalPledged,
|
totalPledgedPence: totalPledged,
|
||||||
totalCollectedPence: totalCollected,
|
totalCollectedPence: totalCollected,
|
||||||
|
totalConditionalPence: totalConditional,
|
||||||
|
conditionalCount: conditionalPledges.length,
|
||||||
collectionRate: Math.round(collectionRate * 100),
|
collectionRate: Math.round(collectionRate * 100),
|
||||||
overdueRate: Math.round(overdueRate * 100),
|
overdueRate: Math.round(overdueRate * 100),
|
||||||
},
|
},
|
||||||
@@ -148,6 +155,12 @@ export async function GET(request: NextRequest) {
|
|||||||
installmentNumber: p.installmentNumber,
|
installmentNumber: p.installmentNumber,
|
||||||
installmentTotal: p.installmentTotal,
|
installmentTotal: p.installmentTotal,
|
||||||
isDeferred: !!p.dueDate,
|
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,
|
createdAt: p.createdAt,
|
||||||
paidAt: p.paidAt,
|
paidAt: p.paidAt,
|
||||||
nextReminder: p.reminders
|
nextReminder: p.reminders
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export async function GET(request: NextRequest) {
|
|||||||
include: {
|
include: {
|
||||||
_count: { select: { pledges: true, qrSources: true } },
|
_count: { select: { pledges: true, qrSources: true } },
|
||||||
pledges: {
|
pledges: {
|
||||||
select: { amountPence: true, status: true },
|
select: { amountPence: true, status: true, isConditional: true, conditionMet: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
@@ -57,7 +57,12 @@ export async function GET(request: NextRequest) {
|
|||||||
externalUrl: e.externalUrl || null,
|
externalUrl: e.externalUrl || null,
|
||||||
pledgeCount: e._count.pledges,
|
pledgeCount: e._count.pledges,
|
||||||
qrSourceCount: e._count.qrSources,
|
qrSourceCount: e._count.qrSources,
|
||||||
totalPledged: e.pledges.reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0),
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
totalPledged: e.pledges.filter((p: any) => !p.isConditional || p.conditionMet).reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
totalConditional: e.pledges.filter((p: any) => p.isConditional && !p.conditionMet).reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
conditionalCount: e.pledges.filter((p: any) => p.isConditional && !p.conditionMet).length,
|
||||||
totalCollected: e.pledges
|
totalCollected: e.pledges
|
||||||
.filter((p: PledgeSummary) => p.status === "paid")
|
.filter((p: PledgeSummary) => p.status === "paid")
|
||||||
.reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0),
|
.reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0),
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ export async function GET(request: NextRequest) {
|
|||||||
days_to_collect: p.paidAt
|
days_to_collect: p.paidAt
|
||||||
? Math.ceil((p.paidAt.getTime() - p.createdAt.getTime()) / (1000 * 60 * 60 * 24)).toString()
|
? Math.ceil((p.paidAt.getTime() - p.createdAt.getTime()) / (1000 * 60 * 60 * 24)).toString()
|
||||||
: "",
|
: "",
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
is_conditional: (p as any).isConditional ? "Yes" : "No",
|
||||||
|
condition_type: (p as any).conditionType || "",
|
||||||
|
condition_text: (p as any).conditionText || "",
|
||||||
|
condition_met: (p as any).conditionMet ? "Yes" : "No",
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const csv = formatCrmExportCsv(rows)
|
const csv = formatCrmExportCsv(rows)
|
||||||
|
|||||||
@@ -108,6 +108,35 @@ export async function PATCH(
|
|||||||
} catch { /* conversion tracking is best-effort */ }
|
} catch { /* conversion tracking is best-effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-check conditional thresholds when pledge status changes
|
||||||
|
// (paid pledges increase total, potentially unlocking conditional pledges)
|
||||||
|
if (parsed.data.status === "paid" || parsed.data.status === "cancelled") {
|
||||||
|
try {
|
||||||
|
// Import checkConditionalThresholds dynamically — it lives in the pledges route
|
||||||
|
// but we'll inline the logic here for simplicity
|
||||||
|
const result = await prisma.pledge.aggregate({
|
||||||
|
where: { eventId: existing.eventId, status: { notIn: ["cancelled"] }, isConditional: false },
|
||||||
|
_sum: { amountPence: true },
|
||||||
|
})
|
||||||
|
const totalRaised = result._sum.amountPence || 0
|
||||||
|
const unmet = await prisma.pledge.findMany({
|
||||||
|
where: { eventId: existing.eventId, isConditional: true, conditionMet: false, conditionThreshold: { not: null, lte: totalRaised }, status: { notIn: ["cancelled"] } },
|
||||||
|
})
|
||||||
|
const now = new Date()
|
||||||
|
for (const cp of unmet) {
|
||||||
|
await prisma.pledge.update({ where: { id: cp.id }, data: { conditionMet: true, conditionMetAt: now } })
|
||||||
|
if (cp.donorPhone) {
|
||||||
|
const { sendWhatsAppMessage } = await import("@/lib/whatsapp")
|
||||||
|
const ev = await prisma.event.findUnique({ where: { id: existing.eventId }, select: { name: true } })
|
||||||
|
sendWhatsAppMessage(cp.donorPhone,
|
||||||
|
`Assalamu Alaikum ${cp.donorName?.split(" ")[0] || "there"} 🎉\n\nGreat news — *${ev?.name || "the appeal"}* has reached its target!\n\nYour pledge of *£${(cp.amountPence / 100).toFixed(0)}* is now active.\n\nReference: \`${cp.reference}\`\n\nReply *HELP* for payment details.`
|
||||||
|
).catch(() => {})
|
||||||
|
}
|
||||||
|
console.log(`[CONDITIONAL] Unlocked ${cp.reference} — threshold met`)
|
||||||
|
}
|
||||||
|
} catch (err) { console.error("[CONDITIONAL] Threshold check failed:", err) }
|
||||||
|
}
|
||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
const changes = Object.keys(updateData).filter(k => k !== "paidAt" && k !== "cancelledAt")
|
const changes = Object.keys(updateData).filter(k => k !== "paidAt" && k !== "cancelledAt")
|
||||||
await logActivity({
|
await logActivity({
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ export async function GET(request: NextRequest) {
|
|||||||
volunteerName: p.qrSource?.volunteerName || null,
|
volunteerName: p.qrSource?.volunteerName || null,
|
||||||
createdAt: p.createdAt,
|
createdAt: p.createdAt,
|
||||||
paidAt: p.paidAt,
|
paidAt: p.paidAt,
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
isConditional: (p as any).isConditional || false,
|
||||||
|
conditionType: (p as any).conditionType || null,
|
||||||
|
conditionText: (p as any).conditionText || null,
|
||||||
|
conditionThreshold: (p as any).conditionThreshold || null,
|
||||||
|
conditionMet: (p as any).conditionMet || false,
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
})),
|
})),
|
||||||
total,
|
total,
|
||||||
limit,
|
limit,
|
||||||
@@ -117,7 +124,7 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { amountPence, rail, donorName, donorEmail, donorPhone, donorAddressLine1, donorPostcode, giftAid, isZakat, emailOptIn, whatsappOptIn, consentMeta, eventId, qrSourceId, scheduleMode, dueDate, installmentCount, installmentDates } = parsed.data
|
const { amountPence, rail, donorName, donorEmail, donorPhone, donorAddressLine1, donorPostcode, giftAid, isZakat, emailOptIn, whatsappOptIn, consentMeta, eventId, qrSourceId, scheduleMode, dueDate, installmentCount, installmentDates, isConditional, conditionType, conditionText, conditionThreshold } = parsed.data
|
||||||
|
|
||||||
// Capture IP for consent audit trail
|
// Capture IP for consent audit trail
|
||||||
const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||||
@@ -266,6 +273,13 @@ export async function POST(request: NextRequest) {
|
|||||||
qrSourceId: qrSourceId || null,
|
qrSourceId: qrSourceId || null,
|
||||||
organizationId: org.id,
|
organizationId: org.id,
|
||||||
dueDate: parsedDueDate,
|
dueDate: parsedDueDate,
|
||||||
|
// Conditional / match funding
|
||||||
|
isConditional: isConditional || false,
|
||||||
|
conditionType: isConditional ? (conditionType || "custom") : null,
|
||||||
|
conditionText: isConditional ? (conditionText || null) : null,
|
||||||
|
conditionThreshold: isConditional ? (conditionThreshold || null) : null,
|
||||||
|
conditionMet: false,
|
||||||
|
// Conditional pledges start as "new" but reminders won't fire until conditionMet
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -372,9 +386,75 @@ export async function POST(request: NextRequest) {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── AUTO-TRIGGER: Check if any conditional pledges' thresholds are now met ──
|
||||||
|
// After every new pledge, calculate total raised for this event
|
||||||
|
// and unlock any conditional pledges whose threshold has been reached.
|
||||||
|
if (prisma) {
|
||||||
|
checkConditionalThresholds(eventId).catch(err =>
|
||||||
|
console.error("[CONDITIONAL] Threshold check failed:", err)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(response, { status: 201 })
|
return NextResponse.json(response, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Pledge creation error:", error)
|
console.error("Pledge creation error:", error)
|
||||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any conditional pledges for this event have had their threshold met.
|
||||||
|
* If total raised (non-conditional, non-cancelled pledges) >= threshold, unlock them.
|
||||||
|
*/
|
||||||
|
async function checkConditionalThresholds(eventId: string) {
|
||||||
|
if (!prisma) return
|
||||||
|
|
||||||
|
// Get all non-cancelled, non-conditional pledge totals for this event
|
||||||
|
const result = await prisma.pledge.aggregate({
|
||||||
|
where: {
|
||||||
|
eventId,
|
||||||
|
status: { notIn: ["cancelled"] },
|
||||||
|
isConditional: false,
|
||||||
|
},
|
||||||
|
_sum: { amountPence: true },
|
||||||
|
})
|
||||||
|
const totalRaised = result._sum.amountPence || 0
|
||||||
|
|
||||||
|
// Find conditional pledges with threshold that's now met
|
||||||
|
const unmetConditionals = await prisma.pledge.findMany({
|
||||||
|
where: {
|
||||||
|
eventId,
|
||||||
|
isConditional: true,
|
||||||
|
conditionMet: false,
|
||||||
|
conditionThreshold: { not: null, lte: totalRaised },
|
||||||
|
status: { notIn: ["cancelled"] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (unmetConditionals.length === 0) return
|
||||||
|
|
||||||
|
// Unlock them
|
||||||
|
const now = new Date()
|
||||||
|
for (const p of unmetConditionals) {
|
||||||
|
await prisma.pledge.update({
|
||||||
|
where: { id: p.id },
|
||||||
|
data: { conditionMet: true, conditionMetAt: now },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send activation WhatsApp if they have a phone
|
||||||
|
if (p.donorPhone) {
|
||||||
|
try {
|
||||||
|
const { sendWhatsAppMessage } = await import("@/lib/whatsapp")
|
||||||
|
const name = p.donorName?.split(" ")[0] || "there"
|
||||||
|
const amount = (p.amountPence / 100).toFixed(0)
|
||||||
|
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { name: true } })
|
||||||
|
await sendWhatsAppMessage(p.donorPhone,
|
||||||
|
`Assalamu Alaikum ${name} 🎉\n\nGreat news — *${event?.name || "the appeal"}* has reached its target!\n\nYour pledge of *£${amount}* is now active.\n\nReference: \`${p.reference}\`\n\nReply *HELP* for payment details.`
|
||||||
|
)
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[CONDITIONAL] Unlocked pledge ${p.reference} (£${(p.amountPence / 100).toFixed(0)}) — threshold £${((p.conditionThreshold || 0) / 100).toFixed(0)} met (total: £${(totalRaised / 100).toFixed(0)})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export async function GET(
|
|||||||
externalPlatform: event.externalPlatform || null,
|
externalPlatform: event.externalPlatform || null,
|
||||||
zakatEligible: event.zakatEligible || false,
|
zakatEligible: event.zakatEligible || false,
|
||||||
hasStripe: !!event.organization.stripeSecretKey,
|
hasStripe: !!event.organization.stripeSecretKey,
|
||||||
|
goalAmount: event.goalAmount || null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ export async function GET(
|
|||||||
externalPlatform: qrSource.event.externalPlatform || null,
|
externalPlatform: qrSource.event.externalPlatform || null,
|
||||||
zakatEligible: qrSource.event.zakatEligible || false,
|
zakatEligible: qrSource.event.zakatEligible || false,
|
||||||
hasStripe: !!qrSource.event.organization.stripeSecretKey,
|
hasStripe: !!qrSource.event.organization.stripeSecretKey,
|
||||||
|
goalAmount: qrSource.event.goalAmount || null,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("QR resolve error:", error)
|
console.error("QR resolve error:", error)
|
||||||
|
|||||||
@@ -211,6 +211,8 @@ export default function CollectPage() {
|
|||||||
// Stats
|
// Stats
|
||||||
const totalPledges = events.reduce((s, e) => s + e.pledgeCount, 0)
|
const totalPledges = events.reduce((s, e) => s + e.pledgeCount, 0)
|
||||||
const totalPledged = events.reduce((s, e) => s + e.totalPledged, 0)
|
const totalPledged = events.reduce((s, e) => s + e.totalPledged, 0)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const totalConditional = events.reduce((s, e) => s + ((e as any).totalConditional || 0), 0)
|
||||||
// Sort sources by pledges
|
// Sort sources by pledges
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const sortedSources = [...sources].sort((a: any, b: any) => b.totalPledged - a.totalPledged)
|
const sortedSources = [...sources].sort((a: any, b: any) => b.totalPledged - a.totalPledged)
|
||||||
@@ -548,8 +550,14 @@ export default function CollectPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl md:text-3xl font-black text-[#4ADE80] tracking-tight">{formatPence(totalPledged)}</p>
|
<p className="text-2xl md:text-3xl font-black text-[#4ADE80] tracking-tight">{formatPence(totalPledged)}</p>
|
||||||
<p className="text-[10px] text-gray-500 mt-0.5">raised</p>
|
<p className="text-[10px] text-gray-500 mt-0.5">confirmed</p>
|
||||||
</div>
|
</div>
|
||||||
|
{totalConditional > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl md:text-3xl font-black text-[#FBBF24] tracking-tight">{formatPence(totalConditional)}</p>
|
||||||
|
<p className="text-[10px] text-gray-500 mt-0.5">conditional</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] 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>
|
||||||
|
|
||||||
const s = dash?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0 }
|
const s = dash?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, totalConditionalPence: 0, conditionalCount: 0, collectionRate: 0 }
|
||||||
const byStatus = dash?.byStatus || {}
|
const byStatus = dash?.byStatus || {}
|
||||||
const outstanding = s.totalPledgedPence - s.totalCollectedPence
|
const outstanding = s.totalPledgedPence - s.totalCollectedPence
|
||||||
const giftAidPledges = (dash?.pledges || []).filter((p: { giftAid: boolean; status: string }) => p.giftAid && p.status === "paid")
|
const giftAidPledges = (dash?.pledges || []).filter((p: { giftAid: boolean; status: string }) => p.giftAid && p.status === "paid")
|
||||||
@@ -111,9 +111,10 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
{/* ── Financial breakdown ── */}
|
{/* ── Financial breakdown ── */}
|
||||||
<div className="bg-[#111827] p-6">
|
<div className="bg-[#111827] p-6">
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-px bg-gray-700">
|
<div className={`grid grid-cols-2 ${s.totalConditionalPence > 0 ? "lg:grid-cols-5" : "lg:grid-cols-4"} gap-px bg-gray-700`}>
|
||||||
{[
|
{[
|
||||||
{ value: formatPence(s.totalPledgedPence), label: "Total promised", color: "text-white" },
|
{ value: formatPence(s.totalPledgedPence), label: "Confirmed pledges", color: "text-white" },
|
||||||
|
...(s.totalConditionalPence > 0 ? [{ value: formatPence(s.totalConditionalPence), label: `Conditional (${s.conditionalCount})`, color: "text-[#FBBF24]" }] : []),
|
||||||
{ value: formatPence(s.totalCollectedPence), label: "Total received", color: "text-[#4ADE80]" },
|
{ value: formatPence(s.totalCollectedPence), label: "Total received", color: "text-[#4ADE80]" },
|
||||||
{ value: formatPence(outstanding), label: "Still outstanding", color: outstanding > 0 ? "text-[#FBBF24]" : "text-white" },
|
{ value: formatPence(outstanding), label: "Still outstanding", color: outstanding > 0 ? "text-[#FBBF24]" : "text-white" },
|
||||||
{ value: `${s.collectionRate}%`, label: "Collection rate", color: s.collectionRate >= 70 ? "text-[#4ADE80]" : "text-[#FBBF24]" },
|
{ value: `${s.collectionRate}%`, label: "Collection rate", color: s.collectionRate >= 70 ? "text-[#4ADE80]" : "text-[#FBBF24]" },
|
||||||
|
|||||||
@@ -22,9 +22,16 @@ const STATUS_LABELS: Record<string, { label: string; color: string; bg: string }
|
|||||||
initiated: { label: "Said they paid", color: "text-[#F59E0B]", bg: "bg-[#F59E0B]/10" },
|
initiated: { label: "Said they paid", color: "text-[#F59E0B]", bg: "bg-[#F59E0B]/10" },
|
||||||
paid: { label: "Received ✓", color: "text-[#16A34A]", bg: "bg-[#16A34A]/10" },
|
paid: { label: "Received ✓", color: "text-[#16A34A]", bg: "bg-[#16A34A]/10" },
|
||||||
overdue: { label: "Needs a nudge", color: "text-[#DC2626]", bg: "bg-[#DC2626]/10" },
|
overdue: { label: "Needs a nudge", color: "text-[#DC2626]", bg: "bg-[#DC2626]/10" },
|
||||||
|
conditional:{ label: "🤝 Conditional", color: "text-[#F59E0B]", bg: "bg-[#F59E0B]/10" },
|
||||||
cancelled: { label: "Cancelled", color: "text-gray-400", bg: "bg-gray-50" },
|
cancelled: { label: "Cancelled", color: "text-gray-400", bg: "bg-gray-50" },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function getStatusLabel(p: any) {
|
||||||
|
if (p.isConditional && !p.conditionMet) return STATUS_LABELS.conditional
|
||||||
|
return STATUS_LABELS[p.status] || STATUS_LABELS.new
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
@@ -96,7 +103,7 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = data?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0 }
|
const s = data?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, totalConditionalPence: 0, conditionalCount: 0, collectionRate: 0 }
|
||||||
const byStatus = data?.byStatus || {}
|
const byStatus = data?.byStatus || {}
|
||||||
const pledges = data?.pledges || []
|
const pledges = data?.pledges || []
|
||||||
const topSources = data?.topSources || []
|
const topSources = data?.topSources || []
|
||||||
@@ -278,10 +285,11 @@ export default function DashboardPage() {
|
|||||||
{!isEmpty && (
|
{!isEmpty && (
|
||||||
<>
|
<>
|
||||||
{/* Stats — gap-px grid */}
|
{/* Stats — gap-px grid */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-px bg-gray-200">
|
<div className={`grid grid-cols-2 ${s.totalConditionalPence > 0 ? "lg:grid-cols-5" : "lg:grid-cols-4"} gap-px bg-gray-200`}>
|
||||||
{[
|
{[
|
||||||
{ value: String(s.totalPledges), label: "Pledges" },
|
{ value: String(s.totalPledges), label: "Pledges" },
|
||||||
{ value: formatPence(s.totalPledgedPence), label: "Promised" },
|
{ value: formatPence(s.totalPledgedPence), label: "Confirmed" },
|
||||||
|
...(s.totalConditionalPence > 0 ? [{ value: formatPence(s.totalConditionalPence), label: `Conditional (${s.conditionalCount})`, accent: "text-[#F59E0B]" }] : []),
|
||||||
{ value: formatPence(s.totalCollectedPence), label: "Received", accent: "text-[#16A34A]" },
|
{ value: formatPence(s.totalCollectedPence), label: "Received", accent: "text-[#16A34A]" },
|
||||||
{ value: `${s.collectionRate}%`, label: "Collected" },
|
{ value: `${s.collectionRate}%`, label: "Collected" },
|
||||||
].map(stat => (
|
].map(stat => (
|
||||||
@@ -292,18 +300,26 @@ export default function DashboardPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress bar — with conditional segment */}
|
||||||
<div className="bg-white p-5">
|
<div className="bg-white p-5">
|
||||||
<div className="flex justify-between items-center mb-3">
|
<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-bold text-[#111827]">Promised → Received</span>
|
||||||
<span className="text-sm font-black text-[#111827]">{s.collectionRate}%</span>
|
<span className="text-sm font-black text-[#111827]">{s.collectionRate}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-3 bg-gray-100 overflow-hidden">
|
<div className="h-3 bg-gray-100 overflow-hidden flex">
|
||||||
<div className="h-full bg-[#1E40AF] transition-all duration-700" style={{ width: `${s.collectionRate}%` }} />
|
<div className="h-full bg-[#1E40AF] transition-all duration-700" style={{ width: `${s.collectionRate}%` }} />
|
||||||
|
{s.totalConditionalPence > 0 && (
|
||||||
|
<div className="h-full bg-[#F59E0B]/30 transition-all duration-700 border-l border-white"
|
||||||
|
style={{ width: `${Math.round((s.totalConditionalPence / (s.totalPledgedPence + s.totalConditionalPence)) * 100)}%` }}
|
||||||
|
title={`${formatPence(s.totalConditionalPence)} conditional — unlocks when conditions are met`} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between mt-2 text-xs text-gray-500">
|
<div className="flex justify-between mt-2 text-xs text-gray-500">
|
||||||
<span>{formatPence(s.totalCollectedPence)} received</span>
|
<span>{formatPence(s.totalCollectedPence)} received</span>
|
||||||
<span>{formatPence(outstanding)} still to come</span>
|
<span>{formatPence(outstanding)} still to come</span>
|
||||||
|
{s.totalConditionalPence > 0 && (
|
||||||
|
<span className="text-[#F59E0B] font-bold">+ {formatPence(s.totalConditionalPence)} conditional</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -332,8 +348,8 @@ export default function DashboardPage() {
|
|||||||
<span className="text-[10px] font-bold text-white bg-[#DC2626] px-1.5 py-0.5">{needsAttention.length}</span>
|
<span className="text-[10px] font-bold text-white bg-[#DC2626] px-1.5 py-0.5">{needsAttention.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-gray-50">
|
<div className="divide-y divide-gray-50">
|
||||||
{needsAttention.map((p: { id: string; donorName: string | null; amountPence: number; eventName: string; status: string }) => {
|
{needsAttention.map((p: { id: string; donorName: string | null; amountPence: number; eventName: string; status: string; isConditional?: boolean; conditionMet?: boolean }) => {
|
||||||
const sl = STATUS_LABELS[p.status] || STATUS_LABELS.new
|
const sl = getStatusLabel(p)
|
||||||
return (
|
return (
|
||||||
<div key={p.id} className="px-5 py-3 flex items-center justify-between">
|
<div key={p.id} className="px-5 py-3 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -362,8 +378,9 @@ export default function DashboardPage() {
|
|||||||
id: string; donorName: string | null; amountPence: number; status: string;
|
id: string; donorName: string | null; amountPence: number; status: string;
|
||||||
eventName: string; createdAt: string; donorPhone: string | null;
|
eventName: string; createdAt: string; donorPhone: string | null;
|
||||||
installmentNumber: number | null; installmentTotal: number | null;
|
installmentNumber: number | null; installmentTotal: number | null;
|
||||||
|
isConditional?: boolean; conditionMet?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const sl = STATUS_LABELS[p.status] || STATUS_LABELS.new
|
const sl = getStatusLabel(p)
|
||||||
const initial = (p.donorName || "A")[0].toUpperCase()
|
const initial = (p.donorName || "A")[0].toUpperCase()
|
||||||
const days = Math.floor((Date.now() - new Date(p.createdAt).getTime()) / 86400000)
|
const days = Math.floor((Date.now() - new Date(p.createdAt).getTime()) / 86400000)
|
||||||
const when = days === 0 ? "Today" : days === 1 ? "Yesterday" : days < 7 ? `${days}d ago` : new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })
|
const when = days === 0 ? "Today" : days === 1 ? "Yesterday" : days < 7 ? `${days}d ago` : new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })
|
||||||
@@ -479,6 +496,7 @@ export default function DashboardPage() {
|
|||||||
{ label: "Said they paid", desc: "Donor replied PAID — upload bank statement to confirm" },
|
{ label: "Said they paid", desc: "Donor replied PAID — upload bank statement to confirm" },
|
||||||
{ label: "Received ✓", desc: "Payment confirmed in your bank account" },
|
{ label: "Received ✓", desc: "Payment confirmed in your bank account" },
|
||||||
{ label: "Needs a nudge", desc: "It's been a while — you can send a manual reminder" },
|
{ label: "Needs a nudge", desc: "It's been a while — you can send a manual reminder" },
|
||||||
|
{ label: "🤝 Conditional", desc: "Match or conditional pledge — activates when the target is reached" },
|
||||||
].map(s => (
|
].map(s => (
|
||||||
<div key={s.label} className="flex items-start gap-2">
|
<div key={s.label} className="flex items-start gap-2">
|
||||||
<span className="w-1.5 h-1.5 bg-[#1E40AF] shrink-0 mt-1.5" />
|
<span className="w-1.5 h-1.5 bg-[#1E40AF] shrink-0 mt-1.5" />
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ interface Pledge {
|
|||||||
installmentNumber: number | null; installmentTotal: number | null
|
installmentNumber: number | null; installmentTotal: number | null
|
||||||
eventName: string; qrSourceLabel: string | null; volunteerName: string | null
|
eventName: string; qrSourceLabel: string | null; volunteerName: string | null
|
||||||
createdAt: string; paidAt: string | null
|
createdAt: string; paidAt: string | null
|
||||||
|
// Conditional / match funding
|
||||||
|
isConditional: boolean; conditionText: string | null; conditionMet: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MatchResult {
|
interface MatchResult {
|
||||||
@@ -662,8 +664,14 @@ export default function MoneyPage() {
|
|||||||
<p className="text-xs text-gray-600 truncate">{p.eventName}</p>
|
<p className="text-xs text-gray-600 truncate">{p.eventName}</p>
|
||||||
{p.qrSourceLabel && <p className="text-[10px] text-gray-400 truncate">{p.qrSourceLabel}</p>}
|
{p.qrSourceLabel && <p className="text-[10px] text-gray-400 truncate">{p.qrSourceLabel}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2 flex flex-wrap gap-1">
|
||||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 inline-block ${sl.bg} ${sl.color}`}>{sl.label}</span>
|
<span className={`text-[10px] font-bold px-1.5 py-0.5 inline-block ${sl.bg} ${sl.color}`}>{sl.label}</span>
|
||||||
|
{p.isConditional && !p.conditionMet && (
|
||||||
|
<span className="text-[9px] font-bold px-1.5 py-0.5 bg-[#F59E0B]/10 text-[#F59E0B] inline-block" title={p.conditionText || "Conditional pledge"}>🤝 Conditional</span>
|
||||||
|
)}
|
||||||
|
{p.isConditional && p.conditionMet && (
|
||||||
|
<span className="text-[9px] font-bold px-1.5 py-0.5 bg-[#16A34A]/10 text-[#16A34A] inline-block">🤝 Unlocked</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-1 hidden md:block">
|
<div className="col-span-1 hidden md:block">
|
||||||
<span className="text-xs text-gray-500">{timeAgo(p.createdAt)}</span>
|
<span className="text-xs text-gray-500">{timeAgo(p.createdAt)}</span>
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export interface PledgeData {
|
|||||||
dueDate?: string
|
dueDate?: string
|
||||||
installmentCount?: number
|
installmentCount?: number
|
||||||
installmentDates?: string[]
|
installmentDates?: string[]
|
||||||
|
// Conditional / match funding
|
||||||
|
isConditional: boolean
|
||||||
|
conditionType?: "threshold" | "match" | "custom"
|
||||||
|
conditionText?: string
|
||||||
|
conditionThreshold?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventInfo {
|
interface EventInfo {
|
||||||
@@ -46,6 +51,7 @@ interface EventInfo {
|
|||||||
externalPlatform: string | null
|
externalPlatform: string | null
|
||||||
zakatEligible: boolean
|
zakatEligible: boolean
|
||||||
hasStripe: boolean
|
hasStripe: boolean
|
||||||
|
goalAmount: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -78,6 +84,7 @@ export default function PledgePage() {
|
|||||||
emailOptIn: false,
|
emailOptIn: false,
|
||||||
whatsappOptIn: false,
|
whatsappOptIn: false,
|
||||||
scheduleMode: "now",
|
scheduleMode: "now",
|
||||||
|
isConditional: false,
|
||||||
})
|
})
|
||||||
const [pledgeResult, setPledgeResult] = useState<{
|
const [pledgeResult, setPledgeResult] = useState<{
|
||||||
id: string
|
id: string
|
||||||
@@ -106,14 +113,14 @@ export default function PledgePage() {
|
|||||||
const isExternal = eventInfo?.paymentMode === "external" && eventInfo?.externalUrl
|
const isExternal = eventInfo?.paymentMode === "external" && eventInfo?.externalUrl
|
||||||
|
|
||||||
// Step 0: Amount selected
|
// Step 0: Amount selected
|
||||||
const handleAmountSelected = (amountPence: number) => {
|
const handleAmountSelected = (amountPence: number, conditional?: { isConditional: boolean; conditionType?: "threshold" | "match" | "custom"; conditionText?: string; conditionThreshold?: number }) => {
|
||||||
setPledgeData((d) => ({ ...d, amountPence }))
|
const conditionalData = conditional || { isConditional: false }
|
||||||
|
setPledgeData((d) => ({ ...d, amountPence, ...conditionalData }))
|
||||||
if (isExternal) {
|
if (isExternal) {
|
||||||
// External events: amount → identity → redirect (skip schedule + payment method)
|
setPledgeData((d) => ({ ...d, amountPence, rail: "bank", scheduleMode: "now", ...conditionalData }))
|
||||||
setPledgeData((d) => ({ ...d, amountPence, rail: "bank", scheduleMode: "now" }))
|
setStep(3)
|
||||||
setStep(3) // → Identity
|
|
||||||
} else {
|
} else {
|
||||||
setStep(1) // → Schedule step
|
setStep(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +232,7 @@ export default function PledgePage() {
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const steps: Record<number, React.ReactNode> = {
|
const steps: Record<number, React.ReactNode> = {
|
||||||
0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} eventId={eventInfo?.id} />,
|
0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} eventId={eventInfo?.id} goalAmount={eventInfo?.goalAmount} />,
|
||||||
1: <ScheduleStep amount={pledgeData.amountPence} onSelect={handleScheduleSelected} />,
|
1: <ScheduleStep amount={pledgeData.amountPence} onSelect={handleScheduleSelected} />,
|
||||||
2: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} hasStripe={eventInfo?.hasStripe ?? false} />,
|
2: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} hasStripe={eventInfo?.hasStripe ?? false} />,
|
||||||
3: <IdentityStep onSubmit={submitPledge} amount={pledgeData.amountPence} zakatEligible={eventInfo?.zakatEligible} orgName={eventInfo?.organizationName} />,
|
3: <IdentityStep onSubmit={submitPledge} amount={pledgeData.amountPence} zakatEligible={eventInfo?.zakatEligible} orgName={eventInfo?.organizationName} />,
|
||||||
@@ -242,6 +249,8 @@ export default function PledgePage() {
|
|||||||
dueDateLabel={dueDateLabel}
|
dueDateLabel={dueDateLabel}
|
||||||
installmentCount={pledgeData.installmentCount}
|
installmentCount={pledgeData.installmentCount}
|
||||||
installmentAmount={pledgeData.installmentCount ? Math.ceil(pledgeData.amountPence / pledgeData.installmentCount) : undefined}
|
installmentAmount={pledgeData.installmentCount ? Math.ceil(pledgeData.amountPence / pledgeData.installmentCount) : undefined}
|
||||||
|
isConditional={pledgeData.isConditional}
|
||||||
|
conditionText={pledgeData.conditionText}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
6: <CardPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
|
6: <CardPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Heart, Sparkles, TrendingUp } from "lucide-react"
|
import { Heart, Sparkles, TrendingUp } from "lucide-react"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSelect: (amountPence: number) => void
|
onSelect: (amountPence: number, conditional?: { isConditional: boolean; conditionType?: "threshold" | "match" | "custom"; conditionText?: string; conditionThreshold?: number }) => void
|
||||||
eventName: string
|
eventName: string
|
||||||
eventId?: string
|
eventId?: string
|
||||||
|
goalAmount?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AiSuggestion {
|
interface AiSuggestion {
|
||||||
@@ -18,13 +19,18 @@ interface AiSuggestion {
|
|||||||
|
|
||||||
const FALLBACK_AMOUNTS = [2000, 5000, 10000, 25000, 50000, 100000]
|
const FALLBACK_AMOUNTS = [2000, 5000, 10000, 25000, 50000, 100000]
|
||||||
|
|
||||||
export function AmountStep({ onSelect, eventName, eventId }: Props) {
|
export function AmountStep({ onSelect, eventName, eventId, goalAmount }: Props) {
|
||||||
const [custom, setCustom] = useState("")
|
const [custom, setCustom] = useState("")
|
||||||
const [selected, setSelected] = useState<number | null>(null)
|
const [selected, setSelected] = useState<number | null>(null)
|
||||||
const [suggestions, setSuggestions] = useState<AiSuggestion | null>(null)
|
const [suggestions, setSuggestions] = useState<AiSuggestion | null>(null)
|
||||||
const [hovering, setHovering] = useState<number | null>(null)
|
const [hovering, setHovering] = useState<number | null>(null)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Conditional / match funding
|
||||||
|
const [isMatch, setIsMatch] = useState(false)
|
||||||
|
const [matchType, setMatchType] = useState<"threshold" | "match">("threshold")
|
||||||
|
const [matchThreshold, setMatchThreshold] = useState("")
|
||||||
|
|
||||||
// Fetch AI-powered suggestions
|
// Fetch AI-powered suggestions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const url = eventId ? `/api/ai/suggest?eventId=${eventId}` : "/api/ai/suggest"
|
const url = eventId ? `/api/ai/suggest?eventId=${eventId}` : "/api/ai/suggest"
|
||||||
@@ -53,7 +59,22 @@ export function AmountStep({ onSelect, eventName, eventId }: Props) {
|
|||||||
|
|
||||||
const handleContinue = () => {
|
const handleContinue = () => {
|
||||||
const amount = selected || Math.round(parseFloat(custom) * 100)
|
const amount = selected || Math.round(parseFloat(custom) * 100)
|
||||||
if (amount >= 100) onSelect(amount)
|
if (amount >= 100) {
|
||||||
|
if (isMatch) {
|
||||||
|
const thresholdPence = matchThreshold ? Math.round(parseFloat(matchThreshold) * 100) : (goalAmount || 0)
|
||||||
|
const conditionText = matchType === "match"
|
||||||
|
? `I'll match up to £${(amount / 100).toFixed(0)}`
|
||||||
|
: `I'll give £${(amount / 100).toFixed(0)} if £${(thresholdPence / 100).toFixed(0)} is raised`
|
||||||
|
onSelect(amount, {
|
||||||
|
isConditional: true,
|
||||||
|
conditionType: matchType,
|
||||||
|
conditionText,
|
||||||
|
conditionThreshold: thresholdPence,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
onSelect(amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeAmount = selected || (custom ? Math.round(parseFloat(custom) * 100) : 0)
|
const activeAmount = selected || (custom ? Math.round(parseFloat(custom) * 100) : 0)
|
||||||
@@ -176,6 +197,78 @@ export function AmountStep({ onSelect, eventName, eventId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Match / conditional pledge */}
|
||||||
|
{isValid && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMatch(!isMatch)}
|
||||||
|
className={`w-full text-left rounded-lg border-2 p-4 transition-all ${
|
||||||
|
isMatch ? "border-trust-blue bg-trust-blue/5" : "border-gray-100 hover:border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">🤝</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-gray-900">This is a match pledge</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
I'll give this amount only if a condition is met
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isMatch && (
|
||||||
|
<div className="rounded-lg border border-gray-200 p-4 space-y-3 animate-fade-in">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setMatchType("threshold")}
|
||||||
|
className={`p-3 text-center rounded-lg border-2 text-xs font-bold transition-all ${
|
||||||
|
matchType === "threshold" ? "border-trust-blue bg-trust-blue/5 text-trust-blue" : "border-gray-200 text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
If target is reached
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMatchType("match")}
|
||||||
|
className={`p-3 text-center rounded-lg border-2 text-xs font-bold transition-all ${
|
||||||
|
matchType === "match" ? "border-trust-blue bg-trust-blue/5 text-trust-blue" : "border-gray-200 text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Match funding
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{matchType === "threshold" && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-gray-600">
|
||||||
|
I'll give £{(activeAmount / 100).toFixed(0)} if the appeal raises:
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 font-bold">£</span>
|
||||||
|
<input
|
||||||
|
type="text" inputMode="numeric"
|
||||||
|
value={matchThreshold}
|
||||||
|
onChange={e => setMatchThreshold(e.target.value.replace(/[^0-9]/g, ""))}
|
||||||
|
placeholder={goalAmount ? (goalAmount / 100).toFixed(0) : "10000"}
|
||||||
|
className="w-full pl-8 pr-3 h-10 rounded-lg border-2 border-gray-200 text-sm font-bold focus:border-trust-blue outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{matchType === "match" && (
|
||||||
|
<div className="rounded-lg bg-trust-blue/5 p-3">
|
||||||
|
<p className="text-xs text-gray-700">
|
||||||
|
You'll match other donors' pledges, up to <strong>£{(activeAmount / 100).toFixed(0)}</strong>.
|
||||||
|
Your pledge activates when the appeal reaches £{(activeAmount / 100).toFixed(0)} from other donors.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Continue */}
|
{/* Continue */}
|
||||||
<Button
|
<Button
|
||||||
size="xl"
|
size="xl"
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ interface Props {
|
|||||||
dueDateLabel?: string
|
dueDateLabel?: string
|
||||||
installmentCount?: number
|
installmentCount?: number
|
||||||
installmentAmount?: number
|
installmentAmount?: number
|
||||||
|
isConditional?: boolean
|
||||||
|
conditionText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mini confetti
|
// Mini confetti
|
||||||
@@ -54,7 +56,7 @@ function Confetti() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, donorPhone, isDeferred, dueDateLabel, installmentCount, installmentAmount }: Props) {
|
export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, donorPhone, isDeferred, dueDateLabel, installmentCount, installmentAmount, isConditional, conditionText }: Props) {
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [whatsappSent, setWhatsappSent] = useState(false)
|
const [whatsappSent, setWhatsappSent] = useState(false)
|
||||||
|
|
||||||
@@ -138,13 +140,27 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h1 className="text-2xl font-black text-gray-900">
|
<h1 className="text-2xl font-black text-gray-900">
|
||||||
{isDeferred
|
{isConditional
|
||||||
|
? "Match Pledge Registered!"
|
||||||
|
: isDeferred
|
||||||
? "Pledge Locked In!"
|
? "Pledge Locked In!"
|
||||||
: rail === "card" ? "Payment Complete!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
|
: rail === "card" ? "Payment Complete!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
|
{isConditional ? (
|
||||||
|
<>
|
||||||
|
Your match pledge of{" "}
|
||||||
|
<span className="font-semibold text-foreground">£{(amount / 100).toFixed(0)}</span>{" "}
|
||||||
|
for <span className="font-semibold text-foreground">{eventName}</span> has been registered.
|
||||||
|
{conditionText && <span className="block mt-1 text-amber-600 font-medium text-sm">{conditionText}</span>}
|
||||||
|
<span className="block mt-1 text-xs">We'll notify you when the condition is met and your pledge activates.</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
Thank you for your generous support of{" "}
|
Thank you for your generous support of{" "}
|
||||||
<span className="font-semibold text-foreground">{eventName}</span>
|
<span className="font-semibold text-foreground">{eventName}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ function MyPledgesForm() {
|
|||||||
createdAt: string; paidAt: string | null; dueDate: string | null;
|
createdAt: string; paidAt: string | null; dueDate: string | null;
|
||||||
installmentNumber: number | null; installmentTotal: number | null;
|
installmentNumber: number | null; installmentTotal: number | null;
|
||||||
bankDetails?: { sortCode: string; accountNo: string; accountName: string };
|
bankDetails?: { sortCode: string; accountNo: string; accountName: string };
|
||||||
|
isConditional?: boolean; conditionText?: string; conditionMet?: boolean;
|
||||||
}> | null>(null)
|
}> | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
@@ -110,6 +111,16 @@ function MyPledgesForm() {
|
|||||||
{p.installmentNumber && <p><strong>Instalment:</strong> {p.installmentNumber} of {p.installmentTotal}</p>}
|
{p.installmentNumber && <p><strong>Instalment:</strong> {p.installmentNumber} of {p.installmentTotal}</p>}
|
||||||
{p.dueDate && <p><strong>Due:</strong> {new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}</p>}
|
{p.dueDate && <p><strong>Due:</strong> {new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}</p>}
|
||||||
{p.paidAt && <p className="text-green-700"><strong>Paid:</strong> {new Date(p.paidAt).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}</p>}
|
{p.paidAt && <p className="text-green-700"><strong>Paid:</strong> {new Date(p.paidAt).toLocaleDateString("en-GB", { day: "numeric", month: "long" })}</p>}
|
||||||
|
{p.isConditional && !p.conditionMet && (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded p-2 mt-1">
|
||||||
|
<p className="text-amber-700 text-xs font-bold">🤝 Conditional pledge</p>
|
||||||
|
{p.conditionText && <p className="text-amber-600 text-xs mt-0.5">{p.conditionText}</p>}
|
||||||
|
<p className="text-amber-500 text-[10px] mt-0.5">Payment details will be sent when the condition is met</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.isConditional && p.conditionMet && (
|
||||||
|
<p className="text-green-700 text-xs font-bold">🤝 Condition met — pledge activated!</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{p.status !== "paid" && p.status !== "cancelled" && p.bankDetails && (
|
{p.status !== "paid" && p.status !== "cancelled" && p.bankDetails && (
|
||||||
<div className="bg-gray-50 rounded-lg p-3 space-y-1 text-xs">
|
<div className="bg-gray-50 rounded-lg p-3 space-y-1 text-xs">
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ export const createQrSourceSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const createPledgeSchema = z.object({
|
export const createPledgeSchema = z.object({
|
||||||
amountPence: z.number().int().min(100).max(100000000), // £1 to £1M
|
amountPence: z.number().int().min(100).max(100000000),
|
||||||
|
// Conditional / match funding
|
||||||
|
isConditional: z.boolean().default(false),
|
||||||
|
conditionType: z.enum(["threshold", "match", "custom"]).optional(),
|
||||||
|
conditionText: z.string().max(500).optional(),
|
||||||
|
conditionThreshold: z.number().int().positive().optional(),
|
||||||
rail: z.enum(['bank', 'gocardless', 'card']),
|
rail: z.enum(['bank', 'gocardless', 'card']),
|
||||||
donorName: z.string().max(200).optional().default(''),
|
donorName: z.string().max(200).optional().default(''),
|
||||||
donorEmail: z.string().max(200).optional().default(''),
|
donorEmail: z.string().max(200).optional().default(''),
|
||||||
|
|||||||
110
temp_files/fix2/ListDonations.php
Normal file
110
temp_files/fix2/ListDonations.php
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\DonationResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\DonationResource;
|
||||||
|
use App\Models\Donation;
|
||||||
|
use Filament\Resources\Components\Tab;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class ListDonations extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = DonationResource::class;
|
||||||
|
|
||||||
|
public function getHeading(): string
|
||||||
|
{
|
||||||
|
return 'Donations';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubheading(): string
|
||||||
|
{
|
||||||
|
$todayCount = Donation::whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
->whereDate('created_at', today())
|
||||||
|
->count();
|
||||||
|
$todayAmount = Donation::whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
->whereDate('created_at', today())
|
||||||
|
->sum('amount') / 100;
|
||||||
|
|
||||||
|
return "Today: {$todayCount} confirmed (£" . number_format($todayAmount, 0) . ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTabs(): array
|
||||||
|
{
|
||||||
|
$incompleteCount = Donation::whereDoesntHave('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
->where('created_at', '>=', now()->subDays(7))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$recurring = Donation::where('reoccurrence', '!=', -1)
|
||||||
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
// Use whereIn with subquery instead of whereHas to avoid null model crash
|
||||||
|
// during Filament tab initialization (modifyQueryUsing gets a builder with no model)
|
||||||
|
$confirmedSubquery = fn (Builder $q) => $q->whereIn(
|
||||||
|
'donations.id',
|
||||||
|
fn ($sub) => $sub->select('donation_id')
|
||||||
|
->from('donation_confirmations')
|
||||||
|
->whereNotNull('confirmed_at')
|
||||||
|
);
|
||||||
|
|
||||||
|
$unconfirmedSubquery = fn (Builder $q) => $q->whereNotIn(
|
||||||
|
'donations.id',
|
||||||
|
fn ($sub) => $sub->select('donation_id')
|
||||||
|
->from('donation_confirmations')
|
||||||
|
->whereNotNull('confirmed_at')
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'today' => Tab::make('Today')
|
||||||
|
->icon('heroicon-o-clock')
|
||||||
|
->modifyQueryUsing(fn (Builder $q) => $confirmedSubquery($q)
|
||||||
|
->whereDate('created_at', today())
|
||||||
|
),
|
||||||
|
|
||||||
|
'all_confirmed' => Tab::make('All Confirmed')
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->modifyQueryUsing(fn (Builder $q) => $confirmedSubquery($q)),
|
||||||
|
|
||||||
|
'incomplete' => Tab::make('Incomplete')
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->badge($incompleteCount > 0 ? $incompleteCount : null)
|
||||||
|
->badgeColor('danger')
|
||||||
|
->modifyQueryUsing(fn (Builder $q) => $unconfirmedSubquery($q)
|
||||||
|
->where('created_at', '>=', now()->subDays(7))
|
||||||
|
),
|
||||||
|
|
||||||
|
'zakat' => Tab::make('Zakat')
|
||||||
|
->icon('heroicon-o-star')
|
||||||
|
->modifyQueryUsing(fn (Builder $q) => $confirmedSubquery($q)
|
||||||
|
->whereIn('donations.id', fn ($sub) => $sub->select('donation_id')
|
||||||
|
->from('donation_preferences')
|
||||||
|
->where('is_zakat', true))
|
||||||
|
),
|
||||||
|
|
||||||
|
'gift_aid' => Tab::make('Gift Aid')
|
||||||
|
->icon('heroicon-o-gift')
|
||||||
|
->modifyQueryUsing(fn (Builder $q) => $confirmedSubquery($q)
|
||||||
|
->whereIn('donations.id', fn ($sub) => $sub->select('donation_id')
|
||||||
|
->from('donation_preferences')
|
||||||
|
->where('is_gift_aid', true))
|
||||||
|
),
|
||||||
|
|
||||||
|
'recurring' => Tab::make('Recurring')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->badge($recurring > 0 ? $recurring : null)
|
||||||
|
->badgeColor('info')
|
||||||
|
->modifyQueryUsing(fn (Builder $q) => $confirmedSubquery($q)
|
||||||
|
->where('reoccurrence', '!=', -1)
|
||||||
|
),
|
||||||
|
|
||||||
|
'everything' => Tab::make('Everything')
|
||||||
|
->icon('heroicon-o-squares-2x2'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDefaultActiveTab(): string | int | null
|
||||||
|
{
|
||||||
|
return 'today';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,88 +20,85 @@ class ListScheduledGivingDonations extends ListRecords
|
|||||||
|
|
||||||
public function getSubheading(): string
|
public function getSubheading(): string
|
||||||
{
|
{
|
||||||
$current = $this->currentSeasonScope()->count();
|
$current = $this->currentSeasonCount();
|
||||||
return "{$current} subscribers this season.";
|
return "{$current} subscribers this season.";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Real subscriber: has customer, has payments, amount > 0, not soft-deleted */
|
/** Count real current-season subscribers using the model directly (safe) */
|
||||||
private function realScope(): Builder
|
private function currentSeasonCount(): int
|
||||||
{
|
{
|
||||||
return ScheduledGivingDonation::query()
|
return ScheduledGivingDonation::query()
|
||||||
->whereNotNull('customer_id')
|
->whereNotNull('customer_id')
|
||||||
->where('total_amount', '>', 0)
|
->where('total_amount', '>', 0)
|
||||||
->whereNull('scheduled_giving_donations.deleted_at')
|
|
||||||
->whereHas('payments', fn ($q) => $q->whereNull('deleted_at'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Current season: real + has at least one future payment */
|
|
||||||
private function currentSeasonScope(): Builder
|
|
||||||
{
|
|
||||||
return $this->realScope()
|
|
||||||
->whereHas('payments', fn ($q) => $q
|
|
||||||
->whereNull('deleted_at')
|
->whereNull('deleted_at')
|
||||||
->where('expected_at', '>', now()));
|
->whereHas('payments', fn ($q) => $q->whereNull('deleted_at'))
|
||||||
|
->whereHas('payments', fn ($q) => $q->whereNull('deleted_at')->where('expected_at', '>', now()))
|
||||||
|
->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Applies real + current season filters to the query */
|
/**
|
||||||
private function applyCurrentSeason(Builder $q): Builder
|
* Apply "real subscriber" filter using whereIn subqueries
|
||||||
{
|
* instead of whereHas — avoids null model crash during tab init.
|
||||||
return $q
|
*/
|
||||||
->whereNotNull('customer_id')
|
|
||||||
->where('total_amount', '>', 0)
|
|
||||||
->whereNull('scheduled_giving_donations.deleted_at')
|
|
||||||
->whereHas('payments', fn ($sub) => $sub->whereNull('deleted_at'))
|
|
||||||
->whereHas('payments', fn ($sub) => $sub
|
|
||||||
->whereNull('deleted_at')
|
|
||||||
->where('expected_at', '>', now()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Applies real + expired (no future payments) filters */
|
|
||||||
private function applyExpired(Builder $q): Builder
|
|
||||||
{
|
|
||||||
return $q
|
|
||||||
->whereNotNull('customer_id')
|
|
||||||
->where('total_amount', '>', 0)
|
|
||||||
->whereNull('scheduled_giving_donations.deleted_at')
|
|
||||||
->whereHas('payments', fn ($sub) => $sub->whereNull('deleted_at'))
|
|
||||||
->whereDoesntHave('payments', fn ($sub) => $sub
|
|
||||||
->whereNull('deleted_at')
|
|
||||||
->where('expected_at', '>', now()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Applies real subscriber filters */
|
|
||||||
private function applyReal(Builder $q): Builder
|
private function applyReal(Builder $q): Builder
|
||||||
{
|
{
|
||||||
return $q
|
return $q
|
||||||
->whereNotNull('customer_id')
|
->whereNotNull('customer_id')
|
||||||
->where('total_amount', '>', 0)
|
->where('total_amount', '>', 0)
|
||||||
->whereNull('scheduled_giving_donations.deleted_at')
|
->whereNull('scheduled_giving_donations.deleted_at')
|
||||||
->whereHas('payments', fn ($sub) => $sub->whereNull('deleted_at'));
|
->whereIn('scheduled_giving_donations.id', fn ($sub) => $sub
|
||||||
|
->select('scheduled_giving_donation_id')
|
||||||
|
->from('scheduled_giving_payments')
|
||||||
|
->whereNull('deleted_at'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Real + has future payment = current season */
|
||||||
|
private function applyCurrentSeason(Builder $q): Builder
|
||||||
|
{
|
||||||
|
return $this->applyReal($q)
|
||||||
|
->whereIn('scheduled_giving_donations.id', fn ($sub) => $sub
|
||||||
|
->select('scheduled_giving_donation_id')
|
||||||
|
->from('scheduled_giving_payments')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->where('expected_at', '>', now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Real + NO future payments = expired */
|
||||||
|
private function applyExpired(Builder $q): Builder
|
||||||
|
{
|
||||||
|
return $this->applyReal($q)
|
||||||
|
->whereNotIn('scheduled_giving_donations.id', fn ($sub) => $sub
|
||||||
|
->select('scheduled_giving_donation_id')
|
||||||
|
->from('scheduled_giving_payments')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->where('expected_at', '>', now()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTabs(): array
|
public function getTabs(): array
|
||||||
{
|
{
|
||||||
$campaigns = ScheduledGivingCampaign::all();
|
$campaigns = ScheduledGivingCampaign::all();
|
||||||
|
$currentCount = $this->currentSeasonCount();
|
||||||
$currentCount = $this->currentSeasonScope()->count();
|
|
||||||
|
|
||||||
$tabs = [];
|
$tabs = [];
|
||||||
|
|
||||||
// Current season — the primary tab
|
|
||||||
$tabs['current'] = Tab::make('This Season')
|
$tabs['current'] = Tab::make('This Season')
|
||||||
->icon('heroicon-o-sun')
|
->icon('heroicon-o-sun')
|
||||||
->badge($currentCount)
|
->badge($currentCount)
|
||||||
->badgeColor('success')
|
->badgeColor('success')
|
||||||
->modifyQueryUsing(fn (Builder $q) => $this->applyCurrentSeason($q));
|
->modifyQueryUsing(fn (Builder $q) => $this->applyCurrentSeason($q));
|
||||||
|
|
||||||
// Per-campaign tabs for current season
|
|
||||||
foreach ($campaigns as $c) {
|
foreach ($campaigns as $c) {
|
||||||
$slug = str($c->title)->slug()->toString();
|
$slug = str($c->title)->slug()->toString();
|
||||||
$count = $this->currentSeasonScope()
|
$count = ScheduledGivingDonation::query()
|
||||||
|
->whereNotNull('customer_id')
|
||||||
|
->where('total_amount', '>', 0)
|
||||||
|
->whereNull('deleted_at')
|
||||||
->where('scheduled_giving_campaign_id', $c->id)
|
->where('scheduled_giving_campaign_id', $c->id)
|
||||||
|
->whereHas('payments', fn ($q) => $q->whereNull('deleted_at'))
|
||||||
|
->whereHas('payments', fn ($q) => $q->whereNull('deleted_at')->where('expected_at', '>', now()))
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
if ($count === 0) continue; // Skip campaigns with no current subscribers
|
if ($count === 0) continue;
|
||||||
|
|
||||||
$tabs[$slug] = Tab::make($c->title)
|
$tabs[$slug] = Tab::make($c->title)
|
||||||
->icon('heroicon-o-calendar')
|
->icon('heroicon-o-calendar')
|
||||||
@@ -111,12 +108,14 @@ class ListScheduledGivingDonations extends ListRecords
|
|||||||
->where('scheduled_giving_campaign_id', $c->id));
|
->where('scheduled_giving_campaign_id', $c->id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Failed (current season only)
|
// Failed (current season)
|
||||||
$failedCount = $this->currentSeasonScope()
|
$failedCount = ScheduledGivingDonation::query()
|
||||||
->whereHas('payments', fn ($q) => $q
|
->whereNotNull('customer_id')
|
||||||
->where('is_paid', false)
|
->where('total_amount', '>', 0)
|
||||||
->where('attempts', '>', 0)
|
->whereNull('deleted_at')
|
||||||
->whereNull('deleted_at'))
|
->whereHas('payments', fn ($q) => $q->whereNull('deleted_at'))
|
||||||
|
->whereHas('payments', fn ($q) => $q->whereNull('deleted_at')->where('expected_at', '>', now()))
|
||||||
|
->whereHas('payments', fn ($q) => $q->where('is_paid', false)->where('attempts', '>', 0)->whereNull('deleted_at'))
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
if ($failedCount > 0) {
|
if ($failedCount > 0) {
|
||||||
@@ -125,17 +124,21 @@ class ListScheduledGivingDonations extends ListRecords
|
|||||||
->badge($failedCount)
|
->badge($failedCount)
|
||||||
->badgeColor('danger')
|
->badgeColor('danger')
|
||||||
->modifyQueryUsing(fn (Builder $q) => $this->applyCurrentSeason($q)
|
->modifyQueryUsing(fn (Builder $q) => $this->applyCurrentSeason($q)
|
||||||
->whereHas('payments', fn ($sub) => $sub
|
->whereIn('scheduled_giving_donations.id', fn ($sub) => $sub
|
||||||
|
->select('scheduled_giving_donation_id')
|
||||||
|
->from('scheduled_giving_payments')
|
||||||
->where('is_paid', false)
|
->where('is_paid', false)
|
||||||
->where('attempts', '>', 0)
|
->where('attempts', '>', 0)
|
||||||
->whereNull('deleted_at')));
|
->whereNull('deleted_at')));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Past seasons
|
// Past seasons
|
||||||
$expiredCount = $this->realScope()
|
$expiredCount = ScheduledGivingDonation::query()
|
||||||
->whereDoesntHave('payments', fn ($q) => $q
|
->whereNotNull('customer_id')
|
||||||
|
->where('total_amount', '>', 0)
|
||||||
->whereNull('deleted_at')
|
->whereNull('deleted_at')
|
||||||
->where('expected_at', '>', now()))
|
->whereHas('payments', fn ($q) => $q->whereNull('deleted_at'))
|
||||||
|
->whereDoesntHave('payments', fn ($q) => $q->whereNull('deleted_at')->where('expected_at', '>', now()))
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
$tabs['past'] = Tab::make('Past Seasons')
|
$tabs['past'] = Tab::make('Past Seasons')
|
||||||
@@ -144,7 +147,6 @@ class ListScheduledGivingDonations extends ListRecords
|
|||||||
->badgeColor('gray')
|
->badgeColor('gray')
|
||||||
->modifyQueryUsing(fn (Builder $q) => $this->applyExpired($q));
|
->modifyQueryUsing(fn (Builder $q) => $this->applyExpired($q));
|
||||||
|
|
||||||
// All real
|
|
||||||
$tabs['all'] = Tab::make('All')
|
$tabs['all'] = Tab::make('All')
|
||||||
->icon('heroicon-o-squares-2x2')
|
->icon('heroicon-o-squares-2x2')
|
||||||
->modifyQueryUsing(fn (Builder $q) => $this->applyReal($q));
|
->modifyQueryUsing(fn (Builder $q) => $this->applyReal($q));
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ class ScheduledGivingDashboard extends Page
|
|||||||
$currentIds = $this->currentSeasonIds($c->id);
|
$currentIds = $this->currentSeasonIds($c->id);
|
||||||
$expiredIds = $realIds->diff($currentIds);
|
$expiredIds = $realIds->diff($currentIds);
|
||||||
|
|
||||||
// Current season payment stats
|
// Current season payment stats — separate due vs future
|
||||||
$currentPayments = null;
|
$currentPayments = null;
|
||||||
if ($currentIds->isNotEmpty()) {
|
if ($currentIds->isNotEmpty()) {
|
||||||
$currentPayments = DB::table('scheduled_giving_payments')
|
$currentPayments = DB::table('scheduled_giving_payments')
|
||||||
@@ -91,10 +91,12 @@ class ScheduledGivingDashboard extends Page
|
|||||||
->selectRaw("
|
->selectRaw("
|
||||||
COUNT(*) as total,
|
COUNT(*) as total,
|
||||||
SUM(is_paid = 1) as paid,
|
SUM(is_paid = 1) as paid,
|
||||||
SUM(is_paid = 0) as pending,
|
SUM(is_paid = 0 AND expected_at <= NOW()) as failed,
|
||||||
SUM(is_paid = 0 AND attempts > 0) as failed,
|
SUM(is_paid = 0 AND expected_at > NOW()) as scheduled,
|
||||||
|
SUM(expected_at <= NOW()) as due,
|
||||||
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) as collected,
|
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) as collected,
|
||||||
SUM(CASE WHEN is_paid = 0 THEN amount ELSE 0 END) as pending_amount,
|
SUM(CASE WHEN is_paid = 0 AND expected_at <= NOW() THEN amount ELSE 0 END) as failed_amount,
|
||||||
|
SUM(CASE WHEN is_paid = 0 AND expected_at > NOW() THEN amount ELSE 0 END) as scheduled_amount,
|
||||||
AVG(CASE WHEN is_paid = 1 THEN amount ELSE NULL END) as avg_amount,
|
AVG(CASE WHEN is_paid = 1 THEN amount ELSE NULL END) as avg_amount,
|
||||||
MIN(CASE WHEN is_paid = 0 AND expected_at > NOW() THEN expected_at ELSE NULL END) as next_payment
|
MIN(CASE WHEN is_paid = 0 AND expected_at > NOW() THEN expected_at ELSE NULL END) as next_payment
|
||||||
")
|
")
|
||||||
@@ -121,6 +123,9 @@ class ScheduledGivingDashboard extends Page
|
|||||||
$fullyPaid = $row->cnt ?? 0;
|
$fullyPaid = $row->cnt ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$due = (int) ($currentPayments->due ?? 0);
|
||||||
|
$paid = (int) ($currentPayments->paid ?? 0);
|
||||||
|
|
||||||
$result[] = [
|
$result[] = [
|
||||||
'campaign' => $c,
|
'campaign' => $c,
|
||||||
'all_time_subscribers' => $realIds->count(),
|
'all_time_subscribers' => $realIds->count(),
|
||||||
@@ -130,13 +135,16 @@ class ScheduledGivingDashboard extends Page
|
|||||||
// Current season
|
// Current season
|
||||||
'current_subscribers' => $currentIds->count(),
|
'current_subscribers' => $currentIds->count(),
|
||||||
'expired_subscribers' => $expiredIds->count(),
|
'expired_subscribers' => $expiredIds->count(),
|
||||||
'current_payments' => (int) ($currentPayments->total ?? 0),
|
'total_payments' => (int) ($currentPayments->total ?? 0),
|
||||||
'current_paid' => (int) ($currentPayments->paid ?? 0),
|
'due_payments' => $due,
|
||||||
'current_pending' => (int) ($currentPayments->pending ?? 0),
|
'paid_payments' => $paid,
|
||||||
'current_failed' => (int) ($currentPayments->failed ?? 0),
|
'failed_payments' => (int) ($currentPayments->failed ?? 0),
|
||||||
'current_collected' => ($currentPayments->collected ?? 0) / 100,
|
'scheduled_payments' => (int) ($currentPayments->scheduled ?? 0),
|
||||||
'current_pending_amount' => ($currentPayments->pending_amount ?? 0) / 100,
|
'collected' => ($currentPayments->collected ?? 0) / 100,
|
||||||
|
'failed_amount' => ($currentPayments->failed_amount ?? 0) / 100,
|
||||||
|
'scheduled_amount' => ($currentPayments->scheduled_amount ?? 0) / 100,
|
||||||
'avg_per_night' => ($currentPayments->avg_amount ?? 0) / 100,
|
'avg_per_night' => ($currentPayments->avg_amount ?? 0) / 100,
|
||||||
|
'collection_rate' => $due > 0 ? round($paid / $due * 100, 1) : 0,
|
||||||
'fully_completed' => $fullyPaid,
|
'fully_completed' => $fullyPaid,
|
||||||
'dates' => $c->dates ?? [],
|
'dates' => $c->dates ?? [],
|
||||||
'total_nights' => $totalNights,
|
'total_nights' => $totalNights,
|
||||||
@@ -169,25 +177,32 @@ class ScheduledGivingDashboard extends Page
|
|||||||
->whereNull('deleted_at')
|
->whereNull('deleted_at')
|
||||||
->selectRaw("
|
->selectRaw("
|
||||||
SUM(is_paid = 1) as paid,
|
SUM(is_paid = 1) as paid,
|
||||||
SUM(is_paid = 0 AND attempts > 0) as failed,
|
SUM(expected_at <= NOW()) as due,
|
||||||
|
SUM(is_paid = 0 AND expected_at <= NOW()) as failed,
|
||||||
|
SUM(is_paid = 0 AND expected_at > NOW()) as scheduled,
|
||||||
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) / 100 as collected,
|
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) / 100 as collected,
|
||||||
SUM(CASE WHEN is_paid = 0 THEN amount ELSE 0 END) / 100 as pending,
|
SUM(CASE WHEN is_paid = 0 AND expected_at <= NOW() THEN amount ELSE 0 END) / 100 as failed_amount,
|
||||||
COUNT(*) as total
|
SUM(CASE WHEN is_paid = 0 AND expected_at > NOW() THEN amount ELSE 0 END) / 100 as scheduled_amount
|
||||||
")
|
")
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$due = (int) ($currentStats->due ?? 0);
|
||||||
|
$paid = (int) ($currentStats->paid ?? 0);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'total_subscribers' => $realIds->count(),
|
'total_subscribers' => $realIds->count(),
|
||||||
'current_subscribers' => $currentIds->count(),
|
'current_subscribers' => $currentIds->count(),
|
||||||
'expired_subscribers' => $realIds->count() - $currentIds->count(),
|
'expired_subscribers' => $realIds->count() - $currentIds->count(),
|
||||||
'all_time_collected' => (float) ($allTime->collected ?? 0),
|
'all_time_collected' => (float) ($allTime->collected ?? 0),
|
||||||
'current_collected' => (float) ($currentStats->collected ?? 0),
|
'collected' => (float) ($currentStats->collected ?? 0),
|
||||||
'current_pending' => (float) ($currentStats->pending ?? 0),
|
'failed_amount' => (float) ($currentStats->failed_amount ?? 0),
|
||||||
'current_failed' => (int) ($currentStats->failed ?? 0),
|
'scheduled_amount' => (float) ($currentStats->scheduled_amount ?? 0),
|
||||||
'collection_rate' => ($currentStats->total ?? 0) > 0
|
'failed_count' => (int) ($currentStats->failed ?? 0),
|
||||||
? round($currentStats->paid / $currentStats->total * 100, 1)
|
'scheduled_count' => (int) ($currentStats->scheduled ?? 0),
|
||||||
: 0,
|
'due_payments' => $due,
|
||||||
|
'paid_payments' => $paid,
|
||||||
|
'collection_rate' => $due > 0 ? round($paid / $due * 100, 1) : 0,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,29 +16,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-3xl font-bold text-primary-600">{{ number_format($global['current_subscribers']) }}</div>
|
<div class="text-3xl font-bold text-primary-600">{{ number_format($global['current_subscribers']) }}</div>
|
||||||
<div class="text-sm text-gray-500 mt-1">Active This Season</div>
|
<div class="text-sm text-gray-500 mt-1">Active</div>
|
||||||
<div class="text-xs text-gray-400">{{ number_format($global['expired_subscribers']) }} from past seasons</div>
|
<div class="text-xs text-gray-400">{{ number_format($global['expired_subscribers']) }} past seasons</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-3xl font-bold text-success-600">£{{ number_format($global['current_collected'], 0) }}</div>
|
<div class="text-3xl font-bold text-success-600">£{{ number_format($global['collected'], 0) }}</div>
|
||||||
<div class="text-sm text-gray-500 mt-1">Collected This Season</div>
|
<div class="text-sm text-gray-500 mt-1">Collected</div>
|
||||||
<div class="text-xs text-gray-400">£{{ number_format($global['all_time_collected'], 0) }} all-time</div>
|
<div class="text-xs text-gray-400">{{ number_format($global['paid_payments']) }}/{{ number_format($global['due_payments']) }} due paid</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-3xl font-bold text-warning-600">£{{ number_format($global['current_pending'], 0) }}</div>
|
<div class="text-3xl font-bold {{ $global['failed_count'] > 0 ? 'text-danger-600' : 'text-gray-400' }}">£{{ number_format($global['failed_amount'], 0) }}</div>
|
||||||
<div class="text-sm text-gray-500 mt-1">Pending</div>
|
<div class="text-sm text-gray-500 mt-1">Failed</div>
|
||||||
@if ($global['current_failed'] > 0)
|
<div class="text-xs text-danger-500">{{ number_format($global['failed_count']) }} payments</div>
|
||||||
<div class="text-xs text-danger-500">{{ $global['current_failed'] }} failed</div>
|
</div>
|
||||||
@endif
|
<div class="text-center">
|
||||||
|
<div class="text-3xl font-bold text-gray-500">£{{ number_format($global['scheduled_amount'], 0) }}</div>
|
||||||
|
<div class="text-sm text-gray-500 mt-1">Upcoming</div>
|
||||||
|
<div class="text-xs text-gray-400">{{ number_format($global['scheduled_count']) }} not yet due</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-3xl font-bold {{ $global['collection_rate'] >= 80 ? 'text-success-600' : ($global['collection_rate'] >= 60 ? 'text-warning-600' : 'text-danger-600') }}">
|
<div class="text-3xl font-bold {{ $global['collection_rate'] >= 80 ? 'text-success-600' : ($global['collection_rate'] >= 60 ? 'text-warning-600' : 'text-danger-600') }}">
|
||||||
{{ $global['collection_rate'] }}%
|
{{ $global['collection_rate'] }}%
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-500 mt-1">Collection Rate</div>
|
<div class="text-sm text-gray-500 mt-1">Collection Rate</div>
|
||||||
|
<div class="text-xs text-gray-400">of due payments</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
@@ -49,8 +53,11 @@
|
|||||||
@php
|
@php
|
||||||
$c = $data['campaign'];
|
$c = $data['campaign'];
|
||||||
$hasCurrent = $data['current_subscribers'] > 0;
|
$hasCurrent = $data['current_subscribers'] > 0;
|
||||||
$progressPct = $data['current_payments'] > 0
|
$duePct = $data['due_payments'] > 0
|
||||||
? round($data['current_paid'] / $data['current_payments'] * 100)
|
? round($data['paid_payments'] / $data['due_payments'] * 100)
|
||||||
|
: 0;
|
||||||
|
$overallPct = $data['total_payments'] > 0
|
||||||
|
? round($data['paid_payments'] / $data['total_payments'] * 100)
|
||||||
: 0;
|
: 0;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@@ -67,8 +74,6 @@
|
|||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
@if ($hasCurrent)
|
@if ($hasCurrent)
|
||||||
{{-- Current Season --}}
|
|
||||||
<div class="text-xs font-medium text-primary-600 uppercase tracking-wide mb-2">This Season</div>
|
|
||||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Subscribers</div>
|
<div class="text-xs text-gray-500 uppercase tracking-wide">Subscribers</div>
|
||||||
@@ -80,23 +85,35 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Collected</div>
|
<div class="text-xs text-gray-500 uppercase tracking-wide">Collected</div>
|
||||||
<div class="text-lg font-semibold text-success-600">£{{ number_format($data['current_collected'], 0) }}</div>
|
<div class="text-lg font-semibold text-success-600">£{{ number_format($data['collected'], 0) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Pending</div>
|
<div class="text-xs text-gray-500 uppercase tracking-wide">
|
||||||
<div class="text-lg font-semibold text-warning-600">£{{ number_format($data['current_pending_amount'], 0) }}</div>
|
{{ $data['collection_rate'] }}% Rate
|
||||||
|
</div>
|
||||||
|
<div class="text-lg font-semibold {{ $data['collection_rate'] >= 80 ? 'text-success-600' : ($data['collection_rate'] >= 60 ? 'text-warning-600' : 'text-danger-600') }}">
|
||||||
|
{{ $data['paid_payments'] }}/{{ $data['due_payments'] }} due
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Payment progress bar --}}
|
{{-- Progress bar: paid / total (including future) --}}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="flex justify-between text-xs text-gray-500 mb-1">
|
<div class="flex justify-between text-xs text-gray-500 mb-1">
|
||||||
<span>{{ number_format($data['current_paid']) }} / {{ number_format($data['current_payments']) }} payments</span>
|
<span>{{ number_format($data['paid_payments']) }} paid, {{ number_format($data['failed_payments']) }} failed, {{ number_format($data['scheduled_payments']) }} upcoming</span>
|
||||||
<span>{{ $progressPct }}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
|
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700 flex overflow-hidden">
|
||||||
<div class="h-2.5 rounded-full {{ $progressPct >= 80 ? 'bg-success-500' : ($progressPct >= 50 ? 'bg-warning-500' : 'bg-primary-500') }}"
|
@if ($data['total_payments'] > 0)
|
||||||
style="width: {{ $progressPct }}%"></div>
|
<div class="h-2.5 bg-success-500" style="width: {{ $data['paid_payments'] / $data['total_payments'] * 100 }}%"></div>
|
||||||
|
<div class="h-2.5 bg-danger-400" style="width: {{ $data['failed_payments'] / $data['total_payments'] * 100 }}%"></div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-xs mt-1">
|
||||||
|
<span class="text-success-600">■ Paid</span>
|
||||||
|
@if ($data['failed_payments'] > 0)
|
||||||
|
<span class="text-danger-500">■ Failed (£{{ number_format($data['failed_amount'], 0) }})</span>
|
||||||
|
@endif
|
||||||
|
<span class="text-gray-400">■ Upcoming (£{{ number_format($data['scheduled_amount'], 0) }})</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -111,12 +128,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Failed</div>
|
<div class="text-xs text-gray-500">Failed</div>
|
||||||
<div class="font-semibold {{ $data['current_failed'] > 0 ? 'text-danger-600' : 'text-gray-400' }}">{{ $data['current_failed'] }}</div>
|
<div class="font-semibold {{ $data['failed_payments'] > 0 ? 'text-danger-600' : 'text-gray-400' }}">{{ $data['failed_payments'] }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- All-time summary --}}
|
{{-- All-time --}}
|
||||||
<div class="{{ $hasCurrent ? 'mt-3 pt-3 border-t dark:border-gray-700' : '' }}">
|
<div class="{{ $hasCurrent ? 'mt-3 pt-3 border-t dark:border-gray-700' : '' }}">
|
||||||
<div class="text-xs font-medium text-gray-400 uppercase tracking-wide mb-1">All Time</div>
|
<div class="text-xs font-medium text-gray-400 uppercase tracking-wide mb-1">All Time</div>
|
||||||
<div class="flex justify-between text-sm text-gray-500">
|
<div class="flex justify-between text-sm text-gray-500">
|
||||||
@@ -220,7 +237,6 @@
|
|||||||
<p class="text-sm text-gray-500 mb-3">
|
<p class="text-sm text-gray-500 mb-3">
|
||||||
{{ number_format($quality['total_records']) }} total records in database.
|
{{ number_format($quality['total_records']) }} total records in database.
|
||||||
Only {{ number_format($global['total_subscribers']) }} are real subscribers with payments.
|
Only {{ number_format($global['total_subscribers']) }} are real subscribers with payments.
|
||||||
The rest are incomplete sign-ups, test data, or soft-deleted.
|
|
||||||
</p>
|
</p>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-center text-sm">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-center text-sm">
|
||||||
<div class="p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
<div class="p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||||||
|
|||||||
Reference in New Issue
Block a user