Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/dropdown_settings/ManageDropdownOptionsDialog.tsx

343 lines
11 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 {
CreateDropdownOptionDto,
DropdownSetting,
} from "@/types/dropdownSettings";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api";
export interface ManageDropdownOptionsDialogProps {
setting: DropdownSetting;
children?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
/**
* Local draft used by the editor — uses a stable client-only `key` so React
* keys remain stable across reorders. On save we strip `key` and POST the
* remainder as CreateDropdownOptionDto[].
*/
interface DraftOption extends CreateDropdownOptionDto {
key: string;
}
let draftCounter = 0;
const nextKey = () => `draft-${Date.now()}-${++draftCounter}`;
function makeEmptyDraft(idx: number): DraftOption {
return {
key: nextKey(),
value: "",
label: "",
disabled: false,
order: idx + 1,
meta: {},
};
}
export default function ManageDropdownOptionsDialog({
setting,
children,
open: openProp,
onOpenChange,
}: ManageDropdownOptionsDialogProps) {
const isControlled = openProp !== undefined;
const [internalOpen, setInternalOpen] = useState(false);
const open = isControlled ? openProp : internalOpen;
const setOpen = (next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
};
const [error, setError] = useState<string | null>(null);
const seed = (): DraftOption[] =>
[...(setting.children ?? [])]
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((o, idx) => ({
key: o.id,
value: o.value,
label: o.label,
note: o.note ?? undefined,
disabled: o.disabled,
order: o.order ?? idx + 1,
meta: {
...(o.meta?.icon ? { icon: o.meta.icon } : {}),
...(o.meta?.color ? { color: o.meta.color } : {}),
...(o.meta?.badge ? { badge: o.meta.badge } : {}),
},
}));
const [options, setOptions] = useState<DraftOption[]>(seed);
const replaceMutation = useMutation(
api.dropdownSettings.replaceOptions.mutationOptions(),
);
const update = (i: number, patch: Partial<DraftOption>) =>
setOptions((prev) =>
prev.map((o, idx) => (idx === i ? { ...o, ...patch } : o)),
);
const updateMeta = (
i: number,
patch: Partial<NonNullable<DraftOption["meta"]>>,
) =>
setOptions((prev) =>
prev.map((o, idx) =>
idx === i ? { ...o, meta: { ...(o.meta ?? {}), ...patch } } : o,
),
);
const remove = (i: number) =>
setOptions((prev) => prev.filter((_, idx) => idx !== i));
const add = () =>
setOptions((prev) => [...prev, makeEmptyDraft(prev.length)]);
const move = (i: number, dir: -1 | 1) =>
setOptions((prev) => {
const next = [...prev];
const target = i + dir;
if (target < 0 || target >= next.length) return prev;
const a = next[i] as DraftOption;
const b = next[target] as DraftOption;
next[i] = { ...b, order: i + 1 };
next[target] = { ...a, order: target + 1 };
return next;
});
const handleSave = () => {
setError(null);
const invalid = options.findIndex(
(o) => !o.label.trim() || !o.value.trim(),
);
if (invalid >= 0) {
setError(`Option ${invalid + 1} is missing a label or value.`);
return;
}
const payload: CreateDropdownOptionDto[] = options.map((o, idx) => {
const meta: NonNullable<CreateDropdownOptionDto["meta"]> = {};
if (o.meta?.icon?.trim()) meta.icon = o.meta.icon.trim();
if (o.meta?.color?.trim()) meta.color = o.meta.color.trim();
if (o.meta?.badge?.trim()) meta.badge = o.meta.badge.trim();
return {
value: o.value.trim(),
label: o.label.trim(),
note: o.note?.trim() || undefined,
disabled: o.disabled ?? false,
order: idx + 1,
...(Object.keys(meta).length > 0 ? { meta } : {}),
};
});
replaceMutation.mutate(
{ id: setting.id, options: payload },
{
onSuccess: () => setOpen(false),
onError: (err) =>
setError(
err instanceof Error
? err.message
: "Failed to save options. Try again.",
),
},
);
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) setOptions(seed());
if (!next) setError(null);
}}
>
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Manage Options · {setting.label}
</DialogTitle>
<DialogDescription>
Add, edit, reorder, or remove options 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">
{options.length} option{options.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 Option
</button>
</div>
{options.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
No options yet. Click{" "}
<span className="font-medium">Add Option</span> to start.
</div>
) : (
<div className="space-y-2">
{options.map((opt, i) => (
<div
key={opt.key}
className="grid gap-2 rounded-2xl border border-slate-200 bg-white p-3 md:grid-cols-[auto_1fr_1fr_1fr_auto_auto_auto]"
>
<div className="flex items-center gap-1 text-slate-400">
<GripVertical className="h-4 w-4" />
<div className="flex flex-col">
<button
type="button"
onClick={() => move(i, -1)}
aria-label="Move up"
disabled={i === 0}
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
>
</button>
<button
type="button"
onClick={() => move(i, 1)}
aria-label="Move down"
disabled={i === options.length - 1}
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
>
</button>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Label *</Label>
<Input
value={opt.label}
onChange={(e) => update(i, { label: e.target.value })}
placeholder="Display label"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Value *</Label>
<Input
value={opt.value}
onChange={(e) => update(i, { value: e.target.value })}
placeholder="Stored value"
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Note</Label>
<Input
value={opt.note ?? ""}
onChange={(e) => update(i, { note: e.target.value })}
placeholder="Helper text"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Badge</Label>
<Input
value={opt.meta?.badge ?? ""}
onChange={(e) => updateMeta(i, { badge: e.target.value })}
placeholder="—"
className="w-20"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Color</Label>
<Input
value={opt.meta?.color ?? ""}
onChange={(e) => updateMeta(i, { color: e.target.value })}
placeholder="#…"
className="w-24 font-mono"
/>
</div>
<div className="flex flex-col items-center justify-between gap-2">
<label className="flex items-center gap-1 text-xs text-slate-600">
<input
type="checkbox"
checked={opt.disabled ?? false}
onChange={(e) =>
update(i, { disabled: e.target.checked })
}
className="h-3.5 w-3.5 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
/>
Off
</label>
<button
type="button"
onClick={() => remove(i)}
aria-label={`Remove ${opt.label || "option"}`}
className="rounded-lg p-1 text-red-500 transition hover:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{error ? (
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</p>
) : null}
<div className="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 Options"
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}