mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 10:40:58 +00:00
Merge pull request #1040 from Tria-plc/freight_feature/usermanagement
intercity fix
This commit is contained in:
@@ -9,16 +9,18 @@ import {
|
||||
Modal,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import { AlertTriangle, History, Minus, Plus } from "lucide-react";
|
||||
import { AlertTriangle, ArrowLeftRight, History, MapPin, Minus, Plus } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { ConsistWagonRef } from "@/services/trainBuilder.service";
|
||||
import type { ConsistWagonRef, ScheduleConsist } from "@/services/trainBuilder.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
@@ -34,11 +36,16 @@ const tareOf = (w: ConsistWagonRef) => w.wagonType?.tareWeightTons ?? 0;
|
||||
const lengthOf = (w: ConsistWagonRef) => w.wagonType?.lengthMeters ?? 0;
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
|
||||
type ConsistWagon = ScheduleConsist["wagons"][number];
|
||||
|
||||
/**
|
||||
* Adjust the built train's consist from a schedule: trim free wagons (their
|
||||
* tare no longer rides — the fix when gross weight beats the pull limit) or
|
||||
* couple extra yard wagons while weight/length headroom remains. Changes are
|
||||
* permanent on the train and logged on the schedule.
|
||||
* tare no longer rides — the fix when gross weight beats the pull limit),
|
||||
* couple extra yard wagons while weight/length headroom remains, or SWITCH a
|
||||
* wagon for a same-type replacement — the replacement inherits the slot, cargo
|
||||
* included, which is the only way a loaded wagon leaves the train. Works
|
||||
* before departure and mid-route while the train stands at a checkpointed
|
||||
* stop. Changes are permanent on the train and logged on the schedule.
|
||||
*/
|
||||
export default function AdjustConsistModal({
|
||||
scheduleId,
|
||||
@@ -48,6 +55,9 @@ export default function AdjustConsistModal({
|
||||
const { toast } = useToast();
|
||||
const [removeIds, setRemoveIds] = useState<string[]>([]);
|
||||
const [addIds, setAddIds] = useState<string[]>([]);
|
||||
// fromWagonId → toWagonId. A switch is same-type, so it never moves the
|
||||
// weight/length/slot projections — it only changes which steel rides.
|
||||
const [switchMap, setSwitchMap] = useState<Record<string, string>>({});
|
||||
|
||||
const consistQuery = useQuery(
|
||||
api.trainScheduling.scheduleConsist.queryOptions({
|
||||
@@ -62,13 +72,20 @@ export default function AdjustConsistModal({
|
||||
if (opened) {
|
||||
setRemoveIds([]);
|
||||
setAddIds([]);
|
||||
setSwitchMap({});
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const switchCount = Object.keys(switchMap).length;
|
||||
const usedReplacementIds = useMemo(
|
||||
() => new Set(Object.values(switchMap)),
|
||||
[switchMap],
|
||||
);
|
||||
|
||||
// Live projection: gross = cargo + tare of (consist − trims + adds), plus
|
||||
// the schedule's wagon-slot picture — the consist IS the booking capacity
|
||||
// (weight/length only bind while assembling the consist), so trims/adds
|
||||
// move the FULL line in real time.
|
||||
// move the FULL line in real time. Switches are same-type and cancel out.
|
||||
const projection = useMemo(() => {
|
||||
if (!data) return null;
|
||||
const removed = new Set(removeIds);
|
||||
@@ -117,25 +134,58 @@ export default function AdjustConsistModal({
|
||||
};
|
||||
}, [data, removeIds, addIds]);
|
||||
|
||||
const hasChanges = removeIds.length > 0 || addIds.length > 0;
|
||||
const hasChanges = removeIds.length > 0 || addIds.length > 0 || switchCount > 0;
|
||||
|
||||
const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) =>
|
||||
setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
|
||||
|
||||
// Same-type replacements standing at the current stop, minus wagons already
|
||||
// spoken for by another switch or a couple selection.
|
||||
const switchOptionsFor = (wagon: ConsistWagon) =>
|
||||
(data?.addableWagons ?? [])
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.wagonType?.id === wagon.wagonType?.id &&
|
||||
!addIds.includes(candidate.id) &&
|
||||
(!usedReplacementIds.has(candidate.id) ||
|
||||
switchMap[wagon.id] === candidate.id),
|
||||
)
|
||||
.map((candidate) => ({ value: candidate.id, label: candidate.wagonNumber }));
|
||||
|
||||
const setSwitch = (fromId: string, toId: string | null) =>
|
||||
setSwitchMap((prev) => {
|
||||
const next = { ...prev };
|
||||
if (toId) next[fromId] = toId;
|
||||
else delete next[fromId];
|
||||
return next;
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!removeIds.length && !addIds.length) return;
|
||||
if (!hasChanges) return;
|
||||
try {
|
||||
const result = await adjust.mutateAsync({
|
||||
scheduleId,
|
||||
payload: {
|
||||
...(addIds.length ? { addWagonIds: addIds } : {}),
|
||||
...(removeIds.length ? { removeWagonIds: removeIds } : {}),
|
||||
...(switchCount
|
||||
? {
|
||||
switches: Object.entries(switchMap).map(([fromWagonId, toWagonId]) => ({
|
||||
fromWagonId,
|
||||
toWagonId,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
toast({
|
||||
title: `Consist updated — ${removeIds.length ? `${removeIds.length} trimmed` : ""}${
|
||||
removeIds.length && addIds.length ? ", " : ""
|
||||
}${addIds.length ? `${addIds.length} added` : ""}`,
|
||||
title: `Consist updated — ${[
|
||||
removeIds.length ? `${removeIds.length} trimmed` : "",
|
||||
addIds.length ? `${addIds.length} added` : "",
|
||||
switchCount ? `${switchCount} switched` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}`,
|
||||
});
|
||||
// Schedule-impact warnings from the API: window reopened / now FULL /
|
||||
// consist trimmed below what bookings already hold.
|
||||
@@ -151,6 +201,7 @@ export default function AdjustConsistModal({
|
||||
}
|
||||
setRemoveIds([]);
|
||||
setAddIds([]);
|
||||
setSwitchMap({});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Adjustment failed",
|
||||
@@ -170,7 +221,7 @@ export default function AdjustConsistModal({
|
||||
</Text>
|
||||
}
|
||||
radius="lg"
|
||||
size={860}
|
||||
size={920}
|
||||
centered
|
||||
>
|
||||
{consistQuery.isLoading || !data ? (
|
||||
@@ -181,9 +232,19 @@ export default function AdjustConsistModal({
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{data.currentStop?.isMidRoute ? (
|
||||
<Alert color="blue" icon={<MapPin size={16} />}>
|
||||
Standing at <strong>{data.currentStop.label}</strong> — mid-route
|
||||
consist work is open: couple or switch wagons standing at this
|
||||
stop, trim wagons whose cargo was offloaded here. Detached wagons
|
||||
stay at {data.currentStop.label}.
|
||||
</Alert>
|
||||
) : null}
|
||||
{!data.editable ? (
|
||||
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
||||
The consist is frozen once the train is dispatched.
|
||||
{data.schedule.status === "DISPATCHED"
|
||||
? "The train is rolling — consist changes are only possible while it stands at a route stop."
|
||||
: "The consist can no longer be adjusted — the run is over."}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
@@ -256,37 +317,39 @@ export default function AdjustConsistModal({
|
||||
) : null}
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Grid.Col span={{ base: 12, md: 7 }}>
|
||||
<Stack gap="xs">
|
||||
<Group gap={6}>
|
||||
<Minus size={14} />
|
||||
<Text size="sm" fw={600}>
|
||||
Trim coupled wagons ({data.totals.wagonCount})
|
||||
Coupled wagons ({data.totals.wagonCount})
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Only free (unloaded, unpinned) wagons can be detached. Detaching is
|
||||
permanent — the wagon returns to the yard as available.
|
||||
Trim only wagons carrying nothing beyond this stop. A loaded
|
||||
wagon can't leave — but it can be <strong>switched</strong>:
|
||||
the same-type replacement takes its position and its cargo
|
||||
slot. Detaching is permanent.
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={260} type="auto">
|
||||
<ScrollArea.Autosize mah={280} type="auto">
|
||||
<Stack gap={4}>
|
||||
{data.wagons.map((wagon) => (
|
||||
<WagonRow
|
||||
<CoupledWagonRow
|
||||
key={wagon.id}
|
||||
wagon={wagon}
|
||||
checked={removeIds.includes(wagon.id)}
|
||||
disabled={!data.editable || !wagon.removable}
|
||||
badge={
|
||||
wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null
|
||||
}
|
||||
onToggle={toggle(setRemoveIds)}
|
||||
editable={data.editable}
|
||||
switchValue={switchMap[wagon.id] ?? null}
|
||||
switchOptions={switchOptionsFor(wagon)}
|
||||
onToggleRemove={toggle(setRemoveIds)}
|
||||
onSwitch={setSwitch}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||
<Stack gap="xs">
|
||||
<Group gap={6}>
|
||||
<Plus size={14} />
|
||||
@@ -295,25 +358,30 @@ export default function AdjustConsistModal({
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
AVAILABLE wagons standing in the train's yard. Blocked when they push
|
||||
gross weight or length past the locomotive limits incl. tolerance.
|
||||
AVAILABLE wagons standing at{" "}
|
||||
{data.currentStop?.label ?? "the train's yard"}. Blocked when
|
||||
they push gross weight or length past the locomotive limits
|
||||
incl. tolerance.
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={260} type="auto">
|
||||
<ScrollArea.Autosize mah={280} type="auto">
|
||||
<Stack gap={4}>
|
||||
{data.addableWagons.length ? (
|
||||
data.addableWagons.map((wagon) => (
|
||||
<WagonRow
|
||||
key={wagon.id}
|
||||
wagon={wagon}
|
||||
checked={addIds.includes(wagon.id)}
|
||||
disabled={!data.editable}
|
||||
badge={null}
|
||||
onToggle={toggle(setAddIds)}
|
||||
/>
|
||||
))
|
||||
data.addableWagons.map((wagon) => {
|
||||
const takenBySwitch = usedReplacementIds.has(wagon.id);
|
||||
return (
|
||||
<AddableWagonRow
|
||||
key={wagon.id}
|
||||
wagon={wagon}
|
||||
checked={addIds.includes(wagon.id)}
|
||||
disabled={!data.editable || takenBySwitch}
|
||||
badge={takenBySwitch ? "Switch target" : null}
|
||||
onToggle={toggle(setAddIds)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" py="sm" ta="center">
|
||||
No available wagons in this yard
|
||||
No available wagons at this stop
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -322,6 +390,19 @@ export default function AdjustConsistModal({
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{switchCount ? (
|
||||
<Alert color="blue" icon={<ArrowLeftRight size={16} />} py={8}>
|
||||
{Object.entries(switchMap)
|
||||
.map(([fromId, toId]) => {
|
||||
const from = data.wagons.find((w) => w.id === fromId);
|
||||
const to = data.addableWagons.find((w) => w.id === toId);
|
||||
return `${from?.wagonNumber ?? "?"} → ${to?.wagonNumber ?? "?"}`;
|
||||
})
|
||||
.join(" · ")}{" "}
|
||||
— cargo allocations move to the replacement wagon(s).
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{data.adjustments.length ? (
|
||||
<>
|
||||
<Divider />
|
||||
@@ -339,9 +420,19 @@ export default function AdjustConsistModal({
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={log.action === "ADD" ? "edr-green" : "red"}
|
||||
color={
|
||||
log.action === "ADD"
|
||||
? "edr-green"
|
||||
: log.action === "SWITCH"
|
||||
? "blue"
|
||||
: "red"
|
||||
}
|
||||
>
|
||||
{log.action === "ADD" ? "Added" : "Trimmed"}
|
||||
{log.action === "ADD"
|
||||
? "Added"
|
||||
: log.action === "SWITCH"
|
||||
? "Switched"
|
||||
: "Trimmed"}
|
||||
</Badge>
|
||||
<Text size="xs" ff="monospace">
|
||||
{log.wagonNumber}
|
||||
@@ -369,15 +460,19 @@ export default function AdjustConsistModal({
|
||||
loading={adjust.isPending}
|
||||
disabled={
|
||||
!data.editable ||
|
||||
(!removeIds.length && !addIds.length) ||
|
||||
!hasChanges ||
|
||||
(addIds.length > 0 && (projection?.overWeight || projection?.overLength))
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Apply{" "}
|
||||
{removeIds.length ? `−${removeIds.length}` : ""}
|
||||
{removeIds.length && addIds.length ? " / " : ""}
|
||||
{addIds.length ? `+${addIds.length}` : ""}
|
||||
{[
|
||||
removeIds.length ? `−${removeIds.length}` : "",
|
||||
addIds.length ? `+${addIds.length}` : "",
|
||||
switchCount ? `⇄${switchCount}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" / ")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -429,7 +524,89 @@ function LimitGauge({
|
||||
);
|
||||
}
|
||||
|
||||
function WagonRow({
|
||||
/** Coupled row: trim checkbox (reason-badged when blocked) + switch picker. */
|
||||
function CoupledWagonRow({
|
||||
wagon,
|
||||
checked,
|
||||
editable,
|
||||
switchValue,
|
||||
switchOptions,
|
||||
onToggleRemove,
|
||||
onSwitch,
|
||||
}: {
|
||||
wagon: ConsistWagon;
|
||||
checked: boolean;
|
||||
editable: boolean;
|
||||
switchValue: string | null;
|
||||
switchOptions: Array<{ value: string; label: string }>;
|
||||
onToggleRemove: (id: string, checked: boolean) => void;
|
||||
onSwitch: (fromId: string, toId: string | null) => void;
|
||||
}) {
|
||||
const badge = wagon.loaded ? "Loaded" : !wagon.removable ? "Pinned" : null;
|
||||
const checkbox = (
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={checked}
|
||||
disabled={!editable || !wagon.removable || Boolean(switchValue)}
|
||||
onChange={(e) => onToggleRemove(wagon.id, e.currentTarget.checked)}
|
||||
aria-label={`Trim wagon ${wagon.wagonNumber}`}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Group
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
p={6}
|
||||
style={{
|
||||
border: switchValue
|
||||
? "1px solid var(--mantine-color-blue-4)"
|
||||
: "1px solid var(--mantine-color-gray-2)",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
background: switchValue ? "var(--mantine-color-blue-0)" : undefined,
|
||||
}}
|
||||
>
|
||||
{wagon.blockReason ? (
|
||||
<Tooltip label={wagon.blockReason} withArrow>
|
||||
<span>{checkbox}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
checkbox
|
||||
)}
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} ff="monospace" truncate>
|
||||
{wagon.wagonNumber}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{wagon.wagonType
|
||||
? `${wagon.wagonType.code} · ${wagon.wagonType.tareWeightTons}T tare · ${wagon.wagonType.lengthMeters}m`
|
||||
: "Unknown type"}
|
||||
</Text>
|
||||
</Stack>
|
||||
{badge ? (
|
||||
<Badge size="xs" variant="light" color={badge === "Loaded" ? "orange" : "gray"}>
|
||||
{badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
{editable && wagon.switchable && switchOptions.length ? (
|
||||
<Select
|
||||
size="xs"
|
||||
w={148}
|
||||
placeholder="Switch with…"
|
||||
leftSection={<ArrowLeftRight size={12} />}
|
||||
data={switchOptions}
|
||||
value={switchValue}
|
||||
onChange={(toId) => onSwitch(wagon.id, toId)}
|
||||
clearable
|
||||
searchable
|
||||
disabled={checked}
|
||||
aria-label={`Switch wagon ${wagon.wagonNumber}`}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function AddableWagonRow({
|
||||
wagon,
|
||||
checked,
|
||||
disabled,
|
||||
@@ -471,7 +648,7 @@ function WagonRow({
|
||||
</Text>
|
||||
</Stack>
|
||||
{badge ? (
|
||||
<Badge size="xs" variant="light" color={badge === "Loaded" ? "orange" : "gray"}>
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
{badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
@@ -574,6 +574,11 @@ export function AllocateBookingWizard({
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
weightTons: b.weightTons,
|
||||
isGovernment: b.isGovernment,
|
||||
wagonsRequired: b.wagonsRequired,
|
||||
contractReference: b.contractReference,
|
||||
origin: b.origin,
|
||||
destination: b.destination,
|
||||
}))}
|
||||
eligibleItems={eligibleQuery.data?.items ?? []}
|
||||
eligibleLoading={eligibleQuery.isLoading}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useMemo } from "react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Collapse,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
Stack,
|
||||
@@ -10,7 +12,7 @@ import {
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Info } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Info, Train, Weight } from "lucide-react";
|
||||
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
|
||||
@@ -27,6 +29,13 @@ interface Stop {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface LegBookingUsage {
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
wagons: number;
|
||||
grossTons: number;
|
||||
}
|
||||
|
||||
interface EdgeUsage {
|
||||
edge: number;
|
||||
from: Stop;
|
||||
@@ -35,6 +44,7 @@ interface EdgeUsage {
|
||||
grossTons: number;
|
||||
lengthMeters: number;
|
||||
bookingRefs: string[];
|
||||
bookings: LegBookingUsage[];
|
||||
}
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
@@ -90,6 +100,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
const weightCap = schedule.maxGrossWeightTons ?? null;
|
||||
const lengthCap = schedule.maxLengthMeters ?? null;
|
||||
const wagonCap = schedule.maxWagons ?? null;
|
||||
const [expandedEdge, setExpandedEdge] = useState<number | null>(null);
|
||||
|
||||
const edges: EdgeUsage[] = useMemo(() => {
|
||||
if (stops.length < 2) return [];
|
||||
@@ -105,13 +116,36 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
const refs = new Set<string>();
|
||||
let grossTons = 0;
|
||||
let lengthMeters = 0;
|
||||
// Per booking on this leg: wagon count (distinct wagons carrying at
|
||||
// least one of its allocations — a shared wagon counts for each
|
||||
// booking riding it, so per-booking wagon counts can sum to more than
|
||||
// the leg's total) and its allocated weight share.
|
||||
const byBooking = new Map<string, LegBookingUsage>();
|
||||
for (const w of active) {
|
||||
grossTons += (Number(w.tareWeightTons) || 0) + (Number(w.assignedWeightTons) || 0);
|
||||
lengthMeters += Number(w.lengthMeters) || 0;
|
||||
const bookingIdsOnWagon = new Set<string>();
|
||||
for (const a of w.allocations ?? []) {
|
||||
if (a.bookingReference) refs.add(a.bookingReference);
|
||||
if (!a.bookingReference) continue;
|
||||
refs.add(a.bookingReference);
|
||||
const row = byBooking.get(a.bookingId) ?? {
|
||||
bookingId: a.bookingId,
|
||||
reference: a.bookingReference,
|
||||
wagons: 0,
|
||||
grossTons: 0,
|
||||
};
|
||||
row.grossTons += Number(a.allocatedWeightTons) || 0;
|
||||
byBooking.set(a.bookingId, row);
|
||||
bookingIdsOnWagon.add(a.bookingId);
|
||||
}
|
||||
for (const bookingId of bookingIdsOnWagon) {
|
||||
const row = byBooking.get(bookingId);
|
||||
if (row) row.wagons += 1;
|
||||
}
|
||||
}
|
||||
const bookings = [...byBooking.values()]
|
||||
.map((b) => ({ ...b, grossTons: round1(b.grossTons) }))
|
||||
.sort((a, b) => b.grossTons - a.grossTons);
|
||||
return {
|
||||
edge,
|
||||
from,
|
||||
@@ -120,6 +154,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
grossTons: round1(grossTons),
|
||||
lengthMeters: round1(lengthMeters),
|
||||
bookingRefs: [...refs],
|
||||
bookings,
|
||||
};
|
||||
});
|
||||
}, [stops, wagons]);
|
||||
@@ -190,6 +225,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
<Table verticalSpacing="sm" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ width: 28 }} />
|
||||
<Table.Th>Leg</Table.Th>
|
||||
<Table.Th>Wagons</Table.Th>
|
||||
<Table.Th>Gross weight</Table.Th>
|
||||
@@ -199,43 +235,107 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{edges.map((e) => (
|
||||
<Table.Tr key={e.edge}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||
{e.from.label} → {e.to.label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UsageCell used={e.wagons} cap={wagonCap} unit="wagons" />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UsageCell used={e.grossTons} cap={weightCap} unit="T" />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UsageCell used={e.lengthMeters} cap={lengthCap} unit="m" />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{e.bookingRefs.length ? (
|
||||
<Tooltip
|
||||
label={e.bookingRefs.join(", ")}
|
||||
multiline
|
||||
maw={320}
|
||||
withArrow
|
||||
>
|
||||
<Text size="sm" style={{ cursor: "help" }}>
|
||||
{e.bookingRefs.length}
|
||||
{edges.map((e) => {
|
||||
const isOpen = expandedEdge === e.edge;
|
||||
const hasBookings = e.bookings.length > 0;
|
||||
return (
|
||||
<Fragment key={e.edge}>
|
||||
<Table.Tr
|
||||
style={{ cursor: hasBookings ? "pointer" : undefined }}
|
||||
onClick={
|
||||
hasBookings
|
||||
? () => setExpandedEdge(isOpen ? null : e.edge)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Table.Td>
|
||||
{hasBookings ? (
|
||||
isOpen ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)
|
||||
) : null}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||
{e.from.label} → {e.to.label}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
0
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{legStatus(e)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UsageCell used={e.wagons} cap={wagonCap} unit="wagons" />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UsageCell used={e.grossTons} cap={weightCap} unit="T" />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UsageCell used={e.lengthMeters} cap={lengthCap} unit="m" />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{hasBookings ? (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{e.bookings.length}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
0
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{legStatus(e)}</Table.Td>
|
||||
</Table.Tr>
|
||||
{hasBookings ? (
|
||||
<Table.Tr key={`${e.edge}-detail`}>
|
||||
<Table.Td colSpan={7} p={0} style={{ border: 0 }}>
|
||||
<Collapse expanded={isOpen}>
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
borderTop: "1px solid var(--mantine-color-gray-2)",
|
||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={600} c="dimmed" mb={6} tt="uppercase">
|
||||
Bookings riding {e.from.label} → {e.to.label}
|
||||
</Text>
|
||||
<Table verticalSpacing={4} withRowBorders={false}>
|
||||
<Table.Tbody>
|
||||
{e.bookings.map((b) => (
|
||||
<Table.Tr key={b.bookingId}>
|
||||
<Table.Td w="40%">
|
||||
<Text size="sm" fw={500}>
|
||||
{b.reference}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td w="30%">
|
||||
<Group gap={4}>
|
||||
<Train size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{b.wagons} wagon{b.wagons === 1 ? "" : "s"}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td w="30%">
|
||||
<Group gap={4}>
|
||||
<Weight size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{b.grossTons}T
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Tabs,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { ArrowRight, Landmark, Package, Train } from "lucide-react";
|
||||
import { ArrowRight, FileText, Landmark, MapPin, Package, Train } from "lucide-react";
|
||||
|
||||
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
|
||||
|
||||
@@ -18,6 +18,10 @@ export type AssignedBookingRow = {
|
||||
reference: string;
|
||||
weightTons?: number;
|
||||
isGovernment?: boolean;
|
||||
wagonsRequired?: number | null;
|
||||
contractReference?: string | null;
|
||||
origin?: string | null;
|
||||
destination?: string | null;
|
||||
};
|
||||
|
||||
export function ScheduleBookingsStep({
|
||||
@@ -94,6 +98,16 @@ export function ScheduleBookingsStep({
|
||||
{booking.weightTons}T
|
||||
</Badge>
|
||||
) : null}
|
||||
{booking.wagonsRequired != null ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
size="xs"
|
||||
color="gray"
|
||||
leftSection={<Train size={10} />}
|
||||
>
|
||||
{booking.wagonsRequired} wagon{booking.wagonsRequired === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
) : null}
|
||||
{booking.isGovernment ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
@@ -105,6 +119,26 @@ export function ScheduleBookingsStep({
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
{booking.contractReference || booking.origin || booking.destination ? (
|
||||
<Group gap="xs">
|
||||
{booking.contractReference ? (
|
||||
<Group gap={4}>
|
||||
<FileText size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{booking.contractReference}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
{booking.origin || booking.destination ? (
|
||||
<Group gap={4}>
|
||||
<MapPin size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{booking.origin ?? "?"} → {booking.destination ?? "?"}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
<Group gap={6}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Assigned to this consist
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
Badge,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Timeline,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
History,
|
||||
MapPin,
|
||||
Minus,
|
||||
PackageMinus,
|
||||
Plus,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { ScheduleHistoryEntry } from "@/services/trainBuilder.service";
|
||||
|
||||
const ACTION_META: Record<
|
||||
ScheduleHistoryEntry["action"],
|
||||
{ label: string; color: string; icon: typeof Plus }
|
||||
> = {
|
||||
ADD: { label: "Wagon coupled", color: "edr-green", icon: Plus },
|
||||
REMOVE: { label: "Wagon trimmed", color: "red", icon: Minus },
|
||||
SWITCH: { label: "Wagon switched", color: "blue", icon: ArrowLeftRight },
|
||||
BOOKING_REMOVED: { label: "Booking removed", color: "orange", icon: PackageMinus },
|
||||
};
|
||||
|
||||
/**
|
||||
* "History" tab: every change made to the train after it was scheduled —
|
||||
* wagons coupled/trimmed/switched (with the stop where it happened) and
|
||||
* bookings removed from the composition — newest first.
|
||||
*/
|
||||
export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: string }) {
|
||||
const historyQuery = useQuery(
|
||||
api.trainScheduling.scheduleHistory.queryOptions({
|
||||
input: { scheduleId },
|
||||
enabled: Boolean(scheduleId),
|
||||
}),
|
||||
);
|
||||
const entries = historyQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<Paper radius="xl" p="lg">
|
||||
<Stack gap="lg">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={44} radius="md" variant="light" color="edr-green">
|
||||
<History size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={700} fz="lg">
|
||||
Change history
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Wagons coupled, trimmed or switched — and bookings removed — after
|
||||
this train was scheduled, newest first.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{historyQuery.isLoading ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
Loading history…
|
||||
</Text>
|
||||
) : entries.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
No changes recorded yet — the consist and composition are as
|
||||
scheduled.
|
||||
</Text>
|
||||
) : (
|
||||
<Timeline bulletSize={26} lineWidth={2} color="edr-green">
|
||||
{entries.map((entry) => {
|
||||
const meta = ACTION_META[entry.action] ?? ACTION_META.ADD;
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={`${entry.kind}-${entry.id}`}
|
||||
bullet={<Icon size={13} />}
|
||||
color={meta.color}
|
||||
title={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Badge size="sm" variant="light" color={meta.color}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{entry.subject ? (
|
||||
<Text size="sm" fw={600} ff="monospace">
|
||||
{entry.subject}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Group gap="md" mt={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(entry.occurredAt).toLocaleString()}
|
||||
</Text>
|
||||
{entry.yardLabel ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<MapPin size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
at {entry.yardLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
{entry.actor ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<User size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{entry.actor}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Group>
|
||||
{entry.note ? (
|
||||
<Text size="xs" c="dimmed" mt={2} fs="italic">
|
||||
{entry.note}
|
||||
</Text>
|
||||
) : null}
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -87,22 +87,46 @@ function phaseCountdown(
|
||||
}
|
||||
}
|
||||
|
||||
/** GROSS weight already on this train (each booking's cargo + wagon tare) —
|
||||
* compared against the locomotive pull limit, which is a gross ceiling. */
|
||||
/**
|
||||
* GROSS weight the locomotives actually haul: the HEAVIEST LEG, never the
|
||||
* whole-route sum — disjoint legs (Mojo→Dire + Dire→Doraleh) are pulled one
|
||||
* at a time, so summing every booking over-reports a multi-stop train.
|
||||
* Prefers the API's consist-derived heaviestLeg; before allocation it falls
|
||||
* back to a per-leg max over the bookings (same span math as the header strip).
|
||||
*/
|
||||
function usedWeight(schedule: TrainScheduleDetail): number {
|
||||
return (schedule.bookings ?? []).reduce(
|
||||
(sum, b) => sum + (Number(b.weightTons) || 0),
|
||||
0,
|
||||
);
|
||||
const consist = schedule.trainSet?.heaviestLeg?.grossWeightTons;
|
||||
if (consist != null) return Number(consist) || 0;
|
||||
|
||||
const bookings = schedule.bookings ?? [];
|
||||
const stops = schedule.stops ?? [];
|
||||
if (stops.length <= 2) {
|
||||
return bookings.reduce((sum, b) => sum + (Number(b.weightTons) || 0), 0);
|
||||
}
|
||||
const indexOf = new Map(stops.map((s, i) => [s.yardId, i]));
|
||||
const lastIdx = stops.length - 1;
|
||||
let heaviest = 0;
|
||||
for (let edge = 0; edge < lastIdx; edge += 1) {
|
||||
let legTons = 0;
|
||||
for (const b of bookings) {
|
||||
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
|
||||
const toRaw = b.destinationYardId ? indexOf.get(b.destinationYardId) : lastIdx;
|
||||
const to = toRaw != null && toRaw > from ? toRaw : lastIdx;
|
||||
if (from <= edge && edge < to) legTons += Number(b.weightTons) || 0;
|
||||
}
|
||||
heaviest = Math.max(heaviest, legTons);
|
||||
}
|
||||
return heaviest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull capacity of the set = the WEAKEST locomotive's max pull weight (0 when
|
||||
* unknown). The API caps at the weakest loco, not the sum of all locos — a
|
||||
* consist can only pull as hard as its weakest engine. Both sides of this meter
|
||||
* are gross: `usedWeight` sums per-booking gross (cargo + wagon tare).
|
||||
* Pull capacity of the set. Locomotive pull weights ADD UP (they haul
|
||||
* together), so prefer the API's maxGrossWeightTons — the combined set limit
|
||||
* incl. overage tolerance, the same ceiling the validator holds each leg to —
|
||||
* and fall back to summing the locos' own limits.
|
||||
*/
|
||||
function pullCapacity(schedule: TrainScheduleDetail): number {
|
||||
if (schedule.maxGrossWeightTons != null) return Number(schedule.maxGrossWeightTons) || 0;
|
||||
const set = schedule.trainSet;
|
||||
if (!set) return 0;
|
||||
const locos =
|
||||
@@ -111,8 +135,7 @@ function pullCapacity(schedule: TrainScheduleDetail): number {
|
||||
: set.locomotive
|
||||
? [set.locomotive]
|
||||
: [];
|
||||
if (locos.length === 0) return 0;
|
||||
return Math.min(...locos.map((l) => Number(l.maxPullWeightTons) || 0));
|
||||
return locos.reduce((sum, l) => sum + (Number(l.maxPullWeightTons) || 0), 0);
|
||||
}
|
||||
|
||||
export function ScheduleWorkspacePanel({
|
||||
@@ -371,6 +394,7 @@ export function ScheduleWorkspacePanel({
|
||||
<Text size="xs" fw={600} c={over ? "red" : "dimmed"}>
|
||||
Load {used.toFixed(1)}T
|
||||
{capacity > 0 ? ` / ${capacity.toFixed(0)}T pull` : ""}
|
||||
{(schedule.stops?.length ?? 0) > 2 ? " · heaviest leg" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
{over ? (
|
||||
|
||||
Reference in New Issue
Block a user