Merge pull request #1040 from Tria-plc/freight_feature/usermanagement

intercity fix
This commit is contained in:
marshal
2026-07-31 13:50:50 +03:00
committed by GitHub
17 changed files with 1174 additions and 182 deletions

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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 ? (

View File

@@ -26,6 +26,7 @@ import {
Container as ContainerIcon,
Eye,
FileText,
History as HistoryIcon,
LayoutGrid,
Navigation,
Package,
@@ -51,6 +52,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
@@ -646,6 +648,10 @@ export default function TrainScheduleV2DetailPage() {
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}
@@ -1166,6 +1172,9 @@ export default function TrainScheduleV2DetailPage() {
<Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}>
Leg capacity
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
History
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="workflow">
@@ -1251,6 +1260,10 @@ export default function TrainScheduleV2DetailPage() {
<Tabs.Panel value="legs">
<LegCapacityPanel schedule={schedule} />
</Tabs.Panel>
<Tabs.Panel value="history">
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
</Tabs.Panel>
</Tabs>
{scheduleId ? (

View File

@@ -194,6 +194,7 @@ import {
type BuiltTrainListFilters,
type BuiltTrainListResponse,
type ScheduleConsist,
type ScheduleHistoryEntry,
type TrainComposition,
type UpdateTrainDetailsPayload,
type UsedTrainNumbers,
@@ -362,6 +363,18 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS,
),
scheduleHistory: endpoint<{ scheduleId: string }, ScheduleHistoryEntry[]>(
"train-scheduling",
"schedule-history",
({ scheduleId }) =>
trainBuilderService.scheduleHistory(scheduleId).then((r) => r.data),
({ scheduleId }) => [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"history",
scheduleId,
],
),
bookableSchedules: endpoint<
{ originYardId?: string | null; destinationYardId?: string | null },
BookableSchedule[]

View File

@@ -231,17 +231,28 @@ export interface ScheduleConsist {
grossTons: number;
consistLengthMeters: number;
};
wagons: Array<ConsistWagonRef & { loaded: boolean; removable: boolean }>;
wagons: Array<
ConsistWagonRef & {
loaded: boolean;
removable: boolean;
/** Loaded wagons can't leave, but their SLOT can change wagon. */
switchable: boolean;
blockReason: string | null;
}
>;
addableWagons: ConsistWagonRef[];
adjustments: Array<{
id: string;
action: "ADD" | "REMOVE";
action: "ADD" | "REMOVE" | "SWITCH";
wagonId: string;
wagonNumber: string;
adjustedByUserId: string | null;
yardId: string | null;
occurredAt: string;
}>;
editable: boolean;
/** Where the train stands — mid-route this is the checkpointed stop. */
currentStop: { yardId: string; label: string; isMidRoute: boolean } | null;
/**
* Wagon-slot picture of the schedule: the consist IS the booking capacity
* (weight/length only bind while building the consist), so the dialog can
@@ -259,6 +270,20 @@ export interface ScheduleConsist {
export interface AdjustConsistPayload {
addWagonIds?: string[];
removeWagonIds?: string[];
/** Replacement takes the outgoing wagon's position and slot, cargo included. */
switches?: Array<{ fromWagonId: string; toWagonId: string }>;
}
/** One row of the schedule's unified change history (History tab). */
export interface ScheduleHistoryEntry {
id: string;
kind: "WAGON" | "BOOKING";
action: "ADD" | "REMOVE" | "SWITCH" | "BOOKING_REMOVED";
subject: string | null;
yardLabel: string | null;
actor: string | null;
note: string | null;
occurredAt: string;
}
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
@@ -302,10 +327,15 @@ export const trainBuilderService = {
/** Consist snapshot for a train-bound schedule (adjust-consist UI). */
scheduleConsist: (scheduleId: string) =>
apiClient.get<ScheduleConsist>(`/train-scheduling/schedules/${scheduleId}/consist`),
/** Permanently trim/add wagons on the schedule's built train. */
/** Permanently trim/add/switch wagons on the schedule's built train. */
adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) =>
apiClient.post<AdjustConsistResult>(
`/train-scheduling/schedules/${scheduleId}/adjust-consist`,
payload,
),
/** Unified wagon/booking change history for the schedule's History tab. */
scheduleHistory: (scheduleId: string) =>
apiClient.get<ScheduleHistoryEntry[]>(
`/train-scheduling/schedules/${scheduleId}/history`,
),
};

View File

@@ -685,6 +685,7 @@ export interface TrainScheduleDetail {
destinationYardId?: string | null;
origin?: string | null;
destination?: string | null;
contractReference?: string | null;
wagonsRequired?: number | null;
loadedAt?: string | null;
arrivedAt?: string | null;