206 lines
6.4 KiB
TypeScript
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>
|
||
|
|
);
|
||
|
|
}
|