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

@@ -0,0 +1,97 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { ENTITY_KEYS, ENTITY_REGISTRY, type EntityKey } from '@/lib/reports/custom/registry';
/**
* POST /api/v1/reports/custom/run
*
* Executes a custom-report query and returns raw rows. The UI calls
* this with the entity + selected columns + optional date range; the
* service resolves the column allowlist and runs the underlying
* Drizzle query.
*
* Permission: `reports.export` — same gate as the saved-template
* endpoints (anyone who can export reports can run a custom slice).
*
* The handler returns JSON `{ data: rows[] }` rather than streaming
* CSV — the client serializes via the existing CSV exporter so all
* download formats (CSV/XLSX/PDF) reuse one code path.
*/
const bodySchema = z.object({
entity: z.enum(ENTITY_KEYS),
columns: z.array(z.string().min(1)).min(1).max(50),
from: z.string().datetime().optional(),
to: z.string().datetime().optional(),
});
export const POST = withAuth(
withPermission('reports', 'export', async (req, ctx) => {
try {
const body = await parseBody(req, bodySchema);
const def = ENTITY_REGISTRY[body.entity as EntityKey];
// Cross-validate columns against the registry's allowlist.
const allowedKeys = new Set(def.columns.map((c) => c.key));
const requested = body.columns.filter((k) => allowedKeys.has(k));
if (requested.length === 0) {
return NextResponse.json(
{ error: `No valid columns selected for entity "${body.entity}"` },
{ status: 400 },
);
}
const filter = {
from: body.from ? new Date(body.from) : undefined,
to: body.to ? new Date(body.to) : undefined,
};
const rows = await def.runQuery({
portId: ctx.portId,
columns: requested,
filter,
});
// Money columns travel with a hidden sibling currency column so the
// client formatter can render `€1,234,567` instead of bare numbers
// even when the user didn't tick the currency column for display.
// The sibling is stripped from the meta-column list below (so the
// table doesn't render it twice) but survives in the row payload.
const MONEY_SIBLINGS: Record<string, string> = {
price: 'priceCurrency',
depositExpectedAmount: 'depositExpectedCurrency',
};
const siblingsToAttach = new Set<string>();
for (const k of requested) {
const sib = MONEY_SIBLINGS[k];
if (sib && allowedKeys.has(sib)) siblingsToAttach.add(sib);
}
const projectionKeys = [...requested, ...siblingsToAttach].filter(
(k, idx, arr) => arr.indexOf(k) === idx,
);
const projected = rows.map((row) => {
const out: Record<string, unknown> = {};
for (const k of projectionKeys) out[k] = row[k];
return out;
});
return NextResponse.json({
data: projected,
meta: {
entity: body.entity,
columns: requested.map((k) => ({
key: k,
label: def.columns.find((c) => c.key === k)?.label ?? k,
})),
rowCount: projected.length,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,123 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { renderToBuffer } from '@react-pdf/renderer';
import { createElement } from 'react';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { parseBody } from '@/lib/api/route-helpers';
import { absolutizeBrandingUrl } from '@/lib/branding/url';
import { getPortBrandingConfig } from '@/lib/services/port-config';
import { PayloadReportDocument } from '@/lib/pdf/reports/payload-report';
/**
* POST /api/v1/reports/export-pdf
*
* Generic PDF generator. Client posts a JSON `ReportPayload`; server
* resolves branding for the active port, renders the payload through
* the shared PayloadReportDocument, and streams back the PDF bytes.
*
* Used by every report's export-button dropdown ("Download PDF"
* option) so we don't have to keep adding routes per report kind.
*/
// Minimal shape validation — full ReportPayload is structurally typed
// in TS; here we just check it has the basic envelope.
const payloadSchema = z.object({
title: z.string().min(1),
description: z.string().optional(),
filenameSlug: z.string().min(1),
range: z.object({
from: z.string().datetime(),
to: z.string().datetime(),
}),
kpis: z.array(
z.object({
label: z.string(),
value: z.union([z.string(), z.number()]),
hint: z.string().optional(),
}),
),
sections: z.array(
z.object({
title: z.string(),
columns: z.array(
z.object({
key: z.string(),
label: z.string(),
align: z.enum(['left', 'right', 'center']).optional(),
}),
),
rows: z.array(z.record(z.string(), z.unknown())),
}),
),
/** Optional filename override (without extension) — the client
* passes the slug derived from the custom title. */
filenameOverride: z.string().optional(),
});
export const POST = withAuth(
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
try {
const body = await parseBody(req, payloadSchema);
// Resolve port branding (logo + primary color + name)
const portRow = await db.query.ports.findFirst({
where: eq(ports.id, ctx.portId),
columns: { name: true },
});
if (!portRow) throw new NotFoundError('Port');
const cfg = await getPortBrandingConfig(ctx.portId);
const branding = {
logoUrl: absolutizeBrandingUrl(cfg.logoUrl),
primaryColor: cfg.primaryColor,
portName: portRow.name,
};
const generatedAt = new Date().toISOString();
// Convert ISO date strings back to Date objects for the payload
// (client side serialised them through JSON).
const payload = {
...body,
range: {
from: new Date(body.range.from),
to: new Date(body.range.to),
},
// The format-callback isn't transferable through JSON; the
// PDF document falls back to formatPlain when undefined,
// which is the same default the CSV exporter falls back to.
sections: body.sections.map((s) => ({
...s,
columns: s.columns.map((c) => ({ ...c, format: undefined })),
})),
};
const element = createElement(PayloadReportDocument, {
payload,
branding,
generatedAt,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const buffer = await renderToBuffer(element as any);
const filename =
body.filenameOverride ??
`${body.filenameSlug}-${body.range.from.slice(0, 10)}_${body.range.to.slice(0, 10)}.pdf`;
return new NextResponse(buffer as unknown as BodyInit, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'no-store',
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,98 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import {
getOperationalKpis,
getUtilisationHeatmap,
getStatusMixOverTime,
getTenancyChurn,
getTenureDistribution,
getSigningBoxPlot,
getOccupancyByArea,
getDocumentsInPipeline,
getTenanciesEndingSoon,
getVacantBerths,
getStuckSigning,
getHighestValueVacant,
} from '@/lib/services/reports/operational.service';
const querySchema = z.object({
from: z.string().datetime().optional(),
to: z.string().datetime().optional(),
});
function resolveRange(from?: string, to?: string): { from: Date; to: Date } {
const now = new Date();
const defaultFrom = new Date(now);
defaultFrom.setDate(defaultFrom.getDate() - 30);
return {
from: from ? new Date(from) : defaultFrom,
to: to ? new Date(to) : now,
};
}
export const GET = withAuth(
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
try {
const params = req.nextUrl.searchParams;
const { from, to } = querySchema.parse({
from: params.get('from') ?? undefined,
to: params.get('to') ?? undefined,
});
const range = resolveRange(from, to);
const [
kpis,
utilisationHeatmap,
statusMix,
tenancyChurn,
tenureDistribution,
signingBoxPlot,
occupancyByArea,
docsInPipeline,
endingSoon,
vacantBerths,
stuckSigning,
highestValueVacant,
] = await Promise.all([
getOperationalKpis(ctx.portId, range),
getUtilisationHeatmap(ctx.portId),
getStatusMixOverTime(ctx.portId),
getTenancyChurn(ctx.portId),
getTenureDistribution(ctx.portId),
getSigningBoxPlot(ctx.portId),
getOccupancyByArea(ctx.portId),
getDocumentsInPipeline(ctx.portId),
getTenanciesEndingSoon(ctx.portId),
getVacantBerths(ctx.portId),
getStuckSigning(ctx.portId),
getHighestValueVacant(ctx.portId),
]);
return NextResponse.json({
data: {
kpis,
utilisationHeatmap,
statusMix,
tenancyChurn,
tenureDistribution,
signingBoxPlot,
occupancyByArea,
docsInPipeline,
endingSoon,
vacantBerths,
stuckSigning,
highestValueVacant,
range: {
from: range.from.toISOString(),
to: range.to.toISOString(),
},
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,161 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
import {
getSalesKpis,
getPipelineFunnel,
getStageVelocity,
getWinRateOverTime,
getSourceConversion,
getRepLeaderboard,
getDealHeat,
getRepPerformanceDetail,
getStalledDeals,
getClosingThisMonth,
getRecentWins,
getLostReasonBreakdown,
type SalesFilters,
} from '@/lib/services/reports/sales.service';
const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const;
const OUTCOMES = [
'won',
'lost_other_marina',
'lost_unqualified',
'lost_no_response',
'lost_other',
'cancelled',
] as const;
/**
* GET /api/v1/reports/sales?from=&to=
*
* Returns the Sales Performance report payload for the active port:
* the 7 KPI tiles + the pipeline funnel (chart 1). Further charts +
* tables ship on this same endpoint as they're built; the response
* shape grows additively under a single `data` envelope.
*
* Permission: `reports.view_dashboard` (same gate as the existing
* dashboard report endpoints; the Sales report is the canonical "for
* leadership" surface).
*/
const querySchema = z.object({
from: z.string().datetime().optional(),
to: z.string().datetime().optional(),
// CSV-style list params. Empty string → undefined → no filter.
stage: z.string().optional(),
leadCategory: z.string().optional(),
outcome: z.string().optional(),
});
/**
* Parse a CSV filter param into a typed allowlist. Unknown values are
* silently dropped — that way a stale bookmark with a removed enum
* value degrades to "no filter" instead of 400.
*/
function parseCsv<T extends string>(
raw: string | undefined,
allowed: ReadonlyArray<T>,
): T[] | undefined {
if (!raw) return undefined;
const parts = raw
.split(',')
.map((s) => s.trim())
.filter((s): s is T => (allowed as ReadonlyArray<string>).includes(s));
return parts.length > 0 ? parts : undefined;
}
function resolveRange(from?: string, to?: string): { from: Date; to: Date } {
const now = new Date();
// Defaults: trailing 30 days. Matches the "Last 30 days" preset on
// the date-range picker so a no-argument GET returns the same thing
// the default UI state shows.
const defaultFrom = new Date(now);
defaultFrom.setDate(defaultFrom.getDate() - 30);
return {
from: from ? new Date(from) : defaultFrom,
to: to ? new Date(to) : now,
};
}
export const GET = withAuth(
withPermission('reports', 'view_dashboard', async (req: NextRequest, ctx) => {
try {
const params = req.nextUrl.searchParams;
const { from, to, stage, leadCategory, outcome } = querySchema.parse({
from: params.get('from') ?? undefined,
to: params.get('to') ?? undefined,
stage: params.get('stage') ?? undefined,
leadCategory: params.get('leadCategory') ?? undefined,
outcome: params.get('outcome') ?? undefined,
});
const range = resolveRange(from, to);
const filters: SalesFilters | undefined = (() => {
const stages = parseCsv<PipelineStage>(stage, PIPELINE_STAGES);
const leadCategories = parseCsv<(typeof LEAD_CATEGORIES)[number]>(
leadCategory,
LEAD_CATEGORIES,
);
const outcomes = parseCsv<(typeof OUTCOMES)[number]>(outcome, OUTCOMES);
if (!stages && !leadCategories && !outcomes) return undefined;
return { stages, leadCategories, outcomes };
})();
const [
kpis,
funnel,
stageVelocity,
winRateOverTime,
sourceConversion,
repLeaderboard,
dealHeat,
repPerformanceDetail,
stalledDeals,
closingThisMonth,
recentWins,
lostReasonBreakdown,
] = await Promise.all([
getSalesKpis(ctx.portId, range),
getPipelineFunnel(ctx.portId),
getStageVelocity(ctx.portId),
getWinRateOverTime(ctx.portId, range),
getSourceConversion(ctx.portId),
getRepLeaderboard(ctx.portId, range),
getDealHeat(ctx.portId),
getRepPerformanceDetail(ctx.portId, range, filters),
getStalledDeals(ctx.portId, filters),
getClosingThisMonth(ctx.portId, filters),
getRecentWins(ctx.portId, filters),
getLostReasonBreakdown(ctx.portId, range, filters),
]);
return NextResponse.json({
data: {
kpis,
funnel,
stageVelocity,
winRateOverTime,
sourceConversion,
repLeaderboard,
dealHeat,
repPerformanceDetail,
stalledDeals,
closingThisMonth,
recentWins,
lostReasonBreakdown,
range: {
from: range.from.toISOString(),
to: range.to.toISOString(),
},
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -7,7 +7,12 @@ import { errorResponse } from '@/lib/errors';
import { createReportTemplate, listReportTemplates } from '@/lib/services/report-templates.service';
const createBodySchema = z.object({
kind: z.enum(['dashboard', 'clients', 'berths', 'interests']),
// 'sales' + 'operational' don't go through /api/v1/reports/generate;
// they're standalone report pages with their own routes. The config
// for these kinds is a thin view-state snapshot (date range +
// filters) that the report client applies on load. 'custom' is the
// ad-hoc composer's saved config — entity + columns + filter.
kind: z.enum(['dashboard', 'clients', 'berths', 'interests', 'sales', 'operational', 'custom']),
name: z.string().min(1).max(120),
description: z.string().max(400).nullable().optional(),
// Config is the raw discriminated-union payload; the