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

@@ -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() {
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<SavedTemplatesPicker
kind="dashboard"
currentConfig={{ widgetIds: selected }}
onApply={(t: SavedTemplate) => {
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);
}}
/>
<div className="space-y-1">
<Label htmlFor="export-title">Title</Label>
<Input id="export-title" value={title} onChange={(e) => setTitle(e.target.value)} />

View File

@@ -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
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<SavedTemplatesPicker
kind={kind}
currentConfig={{ filters: { includeArchived } }}
onApply={(t: SavedTemplate) => {
const cfg = t.config as { filters?: { includeArchived?: boolean } };
if (cfg.filters?.includeArchived !== undefined) {
setIncludeArchived(Boolean(cfg.filters.includeArchived));
}
if (t.name) setTitle(t.name);
}}
/>
<div className="space-y-1">
<Label htmlFor={`export-title-${kind}`}>Title</Label>
<Input

View File

@@ -0,0 +1,205 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Bookmark, Loader2, Save, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
export interface SavedTemplate {
id: string;
portId: string;
kind: string;
name: string;
description: string | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
config: Record<string, any>;
}
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<string, unknown>;
/** 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<string>('');
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 (
<div className="space-y-2">
<Label className="flex items-center gap-1.5 text-xs uppercase tracking-wide text-muted-foreground">
<Bookmark className="h-3.5 w-3.5" aria-hidden />
Saved templates
</Label>
<div className="flex items-center gap-2">
<Select value={selectedId} onValueChange={handleApply} disabled={isLoading}>
<SelectTrigger className="flex-1">
<SelectValue
placeholder={
isLoading
? 'Loading…'
: templates.length === 0
? 'No saved templates yet'
: 'Choose a saved template'
}
/>
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedId ? (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => deleteMutation.mutate(selectedId)}
disabled={deleteMutation.isPending}
aria-label="Delete template"
title="Delete this saved template"
>
<Trash2 className="h-4 w-4 text-destructive" aria-hidden />
</Button>
) : null}
</div>
{showSave ? (
saveOpen ? (
<div className="flex items-center gap-2">
<Input
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
placeholder="Template name"
className="h-8"
/>
<Button
type="button"
size="sm"
onClick={handleSave}
disabled={!saveName.trim() || saving}
>
{saving ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<Save className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
Save
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setSaveOpen(false);
setSaveName('');
}}
disabled={saving}
>
Cancel
</Button>
</div>
) : (
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={false}
onCheckedChange={(c) => setSaveOpen(Boolean(c))}
aria-label="Save current config as a template"
/>
Save the current configuration as a template
</label>
)
) : null}
</div>
);
}