mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 01:20:55 +00:00
337 lines
10 KiB
TypeScript
337 lines
10 KiB
TypeScript
import { useState, type ReactNode } from "react";
|
|
import { Hash, Loader2 } 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 { Textarea } from "@/components/ui/textarea";
|
|
|
|
import type {
|
|
CreateDropdownSettingDto,
|
|
DropdownSetting,
|
|
UpdateDropdownSettingDto,
|
|
} from "@/types/dropdownSettings";
|
|
import {
|
|
useCreateDropdownSetting,
|
|
useUpdateDropdownSetting,
|
|
} from "@/hooks/useDropdownSettings";
|
|
|
|
export interface EditDropdownSettingDialogProps {
|
|
mode?: "create" | "edit";
|
|
setting?: DropdownSetting;
|
|
/** Optional trigger element. When omitted, the dialog renders content only and is fully controlled. */
|
|
children?: ReactNode;
|
|
/** Controlled open state. When provided, internal state is ignored. */
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
}
|
|
|
|
function parsePermissions(raw: string): string[] {
|
|
return raw
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
export default function EditDropdownSettingDialog({
|
|
mode = "create",
|
|
setting,
|
|
children,
|
|
open: openProp,
|
|
onOpenChange,
|
|
}: EditDropdownSettingDialogProps) {
|
|
const isEdit = mode === "edit";
|
|
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 [code, setCode] = useState(setting?.code ?? "");
|
|
const [label, setLabel] = useState(setting?.label ?? "");
|
|
const [description, setDescription] = useState(setting?.description ?? "");
|
|
const [icon, setIcon] = useState(setting?.meta?.icon ?? "");
|
|
const [color, setColor] = useState(setting?.meta?.color ?? "");
|
|
const [permissions, setPermissions] = useState(
|
|
setting?.meta?.permissions?.join(", ") ?? "",
|
|
);
|
|
const [version, setVersion] = useState(setting?.meta?.version ?? "1.0");
|
|
const [multiple, setMultiple] = useState<boolean>(setting?.multiple ?? false);
|
|
const [searchable, setSearchable] = useState<boolean>(
|
|
setting?.meta?.searchable ?? false,
|
|
);
|
|
const [clearable, setClearable] = useState<boolean>(
|
|
setting?.meta?.clearable ?? false,
|
|
);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const createMutation = useCreateDropdownSetting();
|
|
const updateMutation = useUpdateDropdownSetting();
|
|
const pending = createMutation.isPending || updateMutation.isPending;
|
|
|
|
const reset = () => {
|
|
setCode(setting?.code ?? "");
|
|
setLabel(setting?.label ?? "");
|
|
setDescription(setting?.description ?? "");
|
|
setIcon(setting?.meta?.icon ?? "");
|
|
setColor(setting?.meta?.color ?? "");
|
|
setPermissions(setting?.meta?.permissions?.join(", ") ?? "");
|
|
setVersion(setting?.meta?.version ?? "1.0");
|
|
setMultiple(setting?.multiple ?? false);
|
|
setSearchable(setting?.meta?.searchable ?? false);
|
|
setClearable(setting?.meta?.clearable ?? false);
|
|
setError(null);
|
|
};
|
|
|
|
const buildPayload = (): CreateDropdownSettingDto => ({
|
|
code: code.trim(),
|
|
label: label.trim(),
|
|
description: description.trim() || undefined,
|
|
multiple,
|
|
meta: {
|
|
...(icon.trim() ? { icon: icon.trim() } : {}),
|
|
...(color.trim() ? { color: color.trim() } : {}),
|
|
searchable,
|
|
clearable,
|
|
...(version.trim() ? { version: version.trim() } : {}),
|
|
permissions: parsePermissions(permissions),
|
|
},
|
|
});
|
|
|
|
const handleSubmit = () => {
|
|
setError(null);
|
|
if (!code.trim() || !label.trim()) {
|
|
setError("Code and label are required.");
|
|
return;
|
|
}
|
|
if (!/^[a-z][a-z0-9_]*$/i.test(code.trim())) {
|
|
setError(
|
|
"Code must start with a letter and contain only letters, digits, or underscores.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const payload = buildPayload();
|
|
|
|
const onDone = () => {
|
|
setOpen(false);
|
|
if (!isEdit) reset();
|
|
};
|
|
const onError = (err: unknown) => {
|
|
setError(
|
|
err instanceof Error
|
|
? err.message
|
|
: "Something went wrong. Try again.",
|
|
);
|
|
};
|
|
|
|
if (isEdit && setting) {
|
|
// Update DTO omits `code` (immutable); strip it before sending.
|
|
const { code: _unused, ...updateDto } = payload;
|
|
void _unused;
|
|
updateMutation.mutate(
|
|
{ id: setting.id, dto: updateDto as UpdateDropdownSettingDto },
|
|
{ onSuccess: onDone, onError },
|
|
);
|
|
} else {
|
|
createMutation.mutate(payload, { onSuccess: onDone, onError });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
setOpen(next);
|
|
if (!next) reset();
|
|
}}
|
|
>
|
|
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
|
|
|
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl rounded-3xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-2xl font-bold">
|
|
{isEdit ? "Edit Dropdown Setting" : "New Dropdown Setting"}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{isEdit
|
|
? "Update the metadata for this dropdown setting."
|
|
: "Define a new dynamic dropdown that admins can manage."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid gap-5 py-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label>Code *</Label>
|
|
<div className="relative">
|
|
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
<Input
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value)}
|
|
placeholder="e.g. cargo_type"
|
|
className="pl-10 font-mono"
|
|
disabled={isEdit}
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-slate-500">
|
|
{isEdit
|
|
? "Code is immutable after creation."
|
|
: "Stable identifier used in code. Use snake_case."}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Label *</Label>
|
|
<Input
|
|
value={label}
|
|
onChange={(e) => setLabel(e.target.value)}
|
|
placeholder="e.g. Cargo Type"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2 md:col-span-2">
|
|
<Label>Description</Label>
|
|
<Textarea
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="What this dropdown represents and where it's used..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Icon (meta.icon)</Label>
|
|
<Input
|
|
value={icon}
|
|
onChange={(e) => setIcon(e.target.value)}
|
|
placeholder="lucide icon name, e.g. package"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Color (meta.color)</Label>
|
|
<Input
|
|
value={color}
|
|
onChange={(e) => setColor(e.target.value)}
|
|
placeholder="#10B981"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Permissions (comma-separated)</Label>
|
|
<Input
|
|
value={permissions}
|
|
onChange={(e) => setPermissions(e.target.value)}
|
|
placeholder="admin, ops"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Version (meta.version)</Label>
|
|
<Input
|
|
value={version}
|
|
onChange={(e) => setVersion(e.target.value)}
|
|
placeholder="1.0"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2 md:col-span-2">
|
|
<Label>Behavior</Label>
|
|
<div className="flex flex-wrap gap-3">
|
|
<ToggleChip
|
|
checked={multiple}
|
|
onChange={setMultiple}
|
|
label="Multi-select"
|
|
description="Users can pick more than one option"
|
|
/>
|
|
<ToggleChip
|
|
checked={searchable}
|
|
onChange={setSearchable}
|
|
label="Searchable"
|
|
description="Show a search input in the dropdown"
|
|
/>
|
|
<ToggleChip
|
|
checked={clearable}
|
|
onChange={setClearable}
|
|
label="Clearable"
|
|
description="Allow users to clear the selection"
|
|
/>
|
|
</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="mt-2 flex justify-end gap-3">
|
|
<DialogClose asChild>
|
|
<Button variant="outline" disabled={pending}>
|
|
Cancel
|
|
</Button>
|
|
</DialogClose>
|
|
<Button
|
|
type="button"
|
|
onClick={handleSubmit}
|
|
disabled={pending}
|
|
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
|
>
|
|
{pending ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : isEdit ? (
|
|
"Save Changes"
|
|
) : (
|
|
"Create Setting"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function ToggleChip({
|
|
checked,
|
|
onChange,
|
|
label,
|
|
description,
|
|
}: {
|
|
checked: boolean;
|
|
onChange: (next: boolean) => void;
|
|
label: string;
|
|
description: string;
|
|
}) {
|
|
return (
|
|
<label
|
|
className={
|
|
checked
|
|
? "flex cursor-pointer items-start gap-2 rounded-2xl border border-[#10B981]/40 bg-[#10B981]/10 px-3 py-2 text-sm"
|
|
: "flex cursor-pointer items-start gap-2 rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm transition hover:border-[#10B981]/30 hover:bg-[#10B981]/5"
|
|
}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={(e) => onChange(e.target.checked)}
|
|
className="mt-0.5 h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
|
|
/>
|
|
<div>
|
|
<p className="font-medium text-slate-900">{label}</p>
|
|
<p className="text-xs text-slate-500">{description}</p>
|
|
</div>
|
|
</label>
|
|
);
|
|
}
|