Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx
2026-07-31 10:50:16 +00:00

658 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
Alert,
Badge,
Button,
Checkbox,
Divider,
Grid,
Group,
Modal,
Progress,
ScrollArea,
Select,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { AlertTriangle, ArrowLeftRight, History, MapPin, Minus, Plus } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/services/api";
import type { ConsistWagonRef, ScheduleConsist } from "@/services/trainBuilder.service";
import { useToast } from "@/hooks/use-toast";
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
return fallback;
};
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),
* 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,
opened,
onClose,
}: AdjustConsistModalProps) {
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({
input: { scheduleId },
enabled: opened && Boolean(scheduleId),
}),
);
const adjust = useMutation(api.trainScheduling.adjustConsist.mutationOptions());
const data = consistQuery.data;
useEffect(() => {
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. Switches are same-type and cancel out.
const projection = useMemo(() => {
if (!data) return null;
const removed = new Set(removeIds);
const keptTare = data.wagons
.filter((w) => !removed.has(w.id))
.reduce((s, w) => s + tareOf(w), 0);
const keptLength = data.wagons
.filter((w) => !removed.has(w.id))
.reduce((s, w) => s + lengthOf(w), 0);
const addedWagons = data.addableWagons.filter((w) => addIds.includes(w.id));
const tare = keptTare + addedWagons.reduce((s, w) => s + tareOf(w), 0);
const length = keptLength + addedWagons.reduce((s, w) => s + lengthOf(w), 0);
const gross = round2(data.totals.cargoTons + tare);
const wagonCount = data.totals.wagonCount - removeIds.length + addIds.length;
const cap = data.scheduleCapacity;
const freeSlots = cap ? wagonCount - cap.allocatedWagons : null;
return {
wagonCount,
tare: round2(tare),
gross,
length: round2(length),
grossPct: data.limits.pullCapTons
? Math.round((gross / data.limits.pullCapTons) * 100)
: null,
lengthPct: data.limits.lengthCapMeters
? Math.round((length / data.limits.lengthCapMeters) * 100)
: null,
overWeight: data.limits.pullCapTons > 0 && gross > data.limits.pullCapTons,
overLength:
data.limits.lengthCapMeters > 0 && length > data.limits.lengthCapMeters,
slots:
cap && freeSlots != null
? {
allocated: cap.allocatedWagons,
free: freeSlots,
pct:
wagonCount > 0
? Math.round((cap.allocatedWagons / wagonCount) * 100)
: null,
isFullNow: cap.bookingWindowStatus === "FULL",
willBeFull: freeSlots <= 0,
overAllocated: freeSlots < 0,
willReopen: cap.bookingWindowStatus === "FULL" && freeSlots > 0,
}
: null,
};
}, [data, removeIds, addIds]);
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 (!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` : "",
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.
for (const warning of result.warnings ?? []) {
toast({
title: "Schedule capacity",
description: warning,
duration: 8000,
...(warning.includes("over capacity")
? { variant: "destructive" as const }
: {}),
});
}
setRemoveIds([]);
setAddIds([]);
setSwitchMap({});
} catch (err) {
toast({
title: "Adjustment failed",
description: parseError(err, "Could not adjust the consist"),
variant: "destructive",
});
}
};
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Text fw={600}>
Adjust consist{data ? ` — train ${data.train.code}` : ""}
</Text>
}
radius="lg"
size={920}
centered
>
{consistQuery.isLoading || !data ? (
<Text py="lg" ta="center" c="dimmed" size="sm">
{consistQuery.isError
? "This schedule has no built train to adjust."
: "Loading consist…"}
</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} />}>
{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}
<Grid gap="md">
<Grid.Col span={{ base: 12, sm: 6 }}>
<LimitGauge
label="Gross weight"
detail={`${data.totals.cargoTons}T cargo + ${projection?.tare}T tare = ${projection?.gross}T of ${data.limits.pullCapTons}T (limit ${data.limits.maxPullWeightTons}T + ${data.limits.overageToleranceTons}T tolerance)`}
pct={projection?.grossPct ?? null}
over={projection?.overWeight ?? false}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<LimitGauge
label="Consist length"
detail={`${projection?.length}m of ${data.limits.lengthCapMeters}m (limit ${data.limits.maxTrainLengthMeters}m + ${data.limits.overageToleranceMeters}m tolerance)`}
pct={projection?.lengthPct ?? null}
over={projection?.overLength ?? false}
/>
</Grid.Col>
{projection?.slots ? (
<Grid.Col span={12}>
<LimitGauge
label="Booking slots — the consist is the schedule's capacity"
detail={`${projection.slots.allocated} of ${projection.wagonCount} projected wagon slot(s) held by bookings${
projection.slots.free > 0
? `${projection.slots.free} free`
: projection.slots.free === 0
? " — none free (FULL)"
: ""
}`}
pct={projection.slots.pct}
over={projection.slots.overAllocated}
/>
</Grid.Col>
) : null}
</Grid>
{projection?.slots?.isFullNow && !hasChanges ? (
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
This schedule is FULL all {projection.wagonCount} wagon slots are
taken. You can still edit the train: coupling wagons adds capacity
and reopens booking; trimming free wagons keeps it FULL.
</Alert>
) : null}
{hasChanges && projection?.slots?.overAllocated ? (
<Alert color="red" icon={<AlertTriangle size={16} />}>
This change leaves {-projection.slots.free} booked wagon(s) without
a slot bookings already hold {projection.slots.allocated} of the{" "}
{projection.wagonCount} remaining. You can apply it, but couple
wagons back or free bookings before departure.
</Alert>
) : null}
{hasChanges &&
projection?.slots &&
!projection.slots.overAllocated &&
projection.slots.willBeFull &&
!projection.slots.isFullNow ? (
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
This change takes the last free wagon slot the schedule becomes
FULL and stops accepting bookings.
</Alert>
) : null}
{hasChanges && projection?.slots?.willReopen ? (
<Alert color="blue" icon={<AlertTriangle size={16} />}>
This schedule is currently FULL applying frees{" "}
{projection.slots.free} wagon slot(s) and reopens its booking
window.
</Alert>
) : null}
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 7 }}>
<Stack gap="xs">
<Group gap={6}>
<Minus size={14} />
<Text size="sm" fw={600}>
Coupled wagons ({data.totals.wagonCount})
</Text>
</Group>
<Text size="xs" c="dimmed">
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={280} type="auto">
<Stack gap={4}>
{data.wagons.map((wagon) => (
<CoupledWagonRow
key={wagon.id}
wagon={wagon}
checked={removeIds.includes(wagon.id)}
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: 5 }}>
<Stack gap="xs">
<Group gap={6}>
<Plus size={14} />
<Text size="sm" fw={600}>
Couple yard wagons ({data.addableWagons.length} available)
</Text>
</Group>
<Text size="xs" c="dimmed">
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={280} type="auto">
<Stack gap={4}>
{data.addableWagons.length ? (
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 at this stop
</Text>
)}
</Stack>
</ScrollArea.Autosize>
</Stack>
</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 />
<Stack gap={4}>
<Group gap={6}>
<History size={14} />
<Text size="sm" fw={600}>
Adjustment history
</Text>
</Group>
<ScrollArea.Autosize mah={120} type="auto">
<Stack gap={2}>
{data.adjustments.map((log) => (
<Group key={log.id} gap="xs">
<Badge
size="xs"
variant="light"
color={
log.action === "ADD"
? "edr-green"
: log.action === "SWITCH"
? "blue"
: "red"
}
>
{log.action === "ADD"
? "Added"
: log.action === "SWITCH"
? "Switched"
: "Trimmed"}
</Badge>
<Text size="xs" ff="monospace">
{log.wagonNumber}
</Text>
<Text size="xs" c="dimmed">
{new Date(log.occurredAt).toLocaleString()}
</Text>
</Group>
))}
</Stack>
</ScrollArea.Autosize>
</Stack>
</>
) : null}
<Group justify="space-between">
<Text size="xs" c="dimmed">
Projected consist: {projection?.wagonCount} wagons
</Text>
<Group>
<Button variant="default" onClick={onClose}>
Close
</Button>
<Button
loading={adjust.isPending}
disabled={
!data.editable ||
!hasChanges ||
(addIds.length > 0 && (projection?.overWeight || projection?.overLength))
}
onClick={handleSubmit}
>
Apply{" "}
{[
removeIds.length ? `${removeIds.length}` : "",
addIds.length ? `+${addIds.length}` : "",
switchCount ? `⇄${switchCount}` : "",
]
.filter(Boolean)
.join(" / ")}
</Button>
</Group>
</Group>
</Stack>
)}
</Modal>
);
}
export interface AdjustConsistModalProps {
scheduleId: string;
opened: boolean;
onClose: () => void;
}
function LimitGauge({
label,
detail,
pct,
over,
}: {
label: string;
detail: string;
pct: number | null;
over: boolean;
}) {
return (
<Stack gap={4}>
<Group justify="space-between">
<Text size="xs" fw={600}>
{label}
</Text>
<Text size="xs" fw={700} c={over ? "red.7" : "edr-green.7"}>
{pct != null ? `${pct}%` : ""}
</Text>
</Group>
<Progress
value={Math.min(pct ?? 0, 100)}
size="md"
radius="xl"
color={over ? "red" : (pct ?? 0) > 85 ? "yellow" : "edr-green"}
striped={over}
animated={over}
/>
<Text size="xs" c="dimmed">
{detail}
</Text>
</Stack>
);
}
/** 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,
badge,
onToggle,
}: {
wagon: ConsistWagonRef;
checked: boolean;
disabled: boolean;
badge: string | null;
onToggle: (id: string, checked: boolean) => void;
}) {
return (
<Group
gap="sm"
wrap="nowrap"
p={6}
style={{
border: "1px solid var(--mantine-color-gray-2)",
borderRadius: "var(--mantine-radius-md)",
opacity: disabled && !badge ? 0.7 : 1,
}}
>
<Checkbox
size="sm"
checked={checked}
disabled={disabled}
onChange={(e) => onToggle(wagon.id, e.currentTarget.checked)}
aria-label={`Select wagon ${wagon.wagonNumber}`}
/>
<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="blue">
{badge}
</Badge>
) : null}
</Group>
);
}