mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
424 lines
17 KiB
TypeScript
424 lines
17 KiB
TypeScript
import { Fragment, useEffect, useMemo, useState } from "react";
|
|
import { useMutation } from "@tanstack/react-query";
|
|
import { isAxiosError } from "axios";
|
|
import {
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Group,
|
|
Paper,
|
|
Stack,
|
|
Table,
|
|
Text,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import { ArrowLeftRight, Boxes, Info, MoveRight, Wheat, X } from "lucide-react";
|
|
|
|
import { api } from "@/services/api";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
|
|
|
type Slot = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
|
type Stop = { yardId: string; label: string };
|
|
type Span = [number, number];
|
|
|
|
/** One physical wagon of the consist with every slot (leg load) pinned to it. */
|
|
interface WagonRow {
|
|
key: string;
|
|
physicalWagonId: string | null;
|
|
label: string;
|
|
position: number;
|
|
typeCode: string | null;
|
|
capacityTons: number;
|
|
slots: Array<{ slot: Slot; span: Span; loaded: boolean }>;
|
|
}
|
|
|
|
const round1 = (n: number) => Math.round(n * 10) / 10;
|
|
const overlaps = (a: Span, b: Span) => a[0] < b[1] && b[0] < a[1];
|
|
|
|
/**
|
|
* Leg board: rows = physical wagons in coupling order, columns = corridor legs
|
|
* (A→B, B→C, …). A wagon reused on disjoint legs shows one load per leg on the
|
|
* same row, so a "53 full on A→B, 53 full on C→D" train reads at a glance.
|
|
* Loads move by click: pick a load, then click a wagon that is free on that
|
|
* load's legs (move) or another load (swap). Same API as the consist strip.
|
|
*/
|
|
export function LegLoadBoardPanel({
|
|
schedule,
|
|
onChanged,
|
|
}: {
|
|
schedule: TrainScheduleDetail;
|
|
onChanged?: () => void;
|
|
}) {
|
|
const { toast } = useToast();
|
|
const stops: Stop[] = schedule.stops ?? [];
|
|
const legs = useMemo(
|
|
() => stops.slice(0, -1).map((from, i) => ({ from, to: stops[i + 1], idx: i })),
|
|
[stops],
|
|
);
|
|
const canRearrange = !["DISPATCHED", "ARRIVED", "CANCELLED"].includes(schedule.status);
|
|
|
|
const spanOf = (slot: Slot): Span => {
|
|
const from = slot.boardYardId ? stops.findIndex((s) => s.yardId === slot.boardYardId) : 0;
|
|
const to = slot.alightYardId
|
|
? stops.findIndex((s) => s.yardId === slot.alightYardId)
|
|
: stops.length - 1;
|
|
return [from < 0 ? 0 : from, to < 0 ? Math.max(1, stops.length - 1) : to];
|
|
};
|
|
|
|
const rows: WagonRow[] = useMemo(() => {
|
|
const byKey = new Map<string, WagonRow>();
|
|
for (const slot of schedule.trainSet?.wagons ?? []) {
|
|
const key = slot.physicalWagonId ?? `slot:${slot.id}`;
|
|
let row = byKey.get(key);
|
|
if (!row) {
|
|
row = {
|
|
key,
|
|
physicalWagonId: slot.physicalWagonId ?? null,
|
|
label: slot.physicalWagonNumber ?? `#${slot.position ?? slot.sequenceNo}`,
|
|
position: slot.position ?? slot.sequenceNo,
|
|
typeCode: slot.wagonType?.code ?? null,
|
|
capacityTons: slot.capacityTons ?? 0,
|
|
slots: [],
|
|
};
|
|
byKey.set(key, row);
|
|
}
|
|
row.position = Math.min(row.position, slot.position ?? slot.sequenceNo);
|
|
// Coupled-but-empty consist wagons carry no slot row: they are a target only.
|
|
if (!slot.consistOnly) {
|
|
row.slots.push({
|
|
slot,
|
|
span: spanOf(slot),
|
|
loaded: (slot.allocations?.length ?? 0) > 0,
|
|
});
|
|
}
|
|
}
|
|
return [...byKey.values()].sort((a, b) => a.position - b.position);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [schedule.trainSet?.wagons, stops]);
|
|
|
|
const [picked, setPicked] = useState<{ slotId: string; rowKey: string; span: Span } | null>(
|
|
null,
|
|
);
|
|
useEffect(() => {
|
|
if (!picked) return;
|
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setPicked(null);
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [picked]);
|
|
|
|
const moveMutation = useMutation(api.trainScheduling.moveWagonLoad.mutationOptions());
|
|
const doMove = async (targetWagonId: string, swap: boolean) => {
|
|
if (!picked || moveMutation.isPending) return;
|
|
try {
|
|
await moveMutation.mutateAsync({
|
|
scheduleId: schedule.id,
|
|
wagonId: picked.slotId,
|
|
targetWagonId,
|
|
});
|
|
toast({ title: swap ? "Loads swapped" : "Load moved" });
|
|
setPicked(null);
|
|
onChanged?.();
|
|
} catch (error) {
|
|
const message = isAxiosError(error)
|
|
? ((error.response?.data as { message?: string | string[] } | undefined)?.message ??
|
|
null)
|
|
: null;
|
|
toast({
|
|
title: "Could not move the load",
|
|
description: Array.isArray(message)
|
|
? message.join(", ")
|
|
: (message ?? "The move was rejected — check wagon type, payload and leg."),
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
if (stops.length < 2) {
|
|
return (
|
|
<Alert color="gray" icon={<Info size={16} />} radius="md">
|
|
This schedule has no corridor stops yet — the leg board needs a route with at least
|
|
two stops.
|
|
</Alert>
|
|
);
|
|
}
|
|
if (!rows.length) {
|
|
return (
|
|
<Alert color="gray" icon={<Info size={16} />} radius="md">
|
|
No wagons on this train yet.
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
const sharedRows = rows.filter((r) => r.slots.filter((s) => s.loaded).length > 1).length;
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
<Group justify="space-between" align="flex-start" wrap="wrap">
|
|
<Stack gap={2}>
|
|
<Text fw={700} size="sm">
|
|
Loads per wagon per leg
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
One row per physical wagon, one column per leg. A wagon reused on different legs
|
|
shows one load per leg.{" "}
|
|
{canRearrange
|
|
? "Click a load to pick it up, then click a wagon free on those legs to move it, or another load to swap."
|
|
: "Read-only — the train has departed."}
|
|
</Text>
|
|
</Stack>
|
|
<Group gap="xs">
|
|
{sharedRows > 0 ? (
|
|
<Badge variant="light" color="violet" radius="sm">
|
|
{sharedRows} wagon{sharedRows === 1 ? "" : "s"} shared across legs
|
|
</Badge>
|
|
) : null}
|
|
{picked ? (
|
|
<Button
|
|
size="xs"
|
|
variant="default"
|
|
leftSection={<X size={14} />}
|
|
onClick={() => setPicked(null)}
|
|
>
|
|
Cancel move (Esc)
|
|
</Button>
|
|
) : null}
|
|
</Group>
|
|
</Group>
|
|
|
|
<Paper withBorder radius="md" style={{ overflowX: "auto" }}>
|
|
<Table verticalSpacing={6} horizontalSpacing="sm" style={{ minWidth: 640 }}>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th style={{ position: "sticky", left: 0, background: "var(--mantine-color-body)", zIndex: 1, width: 180 }}>
|
|
Wagon
|
|
</Table.Th>
|
|
{legs.map((leg) => (
|
|
<Table.Th key={leg.idx} style={{ minWidth: 200 }}>
|
|
<Group gap={4} wrap="nowrap">
|
|
<Text size="xs" fw={700} truncate>
|
|
{leg.from.label}
|
|
</Text>
|
|
<MoveRight size={12} />
|
|
<Text size="xs" fw={700} truncate>
|
|
{leg.to.label}
|
|
</Text>
|
|
</Group>
|
|
</Table.Th>
|
|
))}
|
|
<Table.Th style={{ width: 110 }}>Cargo</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{rows.map((row) => {
|
|
const cargoTons = row.slots.reduce(
|
|
(s, x) =>
|
|
s +
|
|
((x.slot.allocations ?? []).reduce(
|
|
(a, al) => a + (al.allocatedWeightTons ?? 0),
|
|
0,
|
|
) || x.slot.assignedWeightTons || 0),
|
|
0,
|
|
);
|
|
const isPickedRow = picked?.rowKey === row.key;
|
|
// A row can take the picked load when nothing loaded on it rides
|
|
// any of the picked load's legs.
|
|
const rowFreeForPicked =
|
|
!!picked &&
|
|
!isPickedRow &&
|
|
!row.slots.some((s) => s.loaded && overlaps(s.span, picked.span));
|
|
// Where a "move here" lands: an existing empty slot on those legs,
|
|
// else the physical wagon itself (the API mints the slot).
|
|
const emptyTargetSlot = picked
|
|
? row.slots.find((s) => !s.loaded && overlaps(s.span, picked.span))
|
|
: undefined;
|
|
const moveTargetId = emptyTargetSlot?.slot.id ?? row.physicalWagonId ?? null;
|
|
|
|
// Lay slots into leg columns; uncovered legs render as empty cells.
|
|
const cells: React.ReactNode[] = [];
|
|
let col = 0;
|
|
const sorted = [...row.slots].sort((a, b) => a.span[0] - b.span[0]);
|
|
// Empty cell = uncovered leg (target: the physical wagon) or an
|
|
// empty slot (target: that slot). Both take the picked load when
|
|
// the row is free on its legs.
|
|
const emptyCell = (from: number, to: number, targetId = moveTargetId) => {
|
|
const droppable = rowFreeForPicked && canRearrange && !!targetId &&
|
|
!!picked && overlaps([from, to], picked.span);
|
|
return (
|
|
<Table.Td
|
|
key={`e-${from}`}
|
|
colSpan={Math.max(1, to - from)}
|
|
onClick={droppable ? () => void doMove(targetId!, false) : undefined}
|
|
style={{
|
|
cursor: droppable ? "pointer" : "default",
|
|
background: droppable ? "var(--mantine-color-teal-0)" : undefined,
|
|
outline: droppable ? "1px dashed var(--mantine-color-teal-5)" : undefined,
|
|
outlineOffset: -3,
|
|
borderRadius: 6,
|
|
}}
|
|
>
|
|
{droppable ? (
|
|
<Text size="xs" c="teal.7" fw={600} ta="center">
|
|
Move here
|
|
</Text>
|
|
) : (
|
|
<Text size="xs" c="dimmed" ta="center">
|
|
—
|
|
</Text>
|
|
)}
|
|
</Table.Td>
|
|
);
|
|
};
|
|
for (const s of sorted) {
|
|
if (s.span[0] > col) cells.push(emptyCell(col, s.span[0]));
|
|
if (!s.loaded) {
|
|
cells.push(emptyCell(s.span[0], s.span[1], s.slot.id));
|
|
col = Math.max(col, s.span[1]);
|
|
continue;
|
|
}
|
|
const isPicked = picked?.slotId === s.slot.id;
|
|
const swappable =
|
|
!!picked && !isPicked && !isPickedRow && s.loaded && canRearrange;
|
|
const allocs = s.slot.allocations ?? [];
|
|
const bulk = allocs.some((a) => (a.loadType ?? "CONTAINER").toUpperCase() === "BULK");
|
|
const containers = allocs.flatMap((a) => a.containerItems ?? []);
|
|
cells.push(
|
|
<Table.Td
|
|
key={s.slot.id}
|
|
colSpan={Math.max(1, s.span[1] - s.span[0])}
|
|
onClick={
|
|
!canRearrange
|
|
? undefined
|
|
: s.loaded && !picked
|
|
? () => setPicked({ slotId: s.slot.id, rowKey: row.key, span: s.span })
|
|
: swappable
|
|
? () => void doMove(s.slot.id, true)
|
|
: isPicked
|
|
? () => setPicked(null)
|
|
: undefined
|
|
}
|
|
style={{
|
|
cursor: canRearrange && (s.loaded || swappable) ? "pointer" : "default",
|
|
padding: 4,
|
|
}}
|
|
>
|
|
{s.loaded ? (
|
|
<Paper
|
|
radius="sm"
|
|
px={8}
|
|
py={6}
|
|
style={{
|
|
background: bulk
|
|
? "var(--mantine-color-orange-0)"
|
|
: "var(--mantine-color-cyan-0)",
|
|
borderLeft: `4px solid ${
|
|
bulk ? "var(--mantine-color-orange-6)" : "var(--mantine-color-cyan-6)"
|
|
}`,
|
|
outline: isPicked
|
|
? "2px solid var(--mantine-color-edr-green-6)"
|
|
: swappable
|
|
? "1px dashed var(--mantine-color-orange-6)"
|
|
: undefined,
|
|
outlineOffset: 1,
|
|
}}
|
|
>
|
|
<Group gap={6} wrap="nowrap" justify="space-between">
|
|
<Group gap={6} wrap="nowrap">
|
|
{bulk ? <Wheat size={13} /> : <Boxes size={13} />}
|
|
<Text size="xs" fw={700} truncate>
|
|
{[...new Set(allocs.map((a) => a.bookingReference ?? "—"))].join(", ")}
|
|
</Text>
|
|
</Group>
|
|
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
|
{round1(
|
|
allocs.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0),
|
|
)}{" "}
|
|
t
|
|
</Text>
|
|
</Group>
|
|
<Group gap={4} mt={2} wrap="wrap">
|
|
{bulk
|
|
? allocs.map((a) =>
|
|
a.bulkLoad ? (
|
|
<Badge key={a.id} size="xs" variant="light" color="orange" radius="sm">
|
|
{a.bulkLoad.cargoDescription ?? "Bulk"} · {round1(a.bulkLoad.weightTons)} t
|
|
</Badge>
|
|
) : null,
|
|
)
|
|
: containers.map((c) => (
|
|
<Badge key={c.id} size="xs" variant="light" color="cyan" radius="sm">
|
|
{c.containerNumber ?? "no number"}
|
|
</Badge>
|
|
))}
|
|
{swappable ? (
|
|
<Badge size="xs" color="orange" radius="sm" leftSection={<ArrowLeftRight size={10} />}>
|
|
swap
|
|
</Badge>
|
|
) : null}
|
|
</Group>
|
|
</Paper>
|
|
) : (
|
|
<Text size="xs" c="dimmed" ta="center">
|
|
empty
|
|
</Text>
|
|
)}
|
|
</Table.Td>,
|
|
);
|
|
col = Math.max(col, s.span[1]);
|
|
}
|
|
if (col < legs.length) cells.push(emptyCell(col, legs.length));
|
|
|
|
return (
|
|
<Table.Tr
|
|
key={row.key}
|
|
style={{
|
|
background: isPickedRow
|
|
? "var(--mantine-color-green-0)"
|
|
: rowFreeForPicked
|
|
? undefined
|
|
: picked
|
|
? "var(--mantine-color-gray-0)"
|
|
: undefined,
|
|
opacity: picked && !isPickedRow && !rowFreeForPicked ? 0.55 : 1,
|
|
}}
|
|
>
|
|
<Table.Td style={{ position: "sticky", left: 0, background: "var(--mantine-color-body)", zIndex: 1 }}>
|
|
<Group gap={6} wrap="nowrap">
|
|
<Badge variant="outline" color="gray" radius="sm" size="sm">
|
|
#{row.position}
|
|
</Badge>
|
|
<Stack gap={0}>
|
|
<Text size="sm" fw={700}>
|
|
{row.label}
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
{row.typeCode ?? "—"} · {round1(row.capacityTons)} t
|
|
</Text>
|
|
</Stack>
|
|
{row.slots.filter((s) => s.loaded).length > 1 ? (
|
|
<Tooltip label="This wagon carries different loads on different legs">
|
|
<Badge size="xs" color="violet" variant="light" radius="sm">
|
|
shared
|
|
</Badge>
|
|
</Tooltip>
|
|
) : null}
|
|
</Group>
|
|
</Table.Td>
|
|
{cells.map((c, i) => (
|
|
<Fragment key={i}>{c}</Fragment>
|
|
))}
|
|
<Table.Td>
|
|
<Text size="xs" fw={600} c={cargoTons > row.capacityTons + 0.001 ? "red.7" : undefined}>
|
|
{round1(cargoTons)} / {round1(row.capacityTons)} t
|
|
</Text>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
);
|
|
})}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Paper>
|
|
</Stack>
|
|
);
|
|
}
|