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:
35
src/lib/db/migrations/0079_report_templates.sql
Normal file
35
src/lib/db/migrations/0079_report_templates.sql
Normal file
@@ -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));
|
||||
@@ -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';
|
||||
|
||||
53
src/lib/db/schema/reports.ts
Normal file
53
src/lib/db/schema/reports.ts
Normal file
@@ -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<Record<string, any>>().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;
|
||||
Reference in New Issue
Block a user