Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/documents/ManageFileUploadFieldsDialog.tsx
natib21 0d98ccb04b fix
2026-07-17 13:10:27 +00:00

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