feat(reports-overhaul): sales + operational + custom reports, templates, schedules, exports
End-to-end reports build covering Phases 1, 2, 5, 6, 7 of Initiative 1 in docs/launch-readiness.md. Phases 3 (Marketing) + 4 (Financial) remain deferred per the gap audit at the bottom of that doc. Highlights: - Sales performance report: 7 KPI tiles, pipeline funnel + stage velocity + win-rate-over-time + source conversion + rep leaderboard charts, deal-heat section, 5 detail tables, stage / lead-cat / outcome filters. - Operational report: 7 KPIs, 7 charts (heatmap, status mix, tenancy churn, tenure histogram, signing box plot, occupancy by area, docs in pipeline), 4 tables. Module-OFF banner when tenancies disabled. - Custom (ad-hoc) builder v1: 4 entities (clients, interests, berths, tenancies), column-whitelist composer, date filter, CSV download, save-as-template. Registry-only extension path for the remaining 6 entities documented at src/lib/reports/custom/registry.ts. - Templates: load / modify / save / save-as on Sales / Operational / Custom. ?templateId= URL deep-link hydration via useRef guard. Active-template badge clears when the user drives view-state via wrapped setters; raw setters used on template apply so the badge survives. - Scheduled runs: BullMQ poll fires due schedules, mints report_runs, renders, optionally emails. Recipients optional (zero-recipient schedules archive without sending). PDF-only output for v1. Schedule dialog re-mounts via key prop on schedule.id transitions to avoid setState-in-effect reset patterns. - Server-side PDF endpoint + shared payload renderer (lib/pdf/reports/payload-report.tsx) so client + scheduler share one rendering path. - Shared currency formatter (lib/reports/format-currency.ts) consolidates 5 duplicated formatMoney helpers; fixes hardcoded 'USD' in detail tables; pre-formats money rows so PDF export (which strips column.format callbacks at the JSON boundary) renders consistently with CSV / XLSX. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -143,9 +143,20 @@ export const reportsWorker = new Worker(
|
||||
.where(eq(reportSchedules.id, schedule.id));
|
||||
|
||||
try {
|
||||
const { REPORT_KINDS } = await import('@/lib/validators/reports');
|
||||
const kindNarrowed = (REPORT_KINDS as readonly string[]).includes(template.kind)
|
||||
? (template.kind as (typeof REPORT_KINDS)[number])
|
||||
: null;
|
||||
if (!kindNarrowed) {
|
||||
logger.warn(
|
||||
{ scheduleId: schedule.id, templateId: schedule.templateId, kind: template.kind },
|
||||
'Skipping schedule: template kind not in REPORT_KINDS allowlist',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const run = await createReportRun(
|
||||
{
|
||||
kind: template.kind as 'dashboard' | 'clients' | 'berths' | 'interests',
|
||||
kind: kindNarrowed,
|
||||
config: template.config,
|
||||
outputFormat: schedule.outputFormat as 'pdf' | 'csv' | 'png',
|
||||
templateId: template.id,
|
||||
@@ -183,15 +194,38 @@ export const reportsWorker = new Worker(
|
||||
const { renderReportRun } = await import('@/lib/services/report-render.service');
|
||||
const run = await renderReportRun(reportRunId);
|
||||
|
||||
// Schedule-driven runs auto-cascade into the email job. User-
|
||||
// triggered runs are inert — the rep downloads via the UI.
|
||||
if (run.triggeredBy === 'schedule' && run.status === 'complete') {
|
||||
const { getQueue: enqueue } = await import('@/lib/queue');
|
||||
await enqueue('reports').add(
|
||||
'report-run-email',
|
||||
{ reportRunId: run.id },
|
||||
{ jobId: `report-run-email:${run.id}` },
|
||||
);
|
||||
// Schedule-driven runs auto-cascade into the email job ONLY when
|
||||
// the schedule has recipients configured. Email is optional per
|
||||
// locked decision (2026-05-27): an admin can schedule a run that
|
||||
// just appears in /reports/runs without forcing a blast.
|
||||
// User-triggered runs are inert — the rep downloads via the UI.
|
||||
if (
|
||||
run.triggeredBy === 'schedule' &&
|
||||
run.status === 'complete' &&
|
||||
run.scheduleId !== null
|
||||
) {
|
||||
const { db: dbForSched } = await import('@/lib/db');
|
||||
const { reportSchedules: schedTbl } = await import('@/lib/db/schema/reports');
|
||||
const { eq: eqOp } = await import('drizzle-orm');
|
||||
const sched = await dbForSched.query.reportSchedules.findFirst({
|
||||
where: eqOp(schedTbl.id, run.scheduleId),
|
||||
columns: { recipients: true },
|
||||
});
|
||||
const hasRecipients =
|
||||
Array.isArray(sched?.recipients) && (sched?.recipients?.length ?? 0) > 0;
|
||||
if (hasRecipients) {
|
||||
const { getQueue: enqueue } = await import('@/lib/queue');
|
||||
await enqueue('reports').add(
|
||||
'report-run-email',
|
||||
{ reportRunId: run.id },
|
||||
{ jobId: `report-run-email:${run.id}` },
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
{ reportRunId: run.id, scheduleId: run.scheduleId },
|
||||
'Schedule has no recipients; skipping email cascade (run archived only)',
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user