Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx
hager eeac88f3be fix(freight-backoffice): identify yards by name rather than code
Operators know the yards by name, not by the internal code: the yard coded
KALITY is universally called GMP / Gelan Multipurpose Port (Indode), so
rendering the code alongside the name read as two different places.

Show the name alone in route creation, the yard desks modal and the
booking route card, falling back to the code only when a yard has no
name. The code is unchanged in the data and remains searchable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:37:02 +00:00

863 lines
29 KiB
TypeScript

import { FormEvent, useEffect, useMemo, useState } from "react";
import {
ArrowRight,
Ban,
CircleCheck,
Edit,
Eye,
Plus,
Route as RouteIcon,
Trash2,
ShieldAlert,
} from "lucide-react";
import type { ColumnDef } from "@edr/ui-common";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Divider,
Group,
Modal,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
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 { api } from "@/services/api";
import { ruleEngineService } from "@/services/ruleEngine/ruleEngine.service";
import { useAuth } from "@/auth/useAuth";
import { canFleetAction, canFleetHardDelete } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import {
formatRouteLabel,
ROUTE_STATUS_OPTIONS,
totalRouteDistanceKm,
type RouteRecord,
type RouteStatus,
type YardRef,
} from "@/services/routes.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
type MilestoneFormRow = { yardId: string };
type RouteFormState = {
status: RouteStatus;
milestones: MilestoneFormRow[];
};
const emptyForm = (): RouteFormState => ({
status: "AVAILABLE",
milestones: [{ yardId: "" }, { yardId: "" }],
});
/** Order-insensitive pair key — yard distances are symmetric. */
const pairKey = (a: string, b: string) => (a < b ? `${a}|${b}` : `${b}|${a}`);
// Yards are identified to operators by name, not by their internal code: the
// yard coded KALITY is universally called GMP / Gelan Multipurpose Port, and
// showing both read as two different places. The code stays in the data.
const yardLabel = (yard?: YardRef | null) =>
yard ? (yard.label ?? yard.code) : "—";
const statusColor = (status: RouteStatus) => {
switch (status) {
case "AVAILABLE":
return "edr-green";
case "MAINTENANCE":
return "yellow";
case "DAMAGED":
return "red";
case "STOP_WORKING":
return "gray";
default:
return "gray";
}
};
const statusLabel = (status: RouteStatus) =>
ROUTE_STATUS_OPTIONS.find((o) => o.value === status)?.label ?? status;
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";
};
function RouteTimeline({ route }: { route: RouteRecord }) {
const stops = [...(route.milestones ?? [])].sort(
(a, b) => a.sequenceNo - b.sequenceNo,
);
const total = totalRouteDistanceKm(route);
return (
<Stack gap="sm">
{stops.map((milestone, index) => {
const label =
milestone.yard?.label ?? milestone.yard?.code ?? milestone.yardId;
const role =
index === 0
? "Origin"
: index === stops.length - 1
? "Destination"
: `Milestone ${index}`;
const km = Number(milestone.distanceKm ?? 0);
return (
<Box key={milestone.id ?? `${milestone.yardId}-${index}`}>
{index > 0 && (
<Group gap={8} pl={18} py={6}>
<ThemeIcon size={22} radius="xl" variant="light" color="gray">
<ArrowRight size={12} />
</ThemeIcon>
<Text size="xs" c="dimmed" fw={600}>
{km} km
</Text>
</Group>
)}
<Group gap="sm" wrap="nowrap">
<Badge size="sm" variant="light" color={index === 0 ? "teal" : "gray"}>
{role}
</Badge>
<Text size="sm" fw={500}>
{label}
</Text>
</Group>
</Box>
);
})}
<Divider />
<Group justify="space-between">
<Text size="sm" fw={600}>
Total distance
</Text>
<Text size="sm" fw={700}>
{total} km
</Text>
</Group>
</Stack>
);
}
export default function RoutesPage() {
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
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 { user } = useAuth();
const canCreate = canFleetAction(user, "routes", "create");
const canUpdate = canFleetAction(user, "routes", "update");
const canDelete = canFleetAction(user, "routes", "delete");
// Irreversible purge needs its own grant — the coarse fleet:manage key that
// canFleetAction accepts deliberately does not unlock it.
const canPurge = canFleetHardDelete(user, "routes");
// Both destructive actions confirm first: deactivate is recoverable but still
// changes what operations can book, and a purge cannot be undone at all.
const [deactivateTarget, setDeactivateTarget] = useState<RouteRecord | null>(null);
const [purgeTarget, setPurgeTarget] = useState<RouteRecord | null>(null);
const [purgeConfirmText, setPurgeConfirmText] = useState("");
const routesQuery = useQuery({
...api.routes.listPaged.queryOptions({
input: {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
},
}),
placeholderData: keepPreviousData,
});
// KPI counts stay whole-fleet (they must not move with the search box), so
// they come from two count-only pages rather than the visible one.
const totalCountQuery = useQuery(
api.routes.listPaged.queryOptions({ input: { page: 1, pageSize: 1 } }),
);
const availableCountQuery = useQuery(
api.routes.listPaged.queryOptions({
input: { page: 1, pageSize: 1, status: "AVAILABLE" },
}),
);
const yardsQuery = useQuery(api.routes.yards.queryOptions());
// Segment km are configured in Configuration → Yard Distances and resolved
// by the API on save; this fetch is only to preview them in the form.
const yardDistancesQuery = useQuery({
queryKey: ["yard-distances", "all"],
queryFn: () =>
ruleEngineService.listAll<{ id: string; fromYardId: string; toYardId: string; distanceKm: string }>(
"yard-distances",
),
});
const createMutation = useMutation(api.routes.create.mutationOptions());
const updateMutation = useMutation(api.routes.update.mutationOptions());
const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions());
const purgeMutation = useMutation(api.routes.purge.mutationOptions());
// Narrowing the result set can strand the user on a page that no longer
// exists (search down to 3 rows while on page 5 → empty table).
useEffect(() => {
setPagination((prev) => (prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }));
}, [debouncedSearch, setPagination]);
// Filtering, sorting and the page window all happen server-side.
const pagedRoutes = routesQuery.data?.items ?? [];
const matchCount = routesQuery.data?.meta.total ?? 0;
const pageCount = Math.max(1, routesQuery.data?.meta.totalPages ?? 1);
const totalRoutes = totalCountQuery.data?.meta.total ?? 0;
const availableCount = availableCountQuery.data?.meta.total ?? 0;
const yardOptions = useMemo(
() =>
(yardsQuery.data ?? []).map((yard) => ({
value: yard.id,
label: yard.label ?? yard.code,
})),
[yardsQuery.data],
);
const distanceByPair = useMemo(() => {
const map = new Map<string, number>();
for (const row of yardDistancesQuery.data ?? []) {
map.set(pairKey(row.fromYardId, row.toYardId), Number(row.distanceKm));
}
return map;
}, [yardDistancesQuery.data]);
/** Configured km for the segment ending at `index` (undefined = pair not configured yet). */
const segmentKm = (index: number): number | undefined => {
if (index === 0) return 0;
const from = form.milestones[index - 1]?.yardId;
const to = form.milestones[index]?.yardId;
if (!from || !to) return undefined;
return distanceByPair.get(pairKey(from, to));
};
const formTotalKm = useMemo(() => {
let total = 0;
for (let i = 1; i < form.milestones.length; i++) {
const from = form.milestones[i - 1]?.yardId;
const to = form.milestones[i]?.yardId;
if (!from || !to) continue;
total += distanceByPair.get(pairKey(from, to)) ?? 0;
}
return total;
}, [form.milestones, distanceByPair]);
const resetForm = () => {
setFormOpen(false);
setEditing(null);
setForm(emptyForm());
};
const openCreate = () => {
setEditing(null);
setForm(emptyForm());
setFormOpen(true);
};
const openEdit = (route: RouteRecord) => {
setEditing(route);
setForm({
status: route.status,
milestones: [...(route.milestones ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((m) => ({ yardId: m.yardId })),
});
setFormOpen(true);
};
const setMilestone = (index: number, patch: Partial<MilestoneFormRow>) => {
setForm((current) => ({
...current,
milestones: current.milestones.map((row, i) =>
i === index ? { ...row, ...patch } : row,
),
}));
};
const addMilestone = () => {
setForm((current) => ({
...current,
milestones: [...current.milestones, { yardId: "" }],
}));
};
const removeMilestone = (index: number) => {
setForm((current) => ({
...current,
milestones: current.milestones.filter((_, i) => i !== index),
}));
};
const buildPayload = () => ({
status: form.status,
milestones: form.milestones.map((row) => ({ yardId: row.yardId })),
});
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
if (form.milestones.length < 2 || form.milestones.some((row) => !row.yardId)) {
toast({
title: "Save failed",
description: "Select at least an origin and destination yard",
variant: "destructive",
});
return;
}
// Pre-empt the API's missing-pair rejection with a readable message; if the
// distance list failed to load, skip and let the API validate.
if (yardDistancesQuery.data) {
const missing: string[] = [];
for (let i = 1; i < form.milestones.length; i++) {
const from = form.milestones[i - 1].yardId;
const to = form.milestones[i].yardId;
if (!distanceByPair.has(pairKey(from, to))) {
const label = (id: string) =>
yardOptions.find((o) => o.value === id)?.label ?? id;
missing.push(`${label(from)}${label(to)}`);
}
}
if (missing.length > 0) {
toast({
title: "Save failed",
description: `No distance configured for: ${missing.join(", ")}. Add it under Configuration → Yard Distances first.`,
variant: "destructive",
});
return;
}
}
try {
const payload = buildPayload();
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 () => {
if (!deactivateTarget) return;
try {
await deactivateMutation.mutateAsync(deactivateTarget.id);
toast({ title: "Route marked stop working" });
setDeactivateTarget(null);
} catch {
toast({ title: "Update failed", description: "Could not update route status", variant: "destructive" });
}
};
const closePurge = () => {
setPurgeTarget(null);
setPurgeConfirmText("");
};
/** The label the operator must retype to confirm an irreversible purge. */
const purgeLabel = purgeTarget ? formatRouteLabel(purgeTarget) : "";
const handlePurge = async () => {
if (!purgeTarget) return;
try {
await purgeMutation.mutateAsync(purgeTarget.id);
toast({ title: "Route permanently deleted" });
closePurge();
} catch (error) {
toast({
title: "Permanent delete failed",
description: normalizeRouteError(error),
variant: "destructive",
});
}
};
const handleStatusChange = async (route: RouteRecord, status: RouteStatus) => {
try {
await updateMutation.mutateAsync({ id: route.id, data: { status } });
setViewing((current) => (current?.id === route.id ? { ...current, status } : current));
toast({ title: "Status updated" });
} catch (error) {
toast({ title: "Update failed", description: normalizeRouteError(error), variant: "destructive" });
}
};
const isSaving = createMutation.isPending || updateMutation.isPending;
const availableOptionsForIndex = (index: number) => {
const selectedByOthers = new Set(
form.milestones
.filter((row, i) => i !== index && row.yardId)
.map((row) => row.yardId),
);
return yardOptions.filter(
(option) =>
option.value === form.milestones[index]?.yardId ||
!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: "corridor",
header: "Corridor",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text fw={600} size="sm">
{formatRouteLabel(row.original)}
</Text>
),
},
{
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: "distance",
header: "Total KM",
meta: { headerClassName, cellClassName },
cell: ({ row }) => `${totalRouteDistanceKm(row.original)} km`,
},
{
id: "milestones",
header: "Stops",
meta: { headerClassName, cellClassName },
cell: ({ row }) => row.original.milestones?.length ?? 0,
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge color={statusColor(row.original.status)} variant="light" size="sm">
{statusLabel(row.original.status)}
</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>
{canUpdate ? (
<Tooltip label="Edit">
<ActionIcon variant="subtle" color="gray" onClick={() => openEdit(row.original)}>
<Edit size={16} />
</ActionIcon>
</Tooltip>
) : null}
{canDelete ? (
<Tooltip label="Mark stop working">
<ActionIcon
variant="subtle"
color="red"
disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending}
onClick={() => setDeactivateTarget(row.original)}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
) : null}
{canPurge ? (
<Tooltip label="Delete permanently">
<ActionIcon
variant="subtle"
color="red"
disabled={purgeMutation.isPending}
onClick={() => setPurgeTarget(row.original)}
>
<ShieldAlert size={16} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
),
},
];
}, [deactivateMutation.isPending, purgeMutation.isPending, canUpdate, canDelete, canPurge]);
return (
<PageContainer>
<PageHeader
title="Routes"
subtitle="Define rail corridors, segment distances, and operational status for train scheduling."
action={
canCreate ? (
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
Add route
</Button>
) : undefined
}
/>
<KpiStrip
loading={routesQuery.isLoading}
items={[
{ label: "Total routes", value: totalRoutes, icon: RouteIcon },
{ label: "Available", value: availableCount, icon: CircleCheck, color: "edr-green" },
{
label: "Unavailable",
value: totalRoutes - availableCount,
icon: Ban,
color: "gray",
},
]}
/>
<Card radius="lg" padding={0} withBorder>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search corridors…"
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: matchCount,
}}
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}>{formatRouteLabel(route)}</Text>
<Badge color={statusColor(route.status)} variant="light" size="sm">
{statusLabel(route.status)}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{totalRouteDistanceKm(route)} km · {route.milestones?.length ?? 0} stops
</Text>
<Group gap={6} justify="flex-end">
<Button variant="light" size="compact-sm" onClick={() => setViewing(route)}>
View
</Button>
{canUpdate ? (
<Button variant="light" size="compact-sm" onClick={() => openEdit(route)}>
Edit
</Button>
) : null}
</Group>
</Stack>
</Card>
))}
</SimpleGrid>
)}
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={matchCount}
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">
{editing && (
<Select
label="Status"
data={ROUTE_STATUS_OPTIONS}
value={form.status}
onChange={(value) =>
value && setForm((current) => ({ ...current, status: value as RouteStatus }))
}
/>
)}
<Group justify="space-between">
<Text size="sm" fw={500}>
Stops & segment distances
</Text>
<Button type="button" variant="light" size="compact-sm" onClick={addMilestone}>
Add milestone
</Button>
</Group>
{form.milestones.map((row, index) => {
const role =
index === 0
? "Origin"
: index === form.milestones.length - 1
? "Destination"
: "Milestone";
const km = segmentKm(index);
const bothSelected =
index > 0 && Boolean(row.yardId && form.milestones[index - 1]?.yardId);
return (
<Group key={`${role}-${index}`} align="center" wrap="nowrap" gap="sm">
<Text w={90} size="sm" fw={500}>
{role}
</Text>
<Select
style={{ flex: 1 }}
data={availableOptionsForIndex(index)}
value={row.yardId || null}
onChange={(value) => value && setMilestone(index, { yardId: value })}
placeholder="Select yard"
searchable
/>
{index > 0 ? (
<Box w={120}>
{bothSelected ? (
km != null ? (
<Text size="sm" fw={600} ta="right">
{km} km
</Text>
) : (
<Tooltip label="No distance configured for this yard pair — add it under Configuration → Yard Distances">
<Text size="xs" c="red.7" fw={600} ta="right">
Not configured
</Text>
</Tooltip>
)
) : (
<Text size="xs" c="dimmed" ta="right">
km
</Text>
)}
</Box>
) : (
<Box w={120} />
)}
<ActionIcon
variant="subtle"
color="red"
disabled={form.milestones.length <= 2}
onClick={() => removeMilestone(index)}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
);
})}
<Text size="sm" c="dimmed">
Total route distance: <strong>{formTotalKm} km</strong> segment
distances come from Configuration Yard Distances
</Text>
<Group justify="flex-end">
<Button variant="default" type="button" onClick={resetForm}>
Cancel
</Button>
<Button color="edr-green" type="submit" loading={isSaving}>
Save
</Button>
</Group>
</Stack>
</form>
</Modal>
<Modal
opened={Boolean(deactivateTarget)}
onClose={() => setDeactivateTarget(null)}
title={<Text fw={600}>Mark stop working</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm">
Stop new operations on{" "}
<Text span fw={700}>
{deactivateTarget ? formatRouteLabel(deactivateTarget) : ""}
</Text>
? The route keeps its history and can no longer be booked.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setDeactivateTarget(null)}>
Cancel
</Button>
<Button
color="red"
loading={deactivateMutation.isPending}
onClick={handleDeactivate}
>
Mark stop working
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={Boolean(purgeTarget)}
onClose={closePurge}
title={<Text fw={600}>Delete permanently</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm">
This permanently removes{" "}
<Text span fw={700}>
{purgeLabel}
</Text>{" "}
and its stops from the database. It cannot be undone.
</Text>
<Text size="sm" c="dimmed">
Only unused routes can be purged if any train schedule still
references it, the request is refused and you should mark it stop
working instead.
</Text>
<TextInput
label="Type the route to confirm"
placeholder={purgeLabel}
value={purgeConfirmText}
onChange={(e) => setPurgeConfirmText(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={closePurge}>
Cancel
</Button>
<Button
color="red"
loading={purgeMutation.isPending}
disabled={purgeConfirmText.trim() !== purgeLabel || !purgeLabel}
onClick={handlePurge}
>
Delete permanently
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={Boolean(viewing)}
onClose={() => setViewing(null)}
title={<Text fw={600}>{viewing ? formatRouteLabel(viewing) : "Route details"}</Text>}
radius="lg"
centered
size="md"
>
{viewing ? (
<Stack gap="md">
<Group justify="space-between" align="flex-end">
<div>
<Text size="sm" fw={500}>
Status
</Text>
<Badge mt={4} color={statusColor(viewing.status)} variant="light">
{statusLabel(viewing.status)}
</Badge>
</div>
<Select
w={200}
label="Update status"
data={ROUTE_STATUS_OPTIONS}
value={viewing.status}
onChange={(value) =>
value && handleStatusChange(viewing, value as RouteStatus)
}
/>
</Group>
<div>
<Text size="sm" fw={500} mb={8}>
Road timeline
</Text>
<RouteTimeline route={viewing} />
</div>
</Stack>
) : null}
</Modal>
</PageContainer>
);
}