mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
fix data table
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Boxes,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Home,
|
||||
Layers,
|
||||
Package,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canAccessRuleEngineResource } from "@/lib/permissions";
|
||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||
import {
|
||||
getRuleEngineResource,
|
||||
type FormFieldDef,
|
||||
} from "@/pages/ruleEngine/config/resources";
|
||||
import {
|
||||
useRuleEngineList,
|
||||
useRuleEngineMutations,
|
||||
} from "@/hooks/rule-engine/useRuleEngine";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
|
||||
const CARGO_SLUG = "cargo-types";
|
||||
const BASE_PATH = "/dashboard/configuration/cargo-types";
|
||||
|
||||
interface CargoNode extends RuleEngineRecord {
|
||||
cargoTypeName?: string;
|
||||
code?: string;
|
||||
parentGroupId?: string | null;
|
||||
showFreeTextBox?: boolean;
|
||||
requiresDirectorApproval?: boolean;
|
||||
isActive?: boolean;
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
const str = (v: unknown): string => (v == null ? "" : String(v));
|
||||
const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0);
|
||||
|
||||
/** Create/edit form fields. Parent is set from the current page, never picked. */
|
||||
const FORM_FIELDS: FormFieldDef[] = [
|
||||
{ name: "cargoTypeName", label: "Cargo type name", type: "text", required: true },
|
||||
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
|
||||
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
];
|
||||
|
||||
type FormMode = { kind: "create" } | { kind: "edit"; record: CargoNode };
|
||||
|
||||
const CargoTypesPage = () => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { id: currentId } = useParams<{ id: string }>();
|
||||
const config = getRuleEngineResource(CARGO_SLUG);
|
||||
|
||||
const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view");
|
||||
const canManage = canAccessRuleEngineResource(user, CARGO_SLUG, "manage");
|
||||
|
||||
// One fetch of the whole (small) set; the tree, ancestry and each level are
|
||||
// derived client-side so drilling between levels is instant.
|
||||
const { data, isLoading, isError } = useRuleEngineList(CARGO_SLUG, {
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
sortBy: "displayOrder",
|
||||
sortOrder: "ASC",
|
||||
});
|
||||
|
||||
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [formMode, setFormMode] = useState<FormMode | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CargoNode | null>(null);
|
||||
|
||||
const all = (data?.data ?? []) as CargoNode[];
|
||||
|
||||
const { byId, childrenOf } = useMemo(() => {
|
||||
const byId = new Map<string, CargoNode>(all.map((n) => [n.id, n]));
|
||||
const childrenOf = new Map<string, CargoNode[]>();
|
||||
for (const node of all) {
|
||||
const parentId = node.parentGroupId && byId.has(node.parentGroupId) ? node.parentGroupId : "";
|
||||
const key = parentId || "__root__";
|
||||
const list = childrenOf.get(key) ?? [];
|
||||
list.push(node);
|
||||
childrenOf.set(key, list);
|
||||
}
|
||||
for (const list of childrenOf.values()) {
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
orderOf(a) - orderOf(b) ||
|
||||
str(a.cargoTypeName).localeCompare(str(b.cargoTypeName)),
|
||||
);
|
||||
}
|
||||
return { byId, childrenOf };
|
||||
}, [all]);
|
||||
|
||||
// Current node (null at root) and its ancestor chain for the breadcrumb.
|
||||
const current = currentId ? byId.get(currentId) ?? null : null;
|
||||
const ancestors = useMemo(() => {
|
||||
const chain: CargoNode[] = [];
|
||||
let node = current;
|
||||
const seen = new Set<string>();
|
||||
while (node && !seen.has(node.id)) {
|
||||
chain.unshift(node);
|
||||
seen.add(node.id);
|
||||
node = node.parentGroupId ? byId.get(node.parentGroupId) ?? null : null;
|
||||
}
|
||||
return chain;
|
||||
}, [current, byId]);
|
||||
|
||||
const levelKey = current ? current.id : "__root__";
|
||||
const levelNodes = childrenOf.get(levelKey) ?? [];
|
||||
|
||||
const term = search.trim().toLowerCase();
|
||||
const matches = (n: CargoNode) =>
|
||||
!term ||
|
||||
str(n.cargoTypeName).toLowerCase().includes(term) ||
|
||||
str(n.code).toLowerCase().includes(term);
|
||||
const visibleNodes = useMemo(
|
||||
() => (term ? levelNodes.filter(matches) : levelNodes),
|
||||
[levelNodes, term],
|
||||
);
|
||||
|
||||
if (!config) return <Navigate to="/dashboard/overview" replace />;
|
||||
if (!canView) return <Navigate to="/dashboard/overview" replace />;
|
||||
// A bad/stale :id (after data loads) → fall back to the root list.
|
||||
if (!isLoading && currentId && !current) return <Navigate to={BASE_PATH} replace />;
|
||||
|
||||
const atRoot = !current;
|
||||
const countAtRoot = (childrenOf.get("__root__") ?? []).length;
|
||||
|
||||
const handleSubmit = (values: Record<string, unknown>) => {
|
||||
const payload: Record<string, unknown> = { ...values };
|
||||
// Add always attaches to the page we're on; edit keeps the node's parent.
|
||||
if (formMode?.kind === "create" && current) {
|
||||
payload.parentGroupId = current.id;
|
||||
}
|
||||
const done = () => setFormMode(null);
|
||||
if (formMode?.kind === "edit") {
|
||||
update.mutate({ id: formMode.record.id, payload }, { onSuccess: done });
|
||||
} else {
|
||||
create.mutate(payload, { onSuccess: done });
|
||||
}
|
||||
};
|
||||
|
||||
const addLabel = atRoot ? "Add category" : "Add cargo type";
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* ── Header ─────────────────────────────────────────────── */}
|
||||
<Card
|
||||
p="lg"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ background: "white", boxShadow: "0 1px 3px rgba(0,0,0,0.05)" }}
|
||||
>
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumbs
|
||||
separator={<ChevronRight size={14} style={{ color: "var(--mantine-color-gray-5)" }} />}
|
||||
mb="md"
|
||||
>
|
||||
<UnstyledButton onClick={() => navigate(BASE_PATH)}>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Home size={14} style={{ color: "var(--mantine-color-teal-7)" }} />
|
||||
<Text fz={13} fw={600} c={atRoot ? "dark.7" : "teal.7"}>
|
||||
Cargo Types
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
{ancestors.map((node, i) => {
|
||||
const isLast = i === ancestors.length - 1;
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={node.id}
|
||||
onClick={() => !isLast && navigate(`${BASE_PATH}/${node.id}`)}
|
||||
style={{ cursor: isLast ? "default" : "pointer" }}
|
||||
>
|
||||
<Text fz={13} fw={isLast ? 700 : 600} c={isLast ? "dark.7" : "teal.7"} truncate maw={220}>
|
||||
{str(node.cargoTypeName) || "Untitled"}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon size={48} radius="md" variant="light" color="teal">
|
||||
{atRoot ? <Boxes size={26} /> : <Layers size={26} />}
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={800} fz={22} c="dark.8" truncate>
|
||||
{atRoot ? "Cargo Types" : str(current?.cargoTypeName) || "Untitled"}
|
||||
</Text>
|
||||
{!atRoot && current?.code ? (
|
||||
<Badge variant="default" radius="sm">
|
||||
{str(current.code)}
|
||||
</Badge>
|
||||
) : null}
|
||||
{!atRoot && current?.isActive === false ? (
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
Inactive
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text fz={13} c="dimmed" mt={2}>
|
||||
{atRoot
|
||||
? `${countAtRoot} top-level categor${countAtRoot === 1 ? "y" : "ies"} — click one to see what's inside`
|
||||
: `${levelNodes.length} cargo type${levelNodes.length === 1 ? "" : "s"} directly under this category`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<TextInput
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
placeholder="Search this level…"
|
||||
leftSection={<Search size={16} />}
|
||||
w={240}
|
||||
/>
|
||||
{canManage && (
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setFormMode({ kind: "create" })}
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
{/* ── Level list ─────────────────────────────────────────── */}
|
||||
<Card
|
||||
p={0}
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
background: "white",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.05)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader color="teal" />
|
||||
</Group>
|
||||
) : isError ? (
|
||||
<Text p="xl" c="red" ta="center">
|
||||
Failed to load cargo types.
|
||||
</Text>
|
||||
) : visibleNodes.length === 0 ? (
|
||||
<Stack align="center" gap="sm" py={56}>
|
||||
<ThemeIcon size={52} radius="xl" variant="light" color="gray">
|
||||
<Package size={26} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} c="dark.6">
|
||||
{term
|
||||
? "Nothing matches your search"
|
||||
: atRoot
|
||||
? "No cargo categories yet"
|
||||
: `No cargo types under “${str(current?.cargoTypeName)}” yet`}
|
||||
</Text>
|
||||
{!term && canManage && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setFormMode({ kind: "create" })}
|
||||
>
|
||||
{atRoot ? "Add your first category" : "Add the first cargo type"}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={0}>
|
||||
{visibleNodes.map((node, i) => (
|
||||
<CargoRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
childCount={(childrenOf.get(node.id) ?? []).length}
|
||||
topBorder={i > 0}
|
||||
canManage={canManage}
|
||||
onOpen={() => navigate(`${BASE_PATH}/${node.id}`)}
|
||||
onEdit={() => setFormMode({ kind: "edit", record: node })}
|
||||
onDelete={() => setDeleteTarget(node)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ── Create / edit dialog ───────────────────────────────── */}
|
||||
<RuleEngineFormDialog
|
||||
open={Boolean(formMode)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setFormMode(null);
|
||||
}}
|
||||
title={
|
||||
formMode?.kind === "edit"
|
||||
? `Edit ${str(formMode.record.cargoTypeName)}`
|
||||
: atRoot
|
||||
? "Add category"
|
||||
: `Add cargo under “${str(current?.cargoTypeName)}”`
|
||||
}
|
||||
description={
|
||||
formMode?.kind === "edit"
|
||||
? "Update this cargo type."
|
||||
: atRoot
|
||||
? "Create a top-level cargo category."
|
||||
: "Create a cargo type inside this category. It's attached here automatically."
|
||||
}
|
||||
fields={FORM_FIELDS}
|
||||
initialRecord={formMode?.kind === "edit" ? formMode.record : null}
|
||||
isSubmitting={create.isPending || update.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
{/* ── Delete confirm ─────────────────────────────────────── */}
|
||||
<Modal
|
||||
opened={Boolean(deleteTarget)}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
title="Delete cargo type?"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{deleteTarget && (childrenOf.get(deleteTarget.id)?.length ?? 0) > 0 ? (
|
||||
<>
|
||||
<Text span fw={600}>
|
||||
{str(deleteTarget?.cargoTypeName)}
|
||||
</Text>{" "}
|
||||
has {childrenOf.get(deleteTarget!.id)?.length} cargo type(s) under it. Deleting it
|
||||
leaves them without a category. Continue?
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This will delete{" "}
|
||||
<Text span fw={600}>
|
||||
{str(deleteTarget?.cargoTypeName)}
|
||||
</Text>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={remove.isPending}
|
||||
onClick={() => {
|
||||
if (!deleteTarget) return;
|
||||
remove.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) });
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
// ── A single cargo row — drills into its own page on click ──────────────────
|
||||
interface CargoRowProps {
|
||||
node: CargoNode;
|
||||
childCount: number;
|
||||
topBorder: boolean;
|
||||
canManage: boolean;
|
||||
onOpen: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function CargoRow({
|
||||
node,
|
||||
childCount,
|
||||
topBorder,
|
||||
canManage,
|
||||
onOpen,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: CargoRowProps) {
|
||||
const inactive = node.isActive === false;
|
||||
const hasChildren = childCount > 0;
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px="lg"
|
||||
py="md"
|
||||
style={{
|
||||
borderTop: topBorder ? "1px solid var(--mantine-color-gray-2)" : undefined,
|
||||
transition: "background 120ms ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--mantine-color-teal-0)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "";
|
||||
}}
|
||||
>
|
||||
<UnstyledButton onClick={onOpen} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size={36} radius="md" variant="light" color={inactive ? "gray" : "teal"}>
|
||||
{hasChildren ? <Layers size={18} /> : <Package size={18} />}
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={650} fz={15} c="dark.8" truncate>
|
||||
{str(node.cargoTypeName) || "Untitled"}
|
||||
</Text>
|
||||
{node.code ? (
|
||||
<Badge size="xs" variant="default" radius="sm">
|
||||
{str(node.code)}
|
||||
</Badge>
|
||||
) : null}
|
||||
{node.requiresDirectorApproval ? (
|
||||
<Tooltip label="Requires director approval" withArrow>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={11} />}
|
||||
>
|
||||
Approval
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{node.showFreeTextBox ? (
|
||||
<Tooltip label="Shows a free-text box on booking" withArrow>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="sm"
|
||||
leftSection={<FileText size={11} />}
|
||||
>
|
||||
Free text
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{inactive ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
Inactive
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text fz={12.5} c="dimmed" mt={2}>
|
||||
{hasChildren
|
||||
? `${childCount} cargo type${childCount === 1 ? "" : "s"} inside`
|
||||
: "No cargo types inside yet — open to add"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{canManage && (
|
||||
<>
|
||||
<Tooltip label="Edit" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="gray" onClick={onEdit} px={8}>
|
||||
<Pencil size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="red" onClick={onDelete} px={8}>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<Tooltip label="Open" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="teal" onClick={onOpen} px={8}>
|
||||
<ChevronRight size={18} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default CargoTypesPage;
|
||||
Reference in New Issue
Block a user