Two mechanical sweeps closing the audit's HIGH §16 + MED §11 findings: * 38 client components / 56 toast.error sites converted to toastError(err) so the new admin error inspector becomes usable from user-reported issues — every failed inline-edit, save, send, archive, upload, etc. now carries the request-id + error-code (Copy ID action). * 26 service files / 62 bare-Error throws converted to CodedError or the existing AppError subclasses. Adds new error codes: DOCUMENSO_UPSTREAM_ERROR (502), DOCUMENSO_AUTH_FAILURE (502), DOCUMENSO_TIMEOUT (504), OCR_UPSTREAM_ERROR (502), IMAP_UPSTREAM_ERROR (502), UMAMI_UPSTREAM_ERROR (502), UMAMI_NOT_CONFIGURED (409), and INSERT_RETURNING_EMPTY (500) for post-insert returning-empty guards. * Five vitest assertions updated to match the new user-facing wording (client-merge "already been merged", expense/interest "couldn't find that …", documenso "signing service didn't respond"). Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md HIGH §16 (auditor-H Issue 1) + MED §11 (auditor-G Issue 1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
245 lines
8.2 KiB
TypeScript
245 lines
8.2 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useMutation } from '@tanstack/react-query';
|
|
import { Plus, 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 { Switch } from '@/components/ui/switch';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { toastError } from '@/lib/api/toast-error';
|
|
import type { FormField } from '@/lib/validators/form-templates';
|
|
|
|
interface FormTemplate {
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
fields: FormField[];
|
|
isActive: boolean;
|
|
}
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
template: FormTemplate | null;
|
|
onSaved: () => void;
|
|
}
|
|
|
|
const DEFAULT_FIELD: FormField = {
|
|
key: '',
|
|
label: '',
|
|
type: 'text',
|
|
required: false,
|
|
};
|
|
|
|
const FIELD_TYPES: Array<{ value: FormField['type']; label: string }> = [
|
|
{ value: 'text', label: 'Text' },
|
|
{ value: 'textarea', label: 'Long text' },
|
|
{ value: 'email', label: 'Email' },
|
|
{ value: 'phone', label: 'Phone' },
|
|
{ value: 'number', label: 'Number' },
|
|
{ value: 'select', label: 'Select' },
|
|
{ value: 'checkbox', label: 'Checkbox' },
|
|
];
|
|
|
|
export function FormTemplateForm({ open, onOpenChange, template, onSaved }: Props) {
|
|
const [name, setName] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [isActive, setIsActive] = useState(true);
|
|
const [fields, setFields] = useState<FormField[]>([{ ...DEFAULT_FIELD }]);
|
|
|
|
useEffect(() => {
|
|
if (template) {
|
|
setName(template.name);
|
|
setDescription(template.description ?? '');
|
|
setIsActive(template.isActive);
|
|
setFields(template.fields.length > 0 ? template.fields : [{ ...DEFAULT_FIELD }]);
|
|
} else {
|
|
setName('');
|
|
setDescription('');
|
|
setIsActive(true);
|
|
setFields([{ ...DEFAULT_FIELD }]);
|
|
}
|
|
}, [template, open]);
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: () => {
|
|
const payload = {
|
|
name,
|
|
description: description || undefined,
|
|
fields,
|
|
isActive,
|
|
};
|
|
if (template) {
|
|
return apiFetch(`/api/v1/admin/form-templates/${template.id}`, {
|
|
method: 'PATCH',
|
|
body: payload,
|
|
});
|
|
}
|
|
return apiFetch('/api/v1/admin/form-templates', {
|
|
method: 'POST',
|
|
body: payload,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
toast.success(template ? 'Template saved' : 'Template created');
|
|
onSaved();
|
|
onOpenChange(false);
|
|
},
|
|
onError: (err) => toastError(err),
|
|
});
|
|
|
|
function updateField(idx: number, patch: Partial<FormField>) {
|
|
setFields((prev) => prev.map((f, i) => (i === idx ? { ...f, ...patch } : f)));
|
|
}
|
|
|
|
function addField() {
|
|
setFields((prev) => [...prev, { ...DEFAULT_FIELD }]);
|
|
}
|
|
|
|
function removeField(idx: number) {
|
|
setFields((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== idx)));
|
|
}
|
|
|
|
return (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent className="overflow-y-auto sm:max-w-2xl">
|
|
<SheetHeader>
|
|
<SheetTitle>{template ? 'Edit form template' : 'New form template'}</SheetTitle>
|
|
</SheetHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="space-y-1">
|
|
<Label>Name</Label>
|
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Description</Label>
|
|
<Textarea
|
|
rows={2}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Switch checked={isActive} onCheckedChange={setIsActive} />
|
|
<Label>Active</Label>
|
|
</div>
|
|
|
|
<div className="border-t pt-3 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-sm font-medium">Fields</Label>
|
|
<Button variant="outline" size="sm" onClick={addField}>
|
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
|
Add field
|
|
</Button>
|
|
</div>
|
|
{fields.map((f, i) => (
|
|
<div key={i} className="rounded-md border p-3 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-muted-foreground">Field {i + 1}</span>
|
|
{fields.length > 1 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-destructive h-7 w-7"
|
|
onClick={() => removeField(i)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Key (no spaces)</Label>
|
|
<Input
|
|
value={f.key}
|
|
onChange={(e) => updateField(i, { key: e.target.value })}
|
|
placeholder="email"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Label</Label>
|
|
<Input
|
|
value={f.label}
|
|
onChange={(e) => updateField(i, { label: e.target.value })}
|
|
placeholder="Email address"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Type</Label>
|
|
<Select
|
|
value={f.type}
|
|
onValueChange={(v) => updateField(i, { type: v as FormField['type'] })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{FIELD_TYPES.map((ft) => (
|
|
<SelectItem key={ft.value} value={ft.value}>
|
|
{ft.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1 flex items-end gap-2 pb-1">
|
|
<Switch
|
|
checked={!!f.required}
|
|
onCheckedChange={(v) => updateField(i, { required: v })}
|
|
/>
|
|
<Label className="text-xs">Required</Label>
|
|
</div>
|
|
</div>
|
|
{f.type === 'select' && (
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Options (comma-separated)</Label>
|
|
<Input
|
|
value={(f.options ?? []).join(', ')}
|
|
onChange={(e) =>
|
|
updateField(i, {
|
|
options: e.target.value
|
|
.split(',')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean),
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<SheetFooter>
|
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => saveMutation.mutate()}
|
|
disabled={
|
|
saveMutation.isPending ||
|
|
!name.trim() ||
|
|
fields.some((f) => !f.key.trim() || !f.label.trim())
|
|
}
|
|
>
|
|
{saveMutation.isPending ? 'Saving…' : 'Save template'}
|
|
</Button>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|