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:
@@ -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)} />
|
||||
|
||||
@@ -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
|
||||
|
||||
205
src/components/reports/saved-templates-picker.tsx
Normal file
205
src/components/reports/saved-templates-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user