Files
pn-new-crm/src/components/reports/saved-templates-picker.tsx
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

206 lines
6.4 KiB
TypeScript

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