mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
feat(cargo-types): add CargoTypesPage for managing freight cargo categories and types
This commit is contained in:
@@ -44,6 +44,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
|
|||||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
|
import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage";
|
||||||
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
||||||
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
|
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
|
||||||
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
|
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
|
||||||
@@ -525,6 +526,7 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
||||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -0,0 +1,600 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Collapse,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Tooltip,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
Boxes,
|
||||||
|
ChevronRight,
|
||||||
|
CornerDownRight,
|
||||||
|
FileText,
|
||||||
|
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 {
|
||||||
|
RULE_ENGINE_SELECT_NONE,
|
||||||
|
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";
|
||||||
|
|
||||||
|
/** A cargo type with its (already-resolved) child 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);
|
||||||
|
|
||||||
|
/** Fields for the create/edit form. Parent-group select is added contextually. */
|
||||||
|
const PARENT_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-parent" }
|
||||||
|
| { kind: "create-child"; parent: CargoNode }
|
||||||
|
| { kind: "edit"; record: CargoNode };
|
||||||
|
|
||||||
|
const CargoTypesPage = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const config = getRuleEngineResource(CARGO_SLUG);
|
||||||
|
|
||||||
|
const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view");
|
||||||
|
const canManage = canAccessRuleEngineResource(user, CARGO_SLUG, "manage");
|
||||||
|
|
||||||
|
// Pull the whole set once and build the tree on the client — cargo types are a
|
||||||
|
// small, slow-changing config set, so a single fetch keeps the UX 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 [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
|
const [formMode, setFormMode] = useState<FormMode | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<CargoNode | null>(null);
|
||||||
|
|
||||||
|
const all = (data?.data ?? []) as CargoNode[];
|
||||||
|
|
||||||
|
// Group children under their parents; anything without a (known) parent is a root.
|
||||||
|
const { roots, childrenByParent } = useMemo(() => {
|
||||||
|
const byId = new Map(all.map((n) => [n.id, n]));
|
||||||
|
const childrenByParent = new Map<string, CargoNode[]>();
|
||||||
|
const roots: CargoNode[] = [];
|
||||||
|
for (const node of all) {
|
||||||
|
const parentId = node.parentGroupId ?? null;
|
||||||
|
if (parentId && byId.has(parentId)) {
|
||||||
|
const list = childrenByParent.get(parentId) ?? [];
|
||||||
|
list.push(node);
|
||||||
|
childrenByParent.set(parentId, list);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const list of childrenByParent.values()) {
|
||||||
|
list.sort((a, b) => orderOf(a) - orderOf(b) || str(a.cargoTypeName).localeCompare(str(b.cargoTypeName)));
|
||||||
|
}
|
||||||
|
roots.sort((a, b) => orderOf(a) - orderOf(b) || str(a.cargoTypeName).localeCompare(str(b.cargoTypeName)));
|
||||||
|
return { roots, childrenByParent };
|
||||||
|
}, [all]);
|
||||||
|
|
||||||
|
// Search matches a parent directly, or surfaces a parent because a child matches.
|
||||||
|
const term = search.trim().toLowerCase();
|
||||||
|
const matches = (n: CargoNode) =>
|
||||||
|
!term ||
|
||||||
|
str(n.cargoTypeName).toLowerCase().includes(term) ||
|
||||||
|
str(n.code).toLowerCase().includes(term);
|
||||||
|
|
||||||
|
const visibleRoots = useMemo(() => {
|
||||||
|
if (!term) return roots;
|
||||||
|
return roots.filter(
|
||||||
|
(p) => matches(p) || (childrenByParent.get(p.id) ?? []).some(matches),
|
||||||
|
);
|
||||||
|
}, [roots, childrenByParent, term]);
|
||||||
|
|
||||||
|
if (!config) return <Navigate to="/dashboard/overview" replace />;
|
||||||
|
if (!canView) return <Navigate to="/dashboard/overview" replace />;
|
||||||
|
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const parentCount = roots.length;
|
||||||
|
const childCount = all.length - roots.length;
|
||||||
|
|
||||||
|
// ── form wiring ──────────────────────────────────────────────────────────
|
||||||
|
const formFields: FormFieldDef[] =
|
||||||
|
formMode?.kind === "edit" && !formMode.record.parentGroupId
|
||||||
|
? // editing a parent → allow re-parenting via a select of OTHER roots
|
||||||
|
[
|
||||||
|
PARENT_FORM_FIELDS[0],
|
||||||
|
{
|
||||||
|
name: "parentGroupId",
|
||||||
|
label: "Parent group",
|
||||||
|
type: "select",
|
||||||
|
optional: true,
|
||||||
|
placeholder: "Top-level (no parent)",
|
||||||
|
options: [
|
||||||
|
{ label: "None (top level)", value: RULE_ENGINE_SELECT_NONE },
|
||||||
|
...roots
|
||||||
|
.filter((r) => r.id !== formMode.record.id)
|
||||||
|
.map((r) => ({ label: str(r.cargoTypeName), value: r.id })),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...PARENT_FORM_FIELDS.slice(1),
|
||||||
|
]
|
||||||
|
: // creating a parent, creating a child, or editing a child → no parent picker
|
||||||
|
PARENT_FORM_FIELDS;
|
||||||
|
|
||||||
|
const initialRecord: RuleEngineRecord | null =
|
||||||
|
formMode?.kind === "edit" ? formMode.record : null;
|
||||||
|
|
||||||
|
const formTitle =
|
||||||
|
formMode?.kind === "create-parent"
|
||||||
|
? "Add cargo type"
|
||||||
|
: formMode?.kind === "create-child"
|
||||||
|
? `Add cargo under “${str(formMode.parent.cargoTypeName)}”`
|
||||||
|
: formMode?.kind === "edit"
|
||||||
|
? `Edit ${str(formMode.record.cargoTypeName)}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const formDescription =
|
||||||
|
formMode?.kind === "create-parent"
|
||||||
|
? "Create a top-level cargo category. You can add cargo types under it afterwards."
|
||||||
|
: formMode?.kind === "create-child"
|
||||||
|
? "Create a cargo type inside this category."
|
||||||
|
: "Update this cargo type.";
|
||||||
|
|
||||||
|
const handleSubmit = (values: Record<string, unknown>) => {
|
||||||
|
// Force the parent context for child creates; the form has no parent picker.
|
||||||
|
const payload: Record<string, unknown> = { ...values };
|
||||||
|
if (formMode?.kind === "create-child") {
|
||||||
|
payload.parentGroupId = formMode.parent.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const done = () => setFormMode(null);
|
||||||
|
if (formMode?.kind === "edit") {
|
||||||
|
update.mutate({ id: formMode.record.id, payload }, { onSuccess: done });
|
||||||
|
} else {
|
||||||
|
create.mutate(payload, { onSuccess: done });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
{/* ── Header ─────────────────────────────────────────────── */}
|
||||||
|
<Card
|
||||||
|
p="lg"
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{ background: "white", boxShadow: "0 1px 3px rgba(0,0,0,0.05)" }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||||
|
<Group gap="md" wrap="nowrap">
|
||||||
|
<ThemeIcon size={48} radius="md" variant="light" color="teal">
|
||||||
|
<Boxes size={26} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Text fw={800} fz={22} c="dark.8">
|
||||||
|
Cargo Types
|
||||||
|
</Text>
|
||||||
|
<Text fz={13} c="dimmed" mt={2}>
|
||||||
|
Organise freight cargo into categories and the cargo types within them.
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs" mt={8}>
|
||||||
|
<Badge variant="light" color="teal" radius="sm">
|
||||||
|
{parentCount} categor{parentCount === 1 ? "y" : "ies"}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="light" color="gray" radius="sm">
|
||||||
|
{childCount} cargo type{childCount === 1 ? "" : "s"}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<TextInput
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
placeholder="Search categories or cargo…"
|
||||||
|
leftSection={<Search size={16} />}
|
||||||
|
w={260}
|
||||||
|
/>
|
||||||
|
{canManage && (
|
||||||
|
<Button
|
||||||
|
color="teal"
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
onClick={() => setFormMode({ kind: "create-parent" })}
|
||||||
|
>
|
||||||
|
Add category
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Tree ───────────────────────────────────────────────── */}
|
||||||
|
<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>
|
||||||
|
) : visibleRoots.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 ? "No cargo types match your search" : "No cargo categories yet"}
|
||||||
|
</Text>
|
||||||
|
{!term && canManage && (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="teal"
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
onClick={() => setFormMode({ kind: "create-parent" })}
|
||||||
|
>
|
||||||
|
Add your first category
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack gap={0}>
|
||||||
|
{visibleRoots.map((parent, i) => {
|
||||||
|
const kids = childrenByParent.get(parent.id) ?? [];
|
||||||
|
const isOpen = expanded.has(parent.id) || Boolean(term);
|
||||||
|
const shownKids = term ? kids.filter(matches) : kids;
|
||||||
|
return (
|
||||||
|
<ParentRow
|
||||||
|
key={parent.id}
|
||||||
|
parent={parent}
|
||||||
|
childCount={kids.length}
|
||||||
|
open={isOpen}
|
||||||
|
topBorder={i > 0}
|
||||||
|
canManage={canManage}
|
||||||
|
onToggle={() => toggle(parent.id)}
|
||||||
|
onEdit={() => setFormMode({ kind: "edit", record: parent })}
|
||||||
|
onDelete={() => setDeleteTarget(parent)}
|
||||||
|
onAddChild={() => {
|
||||||
|
setExpanded((prev) => new Set(prev).add(parent.id));
|
||||||
|
setFormMode({ kind: "create-child", parent });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shownKids.length === 0 ? (
|
||||||
|
<Text fz={13} c="dimmed" pl={56} py="sm">
|
||||||
|
No cargo types in this category yet.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap={0}>
|
||||||
|
{shownKids.map((child) => (
|
||||||
|
<ChildRow
|
||||||
|
key={child.id}
|
||||||
|
child={child}
|
||||||
|
canManage={canManage}
|
||||||
|
onEdit={() => setFormMode({ kind: "edit", record: child })}
|
||||||
|
onDelete={() => setDeleteTarget(child)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</ParentRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Create / edit dialog ───────────────────────────────── */}
|
||||||
|
<RuleEngineFormDialog
|
||||||
|
open={Boolean(formMode)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setFormMode(null);
|
||||||
|
}}
|
||||||
|
title={formTitle}
|
||||||
|
description={formDescription}
|
||||||
|
fields={formFields}
|
||||||
|
initialRecord={initialRecord}
|
||||||
|
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 && (childrenByParent.get(deleteTarget.id)?.length ?? 0) > 0 ? (
|
||||||
|
<>
|
||||||
|
<Text span fw={600}>
|
||||||
|
{str(deleteTarget?.cargoTypeName)}
|
||||||
|
</Text>{" "}
|
||||||
|
has {childrenByParent.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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Parent (category) row ──────────────────────────────────────────────────
|
||||||
|
interface ParentRowProps {
|
||||||
|
parent: CargoNode;
|
||||||
|
childCount: number;
|
||||||
|
open: boolean;
|
||||||
|
topBorder: boolean;
|
||||||
|
canManage: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onAddChild: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ParentRow({
|
||||||
|
parent,
|
||||||
|
childCount,
|
||||||
|
open,
|
||||||
|
topBorder,
|
||||||
|
canManage,
|
||||||
|
onToggle,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onAddChild,
|
||||||
|
children,
|
||||||
|
}: ParentRowProps) {
|
||||||
|
const inactive = parent.isActive === false;
|
||||||
|
return (
|
||||||
|
<Box style={topBorder ? { borderTop: "1px solid var(--mantine-color-gray-2)" } : undefined}>
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
px="lg"
|
||||||
|
py="md"
|
||||||
|
style={{ background: open ? "var(--mantine-color-teal-0)" : undefined }}
|
||||||
|
>
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<ChevronRight
|
||||||
|
size={18}
|
||||||
|
style={{
|
||||||
|
color: "var(--mantine-color-gray-6)",
|
||||||
|
transition: "transform 150ms ease",
|
||||||
|
transform: open ? "rotate(90deg)" : "none",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ThemeIcon size={36} radius="md" variant="light" color={inactive ? "gray" : "teal"}>
|
||||||
|
<Boxes size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Text fw={700} fz={15} c="dark.8" truncate>
|
||||||
|
{str(parent.cargoTypeName) || "Untitled"}
|
||||||
|
</Text>
|
||||||
|
{parent.code ? (
|
||||||
|
<Badge size="xs" variant="default" radius="sm">
|
||||||
|
{str(parent.code)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{inactive ? (
|
||||||
|
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||||
|
Inactive
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Text fz={12.5} c="dimmed" mt={2}>
|
||||||
|
{childCount} cargo type{childCount === 1 ? "" : "s"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
{canManage && (
|
||||||
|
<Tooltip label="Add cargo type" withArrow>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="light"
|
||||||
|
color="teal"
|
||||||
|
leftSection={<Plus size={14} />}
|
||||||
|
onClick={onAddChild}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{canManage && (
|
||||||
|
<>
|
||||||
|
<Tooltip label="Edit category" withArrow>
|
||||||
|
<Button size="compact-sm" variant="subtle" color="gray" onClick={onEdit} px={8}>
|
||||||
|
<Pencil size={15} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Delete category" withArrow>
|
||||||
|
<Button size="compact-sm" variant="subtle" color="red" onClick={onDelete} px={8}>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Collapse in={open}>
|
||||||
|
<Box pb={open ? "xs" : 0}>{children}</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Child (cargo type) row ─────────────────────────────────────────────────
|
||||||
|
interface ChildRowProps {
|
||||||
|
child: CargoNode;
|
||||||
|
canManage: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChildRow({ child, canManage, onEdit, onDelete }: ChildRowProps) {
|
||||||
|
const inactive = child.isActive === false;
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
pl={56}
|
||||||
|
pr="lg"
|
||||||
|
py="xs"
|
||||||
|
style={{ borderTop: "1px solid var(--mantine-color-gray-1)" }}
|
||||||
|
>
|
||||||
|
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<CornerDownRight size={15} style={{ color: "var(--mantine-color-gray-5)", flexShrink: 0 }} />
|
||||||
|
<Text fw={500} fz={14} c={inactive ? "dimmed" : "dark.7"} truncate>
|
||||||
|
{str(child.cargoTypeName) || "Untitled"}
|
||||||
|
</Text>
|
||||||
|
{child.code ? (
|
||||||
|
<Badge size="xs" variant="default" radius="sm">
|
||||||
|
{str(child.code)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{child.requiresDirectorApproval ? (
|
||||||
|
<Tooltip label="Requires director approval" withArrow>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<ShieldCheck size={11} />}
|
||||||
|
>
|
||||||
|
Approval
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
{child.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>
|
||||||
|
|
||||||
|
{canManage && (
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<Tooltip label="Edit" withArrow>
|
||||||
|
<Button size="compact-sm" variant="subtle" color="gray" onClick={onEdit} px={8}>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Delete" withArrow>
|
||||||
|
<Button size="compact-sm" variant="subtle" color="red" onClick={onDelete} px={8}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CargoTypesPage;
|
||||||
Reference in New Issue
Block a user