diff --git a/src/app/api/v1/reports/templates/[id]/route.ts b/src/app/api/v1/reports/templates/[id]/route.ts new file mode 100644 index 00000000..ac2151f7 --- /dev/null +++ b/src/app/api/v1/reports/templates/[id]/route.ts @@ -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); + } + }), +); diff --git a/src/app/api/v1/reports/templates/route.ts b/src/app/api/v1/reports/templates/route.ts new file mode 100644 index 00000000..3f3a9756 --- /dev/null +++ b/src/app/api/v1/reports/templates/route.ts @@ -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); + } + }), +); diff --git a/src/components/reports/export-dashboard-pdf-button.tsx b/src/components/reports/export-dashboard-pdf-button.tsx index ea93836f..6d1c87ef 100644 --- a/src/components/reports/export-dashboard-pdf-button.tsx +++ b/src/components/reports/export-dashboard-pdf-button.tsx @@ -23,6 +23,7 @@ import { import { triggerBlobDownload } from '@/lib/utils/download'; import { usePermissions } from '@/hooks/use-permissions'; import { resolvePortIdFromSlug } from '@/lib/api/client'; +import { SavedTemplatesPicker, type SavedTemplate } from './saved-templates-picker'; /** * Dashboard "Export as PDF" affordance. Per-export dialog lets reps @@ -113,6 +114,21 @@ export function ExportDashboardPdfButton() {
+ { + const cfg = t.config as { widgetIds?: string[] }; + if (Array.isArray(cfg.widgetIds)) { + setSelected( + cfg.widgetIds.filter((id): id is PdfDashboardWidgetId => + PDF_DASHBOARD_WIDGETS.some((w) => w.id === id), + ), + ); + } + if (t.name) setTitle(t.name); + }} + />
setTitle(e.target.value)} /> diff --git a/src/components/reports/export-list-pdf-button.tsx b/src/components/reports/export-list-pdf-button.tsx index ff9a37ed..276f6b22 100644 --- a/src/components/reports/export-list-pdf-button.tsx +++ b/src/components/reports/export-list-pdf-button.tsx @@ -19,6 +19,7 @@ import { import { triggerBlobDownload } from '@/lib/utils/download'; import { usePermissions } from '@/hooks/use-permissions'; import { resolvePortIdFromSlug } from '@/lib/api/client'; +import { SavedTemplatesPicker, type SavedTemplate } from './saved-templates-picker'; type ListKind = 'clients' | 'berths' | 'interests'; @@ -110,6 +111,17 @@ export function ExportListPdfButton({ kind, buttonLabel = 'Export PDF', defaultT
+ { + const cfg = t.config as { filters?: { includeArchived?: boolean } }; + if (cfg.filters?.includeArchived !== undefined) { + setIncludeArchived(Boolean(cfg.filters.includeArchived)); + } + if (t.name) setTitle(t.name); + }} + />
; +} + +interface Props { + kind: 'dashboard' | 'clients' | 'berths' | 'interests'; + /** Called when the rep picks a template from the dropdown — the + * parent hydrates its form from the returned config. */ + onApply: (template: SavedTemplate) => void; + /** Used by the "Save as template" toggle to capture the current + * dialog state when the rep checks the box. */ + currentConfig: Record; + /** When set, allow saving the current dialog state as a new + * template. The dialog manages the toggle + name input + * inline so reps don't need to leave the export flow. */ + showSave?: boolean; +} + +/** + * Reusable Saved-templates picker. Mounted at the top of both + * dashboard / list export dialogs. + * + * - Dropdown lists existing templates for this port + kind. + * - "Save as template" expands to a name field + Save button when + * showSave is true. + * - Delete action sits next to the dropdown when a template is + * selected, so the rep can prune the list without leaving the + * dialog. + */ +export function SavedTemplatesPicker({ kind, onApply, currentConfig, showSave = true }: Props) { + const qc = useQueryClient(); + const [selectedId, setSelectedId] = useState(''); + const [saving, setSaving] = useState(false); + const [saveName, setSaveName] = useState(''); + const [saveOpen, setSaveOpen] = useState(false); + + const { data, isLoading } = useQuery<{ data: SavedTemplate[] }>({ + queryKey: ['report-templates', kind], + queryFn: () => + apiFetch<{ data: SavedTemplate[] }>( + `/api/v1/reports/templates?kind=${encodeURIComponent(kind)}`, + ), + staleTime: 30_000, + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + await apiFetch(`/api/v1/reports/templates/${id}`, { method: 'DELETE' }); + }, + onSuccess: () => { + toast.success('Template deleted'); + setSelectedId(''); + void qc.invalidateQueries({ queryKey: ['report-templates', kind] }); + }, + onError: (err) => toastError(err), + }); + + async function handleSave() { + if (!saveName.trim()) return; + setSaving(true); + try { + await apiFetch('/api/v1/reports/templates', { + method: 'POST', + body: { + kind, + name: saveName.trim(), + config: { ...currentConfig, kind }, + }, + }); + toast.success('Template saved'); + setSaveName(''); + setSaveOpen(false); + void qc.invalidateQueries({ queryKey: ['report-templates', kind] }); + } catch (err) { + toastError(err); + } finally { + setSaving(false); + } + } + + function handleApply(id: string) { + setSelectedId(id); + const template = data?.data.find((t) => t.id === id); + if (template) onApply(template); + } + + const templates = data?.data ?? []; + + return ( +
+ +
+ + {selectedId ? ( + + ) : null} +
+ {showSave ? ( + saveOpen ? ( +
+ setSaveName(e.target.value)} + placeholder="Template name" + className="h-8" + /> + + +
+ ) : ( + + ) + ) : null} +
+ ); +} diff --git a/src/lib/db/migrations/0079_report_templates.sql b/src/lib/db/migrations/0079_report_templates.sql new file mode 100644 index 00000000..1fdcb4af --- /dev/null +++ b/src/lib/db/migrations/0079_report_templates.sql @@ -0,0 +1,35 @@ +-- Saved-template store for the PDF report exporter. +-- +-- Each row holds a named, port-scoped report configuration that +-- admins / sales-managers can save once and reps re-run with a +-- single click ("Monthly board report", "Weekly pipeline review", +-- etc.). The `config` JSONB matches the discriminated-union shape +-- the route schema enforces, so the same `kind` + `widgetIds` / +-- `columns` / `filters` keys round-trip cleanly. +-- +-- Apply in dev: +-- PGPASSWORD=changeme psql -h localhost -p 5434 -U crm \ +-- -d port_nimara_crm -f src/lib/db/migrations/0079_report_templates.sql + +CREATE TABLE IF NOT EXISTS report_templates ( + id text PRIMARY KEY, + port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE, + kind text NOT NULL, + name text NOT NULL, + description text, + config jsonb NOT NULL, + created_by text NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_report_templates_port + ON report_templates (port_id); + +CREATE INDEX IF NOT EXISTS idx_report_templates_port_kind + ON report_templates (port_id, kind); + +-- Sibling-name uniqueness within a port + kind so reps don't end up +-- with two "Monthly board report" templates of the same kind. +CREATE UNIQUE INDEX IF NOT EXISTS uniq_report_templates_port_kind_name + ON report_templates (port_id, kind, LOWER(name)); diff --git a/src/lib/db/schema/index.ts b/src/lib/db/schema/index.ts index 7c2736d9..220a95aa 100644 --- a/src/lib/db/schema/index.ts +++ b/src/lib/db/schema/index.ts @@ -74,6 +74,9 @@ export * from './supplemental-forms'; // Pipeline refactor — qualification criteria, payment records export * from './pipeline'; +// Saved PDF-report templates (`/api/v1/reports/templates`). +export * from './reports'; + // Relations (must come last - references all tables) export * from './relations'; export * from './tracked-links'; diff --git a/src/lib/db/schema/reports.ts b/src/lib/db/schema/reports.ts new file mode 100644 index 00000000..60991ae9 --- /dev/null +++ b/src/lib/db/schema/reports.ts @@ -0,0 +1,53 @@ +import { index, jsonb, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; + +import { ports } from './ports'; + +/** + * Saved report templates. Each row captures a named, port-scoped + * configuration that backs the "Saved templates" dropdown in the + * Export-as-PDF dialog. The `config` JSONB matches the discriminated- + * union shape the route schema enforces, so reusing a template is a + * straight pass-through to /api/v1/reports/generate. + * + * Migration: 0079_report_templates.sql. + */ +export const reportTemplates = pgTable( + 'report_templates', + { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + portId: text('port_id') + .notNull() + .references(() => ports.id, { onDelete: 'cascade' }), + /** Mirrors the discriminator on ReportConfig — 'dashboard' | + * 'clients' | 'berths' | 'interests'. Validated at the route + * layer. */ + kind: text('kind').notNull(), + name: text('name').notNull(), + description: text('description'), + /** Untyped at the Drizzle layer; route + service layers validate + * via the same zod schemas they use on `/api/v1/reports/generate`. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + config: jsonb('config').$type>().notNull(), + createdBy: text('created_by').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index('idx_report_templates_port').on(table.portId), + index('idx_report_templates_port_kind').on(table.portId, table.kind), + // Sibling-name uniqueness per port+kind. The lower() expression in + // the SQL migration mirrors how case-insensitive collisions are + // handled elsewhere in the schema. + uniqueIndex('uniq_report_templates_port_kind_name').on( + table.portId, + table.kind, + sql`LOWER(${table.name})`, + ), + ], +); + +export type ReportTemplate = typeof reportTemplates.$inferSelect; +export type NewReportTemplate = typeof reportTemplates.$inferInsert; diff --git a/src/lib/services/report-templates.service.ts b/src/lib/services/report-templates.service.ts new file mode 100644 index 00000000..229441b6 --- /dev/null +++ b/src/lib/services/report-templates.service.ts @@ -0,0 +1,176 @@ +import { and, asc, eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { reportTemplates, type ReportTemplate } from '@/lib/db/schema/reports'; +import { ConflictError, NotFoundError } from '@/lib/errors'; +import { createAuditLog, type AuditMeta } from '@/lib/audit'; + +/** + * CRUD for saved PDF-report templates. Multi-tenant safe — every + * query carries `port_id = ctx.portId` and the unique sibling-name + * index is `(port_id, kind, LOWER(name))`, so two different ports + * can both have a "Monthly board report" template without colliding. + * + * The `config` field is opaque to this service; the route layer + * validates it via the same zod discriminated-union schema it uses + * on `/api/v1/reports/generate`, then passes the parsed value + * through. + */ + +export interface CreateReportTemplateInput { + portId: string; + kind: string; + name: string; + description?: string | null; + config: Record; + meta: AuditMeta; +} + +export async function createReportTemplate( + input: CreateReportTemplateInput, +): Promise { + try { + const [row] = await db + .insert(reportTemplates) + .values({ + portId: input.portId, + kind: input.kind, + name: input.name, + description: input.description ?? null, + config: input.config, + createdBy: input.meta.userId, + }) + .returning(); + if (!row) throw new Error('createReportTemplate: insert returned no row'); + + void createAuditLog({ + portId: input.portId, + userId: input.meta.userId, + action: 'create', + entityType: 'report_template', + entityId: row.id, + newValue: { kind: row.kind, name: row.name }, + ipAddress: input.meta.ipAddress, + userAgent: input.meta.userAgent, + }); + + return row; + } catch (err) { + if (isSiblingNameConflict(err)) { + throw new ConflictError( + `A "${input.name}" template already exists for the ${input.kind} kind. Pick a different name.`, + ); + } + throw err; + } +} + +export async function listReportTemplates( + portId: string, + kind?: string, +): Promise { + const whereClause = kind + ? and(eq(reportTemplates.portId, portId), eq(reportTemplates.kind, kind)) + : eq(reportTemplates.portId, portId); + return db + .select() + .from(reportTemplates) + .where(whereClause) + .orderBy(asc(reportTemplates.kind), asc(reportTemplates.name)); +} + +export async function getReportTemplate(id: string, portId: string): Promise { + const row = await db.query.reportTemplates.findFirst({ + where: and(eq(reportTemplates.id, id), eq(reportTemplates.portId, portId)), + }); + if (!row) throw new NotFoundError('Report template'); + return row; +} + +export interface UpdateReportTemplateInput { + id: string; + portId: string; + name?: string; + description?: string | null; + config?: Record; + meta: AuditMeta; +} + +export async function updateReportTemplate( + input: UpdateReportTemplateInput, +): Promise { + const existing = await getReportTemplate(input.id, input.portId); + const patch: Partial = { + updatedAt: new Date(), + }; + if (input.name !== undefined) patch.name = input.name; + if (input.description !== undefined) patch.description = input.description; + if (input.config !== undefined) patch.config = input.config; + + try { + const [row] = await db + .update(reportTemplates) + .set(patch) + .where(and(eq(reportTemplates.id, input.id), eq(reportTemplates.portId, input.portId))) + .returning(); + if (!row) throw new NotFoundError('Report template'); + + void createAuditLog({ + portId: input.portId, + userId: input.meta.userId, + action: 'update', + entityType: 'report_template', + entityId: row.id, + oldValue: { name: existing.name, description: existing.description }, + newValue: { name: row.name, description: row.description }, + ipAddress: input.meta.ipAddress, + userAgent: input.meta.userAgent, + }); + + return row; + } catch (err) { + if (isSiblingNameConflict(err)) { + throw new ConflictError( + `A template with that name already exists for the ${existing.kind} kind.`, + ); + } + throw err; + } +} + +export async function deleteReportTemplate( + id: string, + portId: string, + meta: AuditMeta, +): Promise { + const existing = await getReportTemplate(id, portId); + await db + .delete(reportTemplates) + .where(and(eq(reportTemplates.id, id), eq(reportTemplates.portId, portId))); + + void createAuditLog({ + portId, + userId: meta.userId, + action: 'delete', + entityType: 'report_template', + entityId: id, + oldValue: { kind: existing.kind, name: existing.name }, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); +} + +function isSiblingNameConflict(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const e = err as { + code?: unknown; + constraint_name?: unknown; + constraint?: unknown; + cause?: { code?: unknown; constraint_name?: unknown; constraint?: unknown }; + }; + const code = e.code ?? e.cause?.code; + if (code !== '23505') return false; + const constraint = + e.constraint_name ?? e.constraint ?? e.cause?.constraint_name ?? e.cause?.constraint; + return constraint === 'uniq_report_templates_port_kind_name'; +}