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';
+}