Files
pn-new-crm/src/app/api/v1/reports/templates/route.ts

83 lines
3.1 KiB
TypeScript
Raw Normal View History

feat(reports): saved-template store + CRUD + dialog integration (phase C) Saves rep-configured export setups so a "Monthly board report" or "Weekly pipeline review" template only has to be assembled once. Schema (migration 0079_report_templates.sql + drizzle entry): - report_templates: id, port_id, kind, name, description, config (jsonb), created_by, created_at, updated_at. - Sibling-name uniqueness scoped (port_id, kind, LOWER(name)) so Port A and Port B can both have "Quarterly review" without colliding, and two different KINDS in the same port can share a name (a clients "Quarterly review" + an interests "Quarterly review" coexist). - port_id FK cascades on delete; templates evaporate with the parent port. No cross-port enumeration risk since every query filters by port_id. Service (src/lib/services/report-templates.service.ts): - createReportTemplate / listReportTemplates / getReportTemplate / updateReportTemplate / deleteReportTemplate. - Audit-logs every write with old/new values for the rename case. - Surfaces sibling-name collisions as ConflictError with a rep-readable message ('A "Monthly board report" template already exists for the dashboard kind'). Routes: - GET /api/v1/reports/templates?kind=clients - POST /api/v1/reports/templates - GET /api/v1/reports/templates/[id] - PATCH /api/v1/reports/templates/[id] - DELETE /api/v1/reports/templates/[id] All gated on `reports.export` — same permission as generating reports lets the rep manage the templates that drive them. POST cross-validates that `body.kind === body.config.kind` so a rep can't sneak a dashboard config into a clients template and confuse the rendering path at use time. UI: - SavedTemplatesPicker reusable component — dropdown of templates for this port + kind, inline "Save as template" toggle that expands to a name input + Save button, delete button next to the picker once a template is selected. - Wired into both ExportDashboardPdfButton + ExportListPdfButton. Applying a saved template hydrates the dialog's form (selected widgets / filters / title) from the saved config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:46:52 +02:00
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 { createReportTemplate, listReportTemplates } from '@/lib/services/report-templates.service';
const createBodySchema = z.object({
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>
2026-05-27 22:41:53 +02:00
// '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']),
feat(reports): saved-template store + CRUD + dialog integration (phase C) Saves rep-configured export setups so a "Monthly board report" or "Weekly pipeline review" template only has to be assembled once. Schema (migration 0079_report_templates.sql + drizzle entry): - report_templates: id, port_id, kind, name, description, config (jsonb), created_by, created_at, updated_at. - Sibling-name uniqueness scoped (port_id, kind, LOWER(name)) so Port A and Port B can both have "Quarterly review" without colliding, and two different KINDS in the same port can share a name (a clients "Quarterly review" + an interests "Quarterly review" coexist). - port_id FK cascades on delete; templates evaporate with the parent port. No cross-port enumeration risk since every query filters by port_id. Service (src/lib/services/report-templates.service.ts): - createReportTemplate / listReportTemplates / getReportTemplate / updateReportTemplate / deleteReportTemplate. - Audit-logs every write with old/new values for the rename case. - Surfaces sibling-name collisions as ConflictError with a rep-readable message ('A "Monthly board report" template already exists for the dashboard kind'). Routes: - GET /api/v1/reports/templates?kind=clients - POST /api/v1/reports/templates - GET /api/v1/reports/templates/[id] - PATCH /api/v1/reports/templates/[id] - DELETE /api/v1/reports/templates/[id] All gated on `reports.export` — same permission as generating reports lets the rep manage the templates that drive them. POST cross-validates that `body.kind === body.config.kind` so a rep can't sneak a dashboard config into a clients template and confuse the rendering path at use time. UI: - SavedTemplatesPicker reusable component — dropdown of templates for this port + kind, inline "Save as template" toggle that expands to a name input + Save button, delete button next to the picker once a template is selected. - Wired into both ExportDashboardPdfButton + ExportListPdfButton. Applying a saved template hydrates the dialog's form (selected widgets / filters / title) from the saved config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:46:52 +02:00
name: z.string().min(1).max(120),
description: z.string().max(400).nullable().optional(),
// Config is the raw discriminated-union payload; the
// /api/v1/reports/generate route re-validates at use time, so we
// accept it as `record` here without imposing the full union shape.
config: z.record(z.string(), z.unknown()),
});
/**
* GET /api/v1/reports/templates?kind=clients
* List saved templates for the active port, optionally filtered to
* one kind. Used by the Export dialog's saved-templates dropdown.
*
* POST /api/v1/reports/templates
* Persist a template. The dialog calls this when the rep ticks
* "Save as template" while configuring an export.
*
* Both gated on `reports.export` - the same permission that lets
feat(reports): saved-template store + CRUD + dialog integration (phase C) Saves rep-configured export setups so a "Monthly board report" or "Weekly pipeline review" template only has to be assembled once. Schema (migration 0079_report_templates.sql + drizzle entry): - report_templates: id, port_id, kind, name, description, config (jsonb), created_by, created_at, updated_at. - Sibling-name uniqueness scoped (port_id, kind, LOWER(name)) so Port A and Port B can both have "Quarterly review" without colliding, and two different KINDS in the same port can share a name (a clients "Quarterly review" + an interests "Quarterly review" coexist). - port_id FK cascades on delete; templates evaporate with the parent port. No cross-port enumeration risk since every query filters by port_id. Service (src/lib/services/report-templates.service.ts): - createReportTemplate / listReportTemplates / getReportTemplate / updateReportTemplate / deleteReportTemplate. - Audit-logs every write with old/new values for the rename case. - Surfaces sibling-name collisions as ConflictError with a rep-readable message ('A "Monthly board report" template already exists for the dashboard kind'). Routes: - GET /api/v1/reports/templates?kind=clients - POST /api/v1/reports/templates - GET /api/v1/reports/templates/[id] - PATCH /api/v1/reports/templates/[id] - DELETE /api/v1/reports/templates/[id] All gated on `reports.export` — same permission as generating reports lets the rep manage the templates that drive them. POST cross-validates that `body.kind === body.config.kind` so a rep can't sneak a dashboard config into a clients template and confuse the rendering path at use time. UI: - SavedTemplatesPicker reusable component — dropdown of templates for this port + kind, inline "Save as template" toggle that expands to a name input + Save button, delete button next to the picker once a template is selected. - Wired into both ExportDashboardPdfButton + ExportListPdfButton. Applying a saved template hydrates the dialog's form (selected widgets / filters / title) from the saved config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:46:52 +02:00
* the rep generate reports also lets them save templates.
*/
export const GET = withAuth(
withPermission('reports', 'export', async (req, ctx) => {
try {
const url = new URL(req.url);
const kind = url.searchParams.get('kind') ?? undefined;
const rows = await listReportTemplates(ctx.portId, kind ?? undefined);
return NextResponse.json({ data: rows });
} catch (error) {
return errorResponse(error);
}
}),
);
export const POST = withAuth(
withPermission('reports', 'export', async (req, ctx) => {
try {
const body = await parseBody(req, createBodySchema);
// Cross-validate that the config's discriminator matches the
// outer `kind`. Without this, a rep could save a clients-kind
// template with a dashboard config and confuse the rendering
// path at use time.
const configKind = (body.config as { kind?: unknown }).kind;
if (configKind !== body.kind) {
return NextResponse.json(
{ error: `config.kind must equal "${body.kind}"` },
{ status: 400 },
);
}
const row = await createReportTemplate({
portId: ctx.portId,
kind: body.kind,
name: body.name,
description: body.description ?? null,
config: body.config,
meta: {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
});
return NextResponse.json({ data: row });
} catch (error) {
return errorResponse(error);
}
}),
);