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,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));