add docs settings to admin page

This commit is contained in:
yaschalew
2026-05-29 16:50:55 +03:00
parent 32e552e13b
commit ac35218299
35 changed files with 3653 additions and 3 deletions

View File

@@ -0,0 +1,77 @@
import { useState, type ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export interface DeleteDropdownSettingDialogProps {
settingLabel: string;
settingCode: string;
onConfirm?: () => void;
children?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export default function DeleteDropdownSettingDialog({
settingLabel,
settingCode,
onConfirm,
children,
open: openProp,
onOpenChange,
}: DeleteDropdownSettingDialogProps) {
const isControlled = openProp !== undefined;
const [internalOpen, setInternalOpen] = useState(false);
const open = isControlled ? openProp : internalOpen;
const setOpen = (next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Delete dropdown setting?
</DialogTitle>
<DialogDescription>
This will remove{" "}
<span className="font-semibold text-slate-900">{settingLabel}</span>{" "}
(<span className="font-mono text-xs">{settingCode}</span>) and all
of its options. Forms referencing this code will fall back to
empty options.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Delete
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,468 @@
import { useEffect, useMemo, useState } from "react";
import {
AlertCircle,
Boxes,
CheckCircle2,
Eye,
Filter,
ListOrdered,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Search,
Settings,
Shield,
Sparkles,
Trash2,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import EditDropdownSettingDialog from "./EditDropdownSettingDialog";
import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog";
import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog";
import {
useDeleteDropdownSetting,
useDropdownSettings,
} from "@/hooks/useDropdownSettings";
import type { DropdownSetting } from "@/types/dropdownSettings";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
type ActiveDialog = "edit" | "options" | "delete";
export default function DropdownSettingsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
const [activeSetting, setActiveSetting] = useState<DropdownSetting | null>(
null,
);
const openDialogFor = (dialog: ActiveDialog, setting: DropdownSetting) => {
// Defer past the DropdownMenu's close cycle. Radix's modal lock can leave
// `pointer-events: none` on <body> when a menu closes and a dialog opens
// in the same frame — wait two RAFs and then explicitly reset the body
// style so the dialog interior is interactive.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.body.style.pointerEvents = "";
setActiveSetting(setting);
setActiveDialog(dialog);
});
});
};
const closeDialog = () => {
setActiveDialog(null);
// Keep activeSetting briefly so dialog content doesn't flash empty during
// the close animation; cleared on next open.
};
// Belt-and-suspenders for the Radix pointer-events leak: any time the active
// dialog changes, schedule a body-style cleanup after the next paint.
useEffect(() => {
const id = requestAnimationFrame(() => {
if (document.body.style.pointerEvents === "none") {
document.body.style.pointerEvents = "";
}
});
return () => cancelAnimationFrame(id);
}, [activeDialog]);
const { data, isLoading, isError, error } = useDropdownSettings();
const deleteMutation = useDeleteDropdownSetting();
const dropdownSettings = useMemo<DropdownSetting[]>(
() => (Array.isArray(data) ? data : []),
[data],
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return dropdownSettings;
return dropdownSettings.filter(
(s) =>
s.code.toLowerCase().includes(q) ||
s.label.toLowerCase().includes(q) ||
(s.description ?? "").toLowerCase().includes(q),
);
}, [dropdownSettings, query]);
const total = filtered.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(
() => filtered.slice(start, end),
[start, end, filtered],
);
const totalOptions = dropdownSettings.reduce(
(sum, s) => sum + (s.children?.length ?? 0),
0,
);
const multipleCount = dropdownSettings.filter((s) => s.multiple).length;
const searchableCount = dropdownSettings.filter(
(s) => s.meta?.searchable,
).length;
const status: "loading" | "error" | "success" = isLoading
? "loading"
: isError
? "error"
: "success";
const columns: ColumnDef<DropdownSetting>[] = [
{
id: "setting",
header: "Setting",
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Settings />
</div>
<div>
<p className="font-medium text-slate-900">{s.label}</p>
<p className="text-xs text-slate-500">
{s.description ?? "No description"}
</p>
</div>
</div>
);
},
},
{
id: "code",
header: "Code",
cell: ({ row }) => (
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
{row.original.code}
</span>
),
},
{
id: "options",
header: "Options",
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex items-center gap-2 text-sm text-slate-700">
<Boxes />
<span className="font-medium">{s.children?.length ?? 0}</span>
</div>
);
},
},
{
id: "behavior",
header: "Behavior",
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex flex-wrap gap-1">
{s.multiple ? (
<BehaviorChip label="Multi" />
) : (
<BehaviorChip label="Single" muted />
)}
{s.meta?.searchable ? <BehaviorChip label="Searchable" /> : null}
{s.meta?.clearable ? <BehaviorChip label="Clearable" /> : null}
</div>
);
},
},
{
id: "permissions",
header: "Permissions",
cell: ({ row }) => {
const s = row.original;
const perms = s.meta?.permissions ?? [];
return (
<div className="flex flex-wrap items-center gap-1">
{perms.length === 0 ? (
<span className="text-xs text-slate-400"></span>
) : (
perms.map((p) => (
<span
key={p}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
<Shield />
{p}
</span>
))
)}
</div>
);
},
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const setting = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Eye />
View
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => openDialogFor("options", setting)}
>
<CheckCircle2 />
Options
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => openDialogFor("edit", setting)}
>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => openDialogFor("delete", setting)}
variant="destructive"
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: "Admin", href: "/admin" },
{ label: "Dropdown Settings" },
]}
/>
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Dropdown Settings
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Manage every dynamic dropdown across the platform labels,
options, ordering, and permissions.
</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search by code, label, description..."
className="pl-8!"
/>
</div>
<EditDropdownSettingDialog mode="create">
<Button>
<Plus />
New Setting
</Button>
</EditDropdownSettingDialog>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-4">
<StatCard
label="Settings"
value={dropdownSettings.length}
icon={<Settings />}
/>
<StatCard
label="Total Options"
value={totalOptions}
icon={<Boxes />}
/>
<StatCard
label="Multi-select"
value={multipleCount}
icon={<ListOrdered />}
/>
<StatCard
label="Searchable"
value={searchableCount}
icon={<Sparkles />}
/>
</div>
{isError ? (
<Card>
<CardContent className="flex items-center gap-3 py-6 text-sm text-red-600">
<AlertCircle className="h-5 w-5" />
Failed to load dropdown settings.{" "}
{error instanceof Error ? error.message : "Unknown error."}
</CardContent>
</Card>
) : null}
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Registered Dropdowns</CardTitle>
<CardDescription>
Every dynamic dropdown the platform reads from.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
<CardContent className="px-0">
{isLoading ? (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
Loading dropdown settings
</div>
) : (
<DataTable
columns={columns}
data={paginatedData}
status={status}
onRowClick={() => { }}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
)}
</CardContent>
</Card>
</div>
{/* Controlled dialogs — hoisted out of the DropdownMenu so they can open
reliably after a menu item is selected. */}
{activeSetting ? (
<>
<EditDropdownSettingDialog
key={`edit-${activeSetting.id}`}
mode="edit"
setting={activeSetting}
open={activeDialog === "edit"}
onOpenChange={(next) => (next ? null : closeDialog())}
/>
<ManageDropdownOptionsDialog
key={`options-${activeSetting.id}`}
setting={activeSetting}
open={activeDialog === "options"}
onOpenChange={(next) => (next ? null : closeDialog())}
/>
<DeleteDropdownSettingDialog
key={`delete-${activeSetting.id}`}
settingLabel={activeSetting.label}
settingCode={activeSetting.code}
onConfirm={() => deleteMutation.mutate(activeSetting.id)}
open={activeDialog === "delete"}
onOpenChange={(next) => (next ? null : closeDialog())}
/>
</>
) : null}
</div>
);
}
function StatCard({
label,
value,
icon,
}: {
label: string;
value: number;
icon: React.ReactNode;
}) {
return (
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{label}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
{icon}
</div>
</CardContent>
</Card>
);
}
function BehaviorChip({
label,
muted = false,
}: {
label: string;
muted?: boolean;
}) {
return (
<span
className={
muted
? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"
: "rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,336 @@
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>
);
}

View File

@@ -0,0 +1,339 @@
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 { useReplaceDropdownOptions } from "@/hooks/useDropdownSettings";
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 = useReplaceDropdownOptions();
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(
{ settingId: 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>
);
}