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>
This commit is contained in:
2026-05-21 20:46:52 +02:00
parent 47c2ba9a99
commit 1cdc2fdc6d
9 changed files with 655 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
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 {
deleteReportTemplate,
getReportTemplate,
updateReportTemplate,
} from '@/lib/services/report-templates.service';
const patchBodySchema = z
.object({
name: z.string().min(1).max(120).optional(),
description: z.string().max(400).nullable().optional(),
config: z.record(z.string(), z.unknown()).optional(),
})
.refine((v) => v.name !== undefined || v.description !== undefined || v.config !== undefined, {
message: 'At least one field must be provided',
});
/**
* GET — single template (used by the dialog to hydrate a saved
* template into the form when the rep picks it).
* PATCH — rename, retitle, or rewrite the config.
* DELETE — remove. Cascade-safe: no FKs reference a saved template.
*/
export const GET = withAuth(
withPermission('reports', 'export', async (_req, ctx, params) => {
try {
const row = await getReportTemplate(params.id!, ctx.portId);
return NextResponse.json({ data: row });
} catch (error) {
return errorResponse(error);
}
}),
);
export const PATCH = withAuth(
withPermission('reports', 'export', async (req, ctx, params) => {
try {
const body = await parseBody(req, patchBodySchema);
const row = await updateReportTemplate({
id: params.id!,
portId: ctx.portId,
name: body.name,
description: body.description,
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);
}
}),
);
export const DELETE = withAuth(
withPermission('reports', 'export', async (_req, ctx, params) => {
try {
await deleteReportTemplate(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,77 @@
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({
kind: z.enum(['dashboard', 'clients', 'berths', 'interests']),
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
* 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);
}
}),
);