mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 01:18:18 +00:00
add docs settings to admin page
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user