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:
97
src/app/api/v1/reports/custom/run/route.ts
Normal file
97
src/app/api/v1/reports/custom/run/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
123
src/app/api/v1/reports/export-pdf/route.ts
Normal file
123
src/app/api/v1/reports/export-pdf/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
98
src/app/api/v1/reports/operational/route.ts
Normal file
98
src/app/api/v1/reports/operational/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
161
src/app/api/v1/reports/sales/route.ts
Normal file
161
src/app/api/v1/reports/sales/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user