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