import { useState, type ReactNode } from "react"; import { GripVertical, Loader2, Plus, Trash2 } from "lucide-react"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import type { CreateFileUploadFieldDto, FileUploadSetting, } from "@/types/fileUploadSettings"; import { getMinFiles } from "@/types/fileUploadSettings"; import { useMutation } from "@tanstack/react-query"; import { api } from "@/services/api"; export interface ManageFileUploadFieldsDialogProps { setting: FileUploadSetting; children: ReactNode; } /** * Local draft used by the editor — does NOT need to satisfy IFileUploadField * (which carries server-only props like createdAt). On save, we strip the * client-only `key` and post the rest as CreateFileUploadFieldDto[]. */ interface DraftField extends CreateFileUploadFieldDto { key: string; } let draftCounter = 0; const nextKey = () => `draft-${Date.now()}-${++draftCounter}`; function makeEmptyDraft(idx: number): DraftField { return { key: nextKey(), fileKey: "", fileLabel: "", isRequired: false, isMultiple: false, maxFiles: 1, allowedExtensions: ["pdf"], maxSizeMb: 10, order: idx + 1, }; } export default function ManageFileUploadFieldsDialog({ setting, children, }: ManageFileUploadFieldsDialogProps) { const [open, setOpen] = useState(false); const [error, setError] = useState(null); const seed = (): DraftField[] => [...setting.fields] .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) .map((f, idx) => ({ key: f.id, fileKey: f.fileKey, fileLabel: f.fileLabel, helpText: f.helpText ?? undefined, isRequired: f.isRequired, isMultiple: f.isMultiple, maxFiles: f.maxFiles, allowedExtensions: f.allowedExtensions, maxSizeMb: f.maxSizeMb, order: f.order ?? idx + 1, })); const [fields, setFields] = useState(seed); const replaceMutation = useMutation( api.fileUploadSettings.replaceFields.mutationOptions(), ); const update = (i: number, patch: Partial) => setFields((prev) => prev.map((f, idx) => { if (idx !== i) return f; const next = { ...f, ...patch }; if (patch.isMultiple === false) next.maxFiles = 1; if (patch.isMultiple === true && next.maxFiles <= 1) next.maxFiles = 5; return next; }), ); const updateExtensions = (i: number, raw: string) => { const list = raw .split(",") .map((s) => s.trim().toLowerCase().replace(/^\./, "")) .filter(Boolean); update(i, { allowedExtensions: list }); }; const remove = (i: number) => setFields((prev) => prev.filter((_, idx) => idx !== i)); const add = () => setFields((prev) => [...prev, makeEmptyDraft(prev.length)]); const move = (i: number, dir: -1 | 1) => setFields((prev) => { const next = [...prev]; const target = i + dir; if (target < 0 || target >= next.length) return prev; const a = next[i] as DraftField; const b = next[target] as DraftField; next[i] = { ...b, order: i + 1 }; next[target] = { ...a, order: target + 1 }; return next; }); const handleSave = () => { setError(null); const invalid = fields.findIndex( (f) => !f.fileKey.trim() || !f.fileLabel.trim() || f.allowedExtensions.length === 0, ); if (invalid >= 0) { setError( `Field ${invalid + 1} is missing file key, label, or extensions.`, ); return; } const payload: CreateFileUploadFieldDto[] = fields.map((f, idx) => ({ fileKey: f.fileKey.trim(), fileLabel: f.fileLabel.trim(), helpText: f.helpText?.trim() || undefined, isRequired: f.isRequired, isMultiple: f.isMultiple, maxFiles: f.isMultiple ? Math.max(1, f.maxFiles) : 1, allowedExtensions: f.allowedExtensions, maxSizeMb: f.maxSizeMb, order: idx + 1, })); replaceMutation.mutate( { id: setting.id, fields: payload }, { onSuccess: () => setOpen(false), onError: (err) => setError( err instanceof Error ? err.message : "Failed to save fields. Try again.", ), }, ); }; return ( { setOpen(next); if (next) { setFields(seed()); setError(null); } }} > {children} Manage Fields · {setting.label} Add, edit, reorder, or remove upload fields for{" "} {setting.code}.

{fields.length} field{fields.length === 1 ? "" : "s"}

{fields.length === 0 ? (
No fields yet. Click{" "} Add Field to start.
) : (
{fields.map((f, i) => ( update(i, patch)} onChangeExtensions={(raw) => updateExtensions(i, raw)} onMove={(dir) => move(i, dir)} onRemove={() => remove(i)} /> ))}
)}
{error ? (

{error}

) : null}
); } function FieldEditor({ field, index, total, onChange, onChangeExtensions, onMove, onRemove, }: { field: DraftField; index: number; total: number; onChange: (patch: Partial) => void; onChangeExtensions: (raw: string) => void; onMove: (dir: -1 | 1) => void; onRemove: () => void; }) { const minFiles = getMinFiles(field); const effectiveMax = field.isMultiple ? field.maxFiles : 1; return (
Field {index + 1} min {minFiles} · max {effectiveMax}
onChange({ fileKey: e.target.value })} placeholder="supporting_doc" className="font-mono" />
onChange({ fileLabel: e.target.value })} placeholder="Supporting Document" />
onChange({ maxSizeMb: Number(e.target.value) }) } />
onChangeExtensions(e.target.value)} placeholder="pdf, docx, jpg" className="font-mono" />

Comma-separated, no leading dot.

onChange({ maxFiles: Number(e.target.value) }) } className={!field.isMultiple ? "bg-slate-50 text-slate-400" : ""} /> {!field.isMultiple ? (

Locked to 1 when single-file.

) : null}
onChange({ helpText: e.target.value })} placeholder="e.g. PDF or photo of the original document." />
); }