mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 19:30:57 +00:00
467 lines
15 KiB
TypeScript
467 lines
15 KiB
TypeScript
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}`;
|
|
|
|
/**
|
|
* Extensions offered as checkboxes. Mirrors DOC_EXTENSIONS in the API's
|
|
* file-upload-settings seeder — the only formats the document flows accept.
|
|
*
|
|
* The API validates `allowedExtensions` as plain strings, so free text let
|
|
* typos ("pd") through silently and the field then rejected every real upload.
|
|
* A fixed list makes that unrepresentable.
|
|
*/
|
|
const FILE_EXTENSION_OPTIONS = ["pdf", "jpg", "jpeg", "png"] as const;
|
|
|
|
const KNOWN_EXTENSIONS = new Set<string>(FILE_EXTENSION_OPTIONS);
|
|
|
|
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<string | null>(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<DraftField[]>(seed);
|
|
|
|
const replaceMutation = useMutation(
|
|
api.fileUploadSettings.replaceFields.mutationOptions(),
|
|
);
|
|
|
|
const update = (i: number, patch: Partial<DraftField>) =>
|
|
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 toggleExtension = (i: number, ext: string, checked: boolean) =>
|
|
setFields((prev) =>
|
|
prev.map((f, idx) => {
|
|
if (idx !== i) return f;
|
|
const current = f.allowedExtensions;
|
|
if (checked) {
|
|
return current.includes(ext)
|
|
? f
|
|
: { ...f, allowedExtensions: [...current, ext] };
|
|
}
|
|
return { ...f, allowedExtensions: current.filter((e) => e !== ext) };
|
|
}),
|
|
);
|
|
|
|
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 (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
setOpen(next);
|
|
if (next) {
|
|
setFields(seed());
|
|
setError(null);
|
|
}
|
|
}}
|
|
>
|
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
|
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl rounded-3xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-2xl font-bold">
|
|
Manage Fields · {setting.label}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Add, edit, reorder, or remove upload fields for{" "}
|
|
<span className="font-mono text-slate-700">{setting.code}</span>.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-3 py-4">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-sm text-slate-500">
|
|
{fields.length} field{fields.length === 1 ? "" : "s"}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={add}
|
|
className="inline-flex items-center gap-1.5 rounded-xl bg-[#10B981] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-[#10B981]/90"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
Add Field
|
|
</button>
|
|
</div>
|
|
|
|
{fields.length === 0 ? (
|
|
<div className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
|
No fields yet. Click{" "}
|
|
<span className="font-medium">Add Field</span> to start.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{fields.map((f, i) => (
|
|
<FieldEditor
|
|
key={f.key}
|
|
field={f}
|
|
index={i}
|
|
total={fields.length}
|
|
onChange={(patch) => update(i, patch)}
|
|
onToggleExtension={(ext, checked) =>
|
|
toggleExtension(i, ext, checked)
|
|
}
|
|
onMove={(dir) => move(i, dir)}
|
|
onRemove={() => remove(i)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{error ? (
|
|
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
|
|
<div className="mt-2 flex justify-end gap-3 border-t border-slate-100 pt-3">
|
|
<DialogClose asChild>
|
|
<Button variant="outline" disabled={replaceMutation.isPending}>
|
|
Cancel
|
|
</Button>
|
|
</DialogClose>
|
|
<Button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={replaceMutation.isPending}
|
|
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
|
>
|
|
{replaceMutation.isPending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
"Save Fields"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function FieldEditor({
|
|
field,
|
|
index,
|
|
total,
|
|
onChange,
|
|
onToggleExtension,
|
|
onMove,
|
|
onRemove,
|
|
}: {
|
|
field: DraftField;
|
|
index: number;
|
|
total: number;
|
|
onChange: (patch: Partial<DraftField>) => void;
|
|
onToggleExtension: (ext: string, checked: boolean) => void;
|
|
onMove: (dir: -1 | 1) => void;
|
|
onRemove: () => void;
|
|
}) {
|
|
const minFiles = getMinFiles(field);
|
|
const effectiveMax = field.isMultiple ? field.maxFiles : 1;
|
|
|
|
// A field saved before this list existed can hold anything the old free-text
|
|
// box accepted (e.g. the typo "pd"). Show those alongside the standard ones so
|
|
// they stay visible and removable instead of silently vanishing on save.
|
|
const extensionChoices = [
|
|
...FILE_EXTENSION_OPTIONS,
|
|
...field.allowedExtensions.filter(
|
|
(ext) => !KNOWN_EXTENSIONS.has(ext),
|
|
),
|
|
];
|
|
|
|
return (
|
|
<div className="rounded-2xl border border-slate-200 bg-white p-4">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<div className="flex items-center gap-2 text-slate-400">
|
|
<GripVertical className="h-4 w-4" />
|
|
<div className="flex flex-col">
|
|
<button
|
|
type="button"
|
|
onClick={() => onMove(-1)}
|
|
aria-label="Move up"
|
|
disabled={index === 0}
|
|
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
|
|
>
|
|
▲
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => onMove(1)}
|
|
aria-label="Move down"
|
|
disabled={index === total - 1}
|
|
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
|
|
>
|
|
▼
|
|
</button>
|
|
</div>
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-[#10B981]">
|
|
Field {index + 1}
|
|
</span>
|
|
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
|
min {minFiles} · max {effectiveMax}
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onRemove}
|
|
aria-label="Remove field"
|
|
className="rounded-lg p-1 text-red-500 transition hover:bg-red-50"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid gap-3 md:grid-cols-4">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">File Key *</Label>
|
|
<Input
|
|
value={field.fileKey}
|
|
onChange={(e) => onChange({ fileKey: e.target.value })}
|
|
placeholder="supporting_doc"
|
|
className="font-mono"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5 md:col-span-2">
|
|
<Label className="text-xs">File Label *</Label>
|
|
<Input
|
|
value={field.fileLabel}
|
|
onChange={(e) => onChange({ fileLabel: e.target.value })}
|
|
placeholder="Supporting Document"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Max Size (MB)</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
value={field.maxSizeMb}
|
|
onChange={(e) =>
|
|
onChange({ maxSizeMb: Number(e.target.value) })
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5 md:col-span-2">
|
|
<Label className="text-xs">Allowed Extensions *</Label>
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
{extensionChoices.map((ext) => (
|
|
<label
|
|
key={ext}
|
|
className="flex items-center gap-2 text-sm text-slate-700"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={field.allowedExtensions.includes(ext)}
|
|
onChange={(e) => onToggleExtension(ext, e.target.checked)}
|
|
className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
|
|
/>
|
|
<span className="font-mono">{ext}</span>
|
|
{!KNOWN_EXTENSIONS.has(ext) ? (
|
|
<span className="text-xs text-amber-600">(unrecognised)</span>
|
|
) : null}
|
|
</label>
|
|
))}
|
|
</div>
|
|
{field.allowedExtensions.length === 0 ? (
|
|
<p className="text-xs text-red-500">Pick at least one extension.</p>
|
|
) : (
|
|
<p className="text-xs text-slate-500">
|
|
Uploads are rejected unless the file matches one of these.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Max Files</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={50}
|
|
value={field.maxFiles}
|
|
disabled={!field.isMultiple}
|
|
onChange={(e) =>
|
|
onChange({ maxFiles: Number(e.target.value) })
|
|
}
|
|
className={!field.isMultiple ? "bg-slate-50 text-slate-400" : ""}
|
|
/>
|
|
{!field.isMultiple ? (
|
|
<p className="text-xs text-slate-400">
|
|
Locked to 1 when single-file.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-4">
|
|
<label className="flex items-center gap-2 text-sm text-slate-700">
|
|
<input
|
|
type="checkbox"
|
|
checked={field.isRequired}
|
|
onChange={(e) =>
|
|
onChange({ isRequired: e.target.checked })
|
|
}
|
|
className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
|
|
/>
|
|
Required
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm text-slate-700">
|
|
<input
|
|
type="checkbox"
|
|
checked={field.isMultiple}
|
|
onChange={(e) =>
|
|
onChange({ isMultiple: e.target.checked })
|
|
}
|
|
className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
|
|
/>
|
|
Multiple
|
|
</label>
|
|
</div>
|
|
|
|
<div className="space-y-1.5 md:col-span-4">
|
|
<Label className="text-xs">Help Text (optional)</Label>
|
|
<Input
|
|
value={field.helpText ?? ""}
|
|
onChange={(e) => onChange({ helpText: e.target.value })}
|
|
placeholder="e.g. PDF or photo of the original document."
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|