mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
490 lines
16 KiB
TypeScript
490 lines
16 KiB
TypeScript
import { FormEvent, useMemo, useState } from "react";
|
|
import { Edit, Eye, Trash2 } from "lucide-react";
|
|
import type { ColumnDef } from "@edr/ui-common";
|
|
import {
|
|
ActionIcon,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
Group,
|
|
Modal,
|
|
Select,
|
|
SimpleGrid,
|
|
Stack,
|
|
Text,
|
|
TextInput,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
|
|
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
|
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
|
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
|
import {
|
|
useCreateRoute,
|
|
useDeactivateRoute,
|
|
useRouteYards,
|
|
useRoutes,
|
|
useUpdateRoute,
|
|
} from "@/hooks/useRoutes";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import type { RouteRecord, YardRef } from "@/services/routes.service";
|
|
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
|
|
|
type RouteFormState = {
|
|
name: string;
|
|
milestones: string[];
|
|
};
|
|
|
|
const emptyForm = (): RouteFormState => ({ name: "", milestones: ["", ""] });
|
|
|
|
const yardLabel = (yard?: YardRef | null) => (yard ? `${yard.label} (${yard.code})` : "—");
|
|
|
|
const routeStops = (route: RouteRecord) =>
|
|
(route.milestones ?? [])
|
|
.sort((left, right) => left.sequenceNo - right.sequenceNo)
|
|
.map((milestone) => milestone.yard?.label ?? milestone.yard?.code ?? milestone.yardId);
|
|
|
|
const normalizeRouteError = (error: unknown) => {
|
|
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
|
const data =
|
|
responseData && typeof responseData === "object"
|
|
? (responseData as Record<string, unknown>)
|
|
: undefined;
|
|
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
|
|
return Array.isArray(rawMessage)
|
|
? rawMessage.join(", ")
|
|
: rawMessage
|
|
? String(rawMessage)
|
|
: "Save failed";
|
|
};
|
|
|
|
export default function RoutesPage() {
|
|
const [search, setSearch] = useState("");
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
const [viewing, setViewing] = useState<RouteRecord | null>(null);
|
|
const [editing, setEditing] = useState<RouteRecord | null>(null);
|
|
const [form, setForm] = useState<RouteFormState>(emptyForm());
|
|
const { viewMode, setViewMode } = useFleetViewMode("routes");
|
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
const { toast } = useToast();
|
|
|
|
const routesQuery = useRoutes();
|
|
const yardsQuery = useRouteYards();
|
|
const createMutation = useCreateRoute();
|
|
const updateMutation = useUpdateRoute();
|
|
const deactivateMutation = useDeactivateRoute();
|
|
|
|
const filteredRoutes = useMemo(() => {
|
|
const query = search.trim().toLowerCase();
|
|
if (!query) return routesQuery.data ?? [];
|
|
return (routesQuery.data ?? []).filter((route) => {
|
|
const searchable = [
|
|
route.name,
|
|
route.originYard?.label,
|
|
route.originYard?.code,
|
|
route.destinationYard?.label,
|
|
route.destinationYard?.code,
|
|
...routeStops(route),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase();
|
|
return searchable.includes(query);
|
|
});
|
|
}, [routesQuery.data, search]);
|
|
|
|
const pageCount = Math.max(1, Math.ceil(filteredRoutes.length / pagination.pageSize));
|
|
const pagedRoutes = useMemo(() => {
|
|
const start = pagination.pageIndex * pagination.pageSize;
|
|
return filteredRoutes.slice(start, start + pagination.pageSize);
|
|
}, [filteredRoutes, pagination.pageIndex, pagination.pageSize]);
|
|
|
|
const yardOptions = useMemo(
|
|
() =>
|
|
(yardsQuery.data ?? []).map((yard) => ({
|
|
value: yard.id,
|
|
label: `${yard.label} (${yard.code})`,
|
|
})),
|
|
[yardsQuery.data],
|
|
);
|
|
|
|
const resetForm = () => {
|
|
setFormOpen(false);
|
|
setEditing(null);
|
|
setForm(emptyForm());
|
|
};
|
|
|
|
const openCreate = () => {
|
|
setEditing(null);
|
|
setForm(emptyForm());
|
|
setFormOpen(true);
|
|
};
|
|
|
|
const openEdit = (route: RouteRecord) => {
|
|
setEditing(route);
|
|
setForm({
|
|
name: route.name,
|
|
milestones: (route.milestones ?? [])
|
|
.sort((left, right) => left.sequenceNo - right.sequenceNo)
|
|
.map((milestone) => milestone.yardId),
|
|
});
|
|
setFormOpen(true);
|
|
};
|
|
|
|
const setMilestone = (index: number, yardId: string) => {
|
|
setForm((current) => ({
|
|
...current,
|
|
milestones: current.milestones.map((value, currentIndex) =>
|
|
currentIndex === index ? yardId : value,
|
|
),
|
|
}));
|
|
};
|
|
|
|
const addMilestone = () => {
|
|
setForm((current) => ({ ...current, milestones: [...current.milestones, ""] }));
|
|
};
|
|
|
|
const removeMilestone = (index: number) => {
|
|
setForm((current) => ({
|
|
...current,
|
|
milestones: current.milestones.filter((_, currentIndex) => currentIndex !== index),
|
|
}));
|
|
};
|
|
|
|
const handleSubmit = async (event: FormEvent) => {
|
|
event.preventDefault();
|
|
if (!form.name.trim()) {
|
|
toast({ title: "Save failed", description: "Route name is required", variant: "destructive" });
|
|
return;
|
|
}
|
|
if (form.milestones.length < 2 || form.milestones.some((yardId) => !yardId)) {
|
|
toast({
|
|
title: "Save failed",
|
|
description: "Select at least an origin and destination yard",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = {
|
|
name: form.name.trim(),
|
|
milestones: form.milestones.map((yardId) => ({ yardId })),
|
|
isActive: editing?.isActive ?? true,
|
|
};
|
|
if (editing) {
|
|
await updateMutation.mutateAsync({ id: editing.id, data: payload });
|
|
toast({ title: "Route updated" });
|
|
} else {
|
|
await createMutation.mutateAsync(payload);
|
|
toast({ title: "Route created" });
|
|
}
|
|
resetForm();
|
|
} catch (error) {
|
|
toast({ title: "Save failed", description: normalizeRouteError(error), variant: "destructive" });
|
|
}
|
|
};
|
|
|
|
const handleDeactivate = async (route: RouteRecord) => {
|
|
try {
|
|
await deactivateMutation.mutateAsync(route.id);
|
|
toast({ title: "Route deactivated" });
|
|
} catch {
|
|
toast({ title: "Deactivate failed", description: "Could not deactivate route", variant: "destructive" });
|
|
}
|
|
};
|
|
|
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
|
|
|
const availableOptionsForIndex = (index: number) => {
|
|
const selectedByOthers = new Set(
|
|
form.milestones.filter((value, currentIndex) => currentIndex !== index && value),
|
|
);
|
|
return yardOptions.filter(
|
|
(option) => option.value === form.milestones[index] || !selectedByOthers.has(option.value),
|
|
);
|
|
};
|
|
|
|
const tableStatus = routesQuery.isLoading
|
|
? "loading"
|
|
: routesQuery.isError
|
|
? "error"
|
|
: "success";
|
|
|
|
const columns = useMemo((): ColumnDef<RouteRecord>[] => {
|
|
const headerClassName = ruleEngineTable.headerCell;
|
|
const cellClassName = ruleEngineTable.bodyCell;
|
|
return [
|
|
{ id: "name", header: "Name", meta: { headerClassName, cellClassName }, cell: ({ row }) => row.original.name },
|
|
{
|
|
id: "origin",
|
|
header: "Origin",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => yardLabel(row.original.originYard),
|
|
},
|
|
{
|
|
id: "destination",
|
|
header: "Destination",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => yardLabel(row.original.destinationYard),
|
|
},
|
|
{
|
|
id: "milestones",
|
|
header: "Milestones",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => Math.max((row.original.milestones?.length ?? 0) - 2, 0),
|
|
},
|
|
{
|
|
id: "status",
|
|
header: "Status",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => (
|
|
<Badge color={row.original.isActive ? "green" : "gray"} variant="light" size="sm">
|
|
{row.original.isActive ? "Active" : "Inactive"}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "Actions",
|
|
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
|
|
cell: ({ row }) => (
|
|
<Group gap={4} justify="flex-end" wrap="nowrap">
|
|
<Tooltip label="View">
|
|
<ActionIcon variant="subtle" color="gray" onClick={() => setViewing(row.original)}>
|
|
<Eye size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
<Tooltip label="Edit">
|
|
<ActionIcon variant="subtle" color="gray" onClick={() => openEdit(row.original)}>
|
|
<Edit size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
<Tooltip label="Deactivate">
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="red"
|
|
disabled={!row.original.isActive || deactivateMutation.isPending}
|
|
onClick={() => handleDeactivate(row.original)}
|
|
>
|
|
<Trash2 size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</Group>
|
|
),
|
|
},
|
|
];
|
|
}, [deactivateMutation.isPending]);
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
|
<Stack gap={0}>
|
|
<Box px="md" pt="md" pb="sm" w="100%">
|
|
<FleetToolbar
|
|
search={search}
|
|
onSearchChange={setSearch}
|
|
searchPlaceholder="Search routes…"
|
|
addLabel="Add Route"
|
|
onAdd={openCreate}
|
|
viewMode={viewMode}
|
|
onViewModeChange={setViewMode}
|
|
/>
|
|
</Box>
|
|
|
|
{viewMode === "table" ? (
|
|
<DataTable
|
|
columns={columns}
|
|
data={pagedRoutes}
|
|
status={tableStatus}
|
|
emptyMessage="No routes found"
|
|
pagination={{
|
|
pageIndex: pagination.pageIndex,
|
|
pageSize: pagination.pageSize,
|
|
pageCount,
|
|
totalCount: filteredRoutes.length,
|
|
}}
|
|
tableOptions={{
|
|
manualPagination: true,
|
|
pageCount,
|
|
state: { pagination },
|
|
onPaginationChange: setPagination,
|
|
}}
|
|
containerClassName="border-0 shadow-none bg-transparent"
|
|
footer={({ table, pagination: footerPagination }) => (
|
|
<DataTableFooter
|
|
table={table}
|
|
pagination={footerPagination}
|
|
options={{ labels: { items: "routes" } }}
|
|
/>
|
|
)}
|
|
/>
|
|
) : (
|
|
<Stack gap={0}>
|
|
{tableStatus === "loading" ? (
|
|
<Text py="xl" ta="center" c="dimmed" size="sm">
|
|
Loading…
|
|
</Text>
|
|
) : !pagedRoutes.length ? (
|
|
<Text py="xl" ta="center" c="dimmed" size="sm">
|
|
No routes found
|
|
</Text>
|
|
) : (
|
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" p="md">
|
|
{pagedRoutes.map((route) => (
|
|
<Card key={route.id} radius="lg" padding="lg" withBorder>
|
|
<Stack gap="sm">
|
|
<Group justify="space-between">
|
|
<Text fw={600}>{route.name}</Text>
|
|
<Badge color={route.isActive ? "green" : "gray"} variant="light" size="sm">
|
|
{route.isActive ? "Active" : "Inactive"}
|
|
</Badge>
|
|
</Group>
|
|
<Text size="sm" c="dimmed">
|
|
{yardLabel(route.originYard)} → {yardLabel(route.destinationYard)}
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{Math.max((route.milestones?.length ?? 0) - 2, 0)} intermediate milestones
|
|
</Text>
|
|
<Group gap={6} justify="flex-end">
|
|
<Button variant="light" size="compact-sm" onClick={() => setViewing(route)}>
|
|
View
|
|
</Button>
|
|
<Button variant="light" size="compact-sm" onClick={() => openEdit(route)}>
|
|
Edit
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Card>
|
|
))}
|
|
</SimpleGrid>
|
|
)}
|
|
<RuleEngineListFooter
|
|
pagination={pagination}
|
|
pageCount={pageCount}
|
|
totalCount={filteredRoutes.length}
|
|
itemLabel="routes"
|
|
onPaginationChange={setPagination}
|
|
/>
|
|
</Stack>
|
|
)}
|
|
</Stack>
|
|
</Card>
|
|
|
|
<Modal
|
|
opened={formOpen}
|
|
onClose={resetForm}
|
|
title={<Text fw={600}>{editing ? "Edit Route" : "Add Route"}</Text>}
|
|
size="lg"
|
|
radius="lg"
|
|
centered
|
|
>
|
|
<form onSubmit={handleSubmit}>
|
|
<Stack gap="md">
|
|
<TextInput
|
|
label="Name"
|
|
value={form.name}
|
|
onChange={(e) => setForm((current) => ({ ...current, name: e.currentTarget.value }))}
|
|
/>
|
|
<Group justify="space-between">
|
|
<Text size="sm" fw={500}>
|
|
Stops
|
|
</Text>
|
|
<Button type="button" variant="light" size="compact-sm" onClick={addMilestone}>
|
|
Add milestone
|
|
</Button>
|
|
</Group>
|
|
{form.milestones.map((yardId, index) => {
|
|
const role =
|
|
index === 0
|
|
? "Origin"
|
|
: index === form.milestones.length - 1
|
|
? "Destination"
|
|
: "Milestone";
|
|
return (
|
|
<Group key={`${role}-${index}`} align="flex-end" wrap="nowrap">
|
|
<Text w={100} size="sm" fw={500}>
|
|
{role}
|
|
</Text>
|
|
<Select
|
|
style={{ flex: 1 }}
|
|
data={availableOptionsForIndex(index)}
|
|
value={yardId || null}
|
|
onChange={(value) => value && setMilestone(index, value)}
|
|
placeholder="Select yard"
|
|
searchable
|
|
/>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="red"
|
|
disabled={form.milestones.length <= 2}
|
|
onClick={() => removeMilestone(index)}
|
|
>
|
|
<Trash2 size={16} />
|
|
</ActionIcon>
|
|
</Group>
|
|
);
|
|
})}
|
|
<Group justify="flex-end">
|
|
<Button variant="default" type="button" onClick={resetForm}>
|
|
Cancel
|
|
</Button>
|
|
<Button color="green" type="submit" loading={isSaving}>
|
|
Save
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</form>
|
|
</Modal>
|
|
|
|
<Modal
|
|
opened={Boolean(viewing)}
|
|
onClose={() => setViewing(null)}
|
|
title={<Text fw={600}>Route details</Text>}
|
|
radius="lg"
|
|
centered
|
|
>
|
|
{viewing ? (
|
|
<Stack gap="sm">
|
|
<div>
|
|
<Text size="sm" fw={500}>
|
|
Name
|
|
</Text>
|
|
<Text size="sm" c="dimmed">
|
|
{viewing.name}
|
|
</Text>
|
|
</div>
|
|
<div>
|
|
<Text size="sm" fw={500}>
|
|
Status
|
|
</Text>
|
|
<Text size="sm" c="dimmed">
|
|
{viewing.isActive ? "Active" : "Inactive"}
|
|
</Text>
|
|
</div>
|
|
<div>
|
|
<Text size="sm" fw={500}>
|
|
Stops
|
|
</Text>
|
|
<Stack gap={6} mt={6}>
|
|
{routeStops(viewing).map((stop, index, stops) => (
|
|
<Text key={`${stop}-${index}`} size="sm" c="dimmed">
|
|
{index === 0
|
|
? "Origin"
|
|
: index === stops.length - 1
|
|
? "Destination"
|
|
: `Milestone ${index}`}
|
|
: {stop}
|
|
</Text>
|
|
))}
|
|
</Stack>
|
|
</div>
|
|
</Stack>
|
|
) : null}
|
|
</Modal>
|
|
</Stack>
|
|
);
|
|
}
|