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:
2026-05-27 22:41:53 +02:00
parent 909dd44605
commit 3bdf59e917
41 changed files with 10704 additions and 203 deletions

View File

@@ -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;
}