mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
343 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|