feat(train-scheduling): mid-route consist changes, audit history, safer workspace

- planned couples: loose wagons join the train at a route stop, added
  from the schedule yards tab; capacity credits them per corridor edge
  and coupling validates locomotive weight/length caps per leg
- real-cut toggle: a cut wagon permanently leaves the train build at
  its cut yard (soft cut still sits out one trip only)
- fix heaviest-leg display counting a shared slot's full cargo on
  every spanned edge (phantom pull-weight overload on S-2026-00045)
- confirmation dialogs for workspace add/load/unload/remove actions
- train-builder History and Detached-wagons tabs, backed by paginated
  endpoints; builder detaches now always write adjustment-log rows

Migrations 3660 (planned_wagon_couples, planned_wagon_real_cuts) and
3670 (adjustment log train_schedule_id nullable) — both applied to the
dev DB by hand; watch mode does not run migrations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marshal
2026-08-23 03:55:54 +00:00
parent ba89b670c8
commit 8e6fc09aac
34 changed files with 2532 additions and 202 deletions

View File

@@ -0,0 +1,194 @@
import {
Badge,
Button,
Checkbox,
Group,
Pagination,
Paper,
Stack,
Table,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Link2, MapPin, PackageOpen, User } from "lucide-react";
import { useState } from "react";
import { api } from "@/services/api";
interface Props {
trainId: string;
/** Staff may attach and the train is editable (not out on a run). */
canAttach: boolean;
attachPending: boolean;
onAttach: (wagonIds: string[]) => void;
}
/**
* "Detached wagons" tab: wagons last detached from THIS train that are still
* loose — with when, where and by whom they were detached — so staff can pick
* them straight back onto the consist without hunting through the global pool.
*/
export default function DetachedWagonsPanel({
trainId,
canAttach,
attachPending,
onAttach,
}: Props) {
const [page, setPage] = useState(1);
const query = useQuery(
api.trainBuilder.detachedWagons.queryOptions({
input: { id: trainId, page, pageSize: 20 },
enabled: Boolean(trainId),
// Keep the previous page on screen while the next one loads.
placeholderData: (prev) => prev,
}),
);
const rows = query.data?.items ?? [];
const totalPages = Math.max(1, query.data?.meta.totalPages ?? 1);
// Selection is page-scoped in the header checkbox but survives paging, so
// staff can gather wagons across pages into one attach.
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.wagonId));
const toggle = (wagonId: string, checked: boolean) =>
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(wagonId);
else next.delete(wagonId);
return next;
});
return (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="orange">
<PackageOpen size={22} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={700} fz="lg">
Detached wagons
</Text>
<Text size="sm" c="dimmed">
Wagons that left this train and are still loose select and
attach them back in one click.
</Text>
</Stack>
</Group>
{canAttach ? (
<Button
leftSection={<Link2 size={16} />}
disabled={selected.size === 0}
loading={attachPending}
onClick={() => {
onAttach([...selected]);
setSelected(new Set());
}}
>
Attach {selected.size || ""} wagon{selected.size === 1 ? "" : "s"}
</Button>
) : null}
</Group>
{query.isLoading ? (
<Text size="sm" c="dimmed" ta="center" py="md">
Loading detached wagons
</Text>
) : rows.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
No loose wagons were detached from this train detach history starts
being recorded from now on.
</Text>
) : (
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
{canAttach ? (
<Table.Th w={36}>
<Checkbox
checked={allSelected}
indeterminate={selected.size > 0 && !allSelected}
onChange={(e) =>
setSelected(
e.currentTarget.checked
? new Set(rows.map((r) => r.wagonId))
: new Set(),
)
}
/>
</Table.Th>
) : null}
<Table.Th>Wagon</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Now standing at</Table.Th>
<Table.Th>Last detached</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r) => (
<Table.Tr key={r.wagonId}>
{canAttach ? (
<Table.Td>
<Checkbox
checked={selected.has(r.wagonId)}
onChange={(e) => toggle(r.wagonId, e.currentTarget.checked)}
/>
</Table.Td>
) : null}
<Table.Td>
<Text fw={600} size="sm" ff="monospace">
{r.wagonNumber}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="gray">
{r.wagonTypeCode ?? "—"}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{r.currentYardLabel ?? "No yard"}</Text>
</Table.Td>
<Table.Td>
<Group gap="md" wrap="wrap">
<Tooltip label={new Date(r.detachedAt).toLocaleString()}>
<Text size="sm">{new Date(r.detachedAt).toLocaleDateString()}</Text>
</Tooltip>
{r.detachedYardLabel ? (
<Group gap={4} wrap="nowrap">
<MapPin size={12} />
<Text size="xs" c="dimmed">
at {r.detachedYardLabel}
</Text>
</Group>
) : null}
{r.detachedBy ? (
<Group gap={4} wrap="nowrap">
<User size={12} />
<Text size="xs" c="dimmed">
by {r.detachedBy}
</Text>
</Group>
) : null}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
{totalPages > 1 ? (
<Group justify="space-between">
<Text size="xs" c="dimmed">
{query.data?.meta.total ?? 0} wagon(s) · selection carries across pages
</Text>
<Pagination size="sm" value={page} onChange={setPage} total={totalPages} />
</Group>
) : null}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,140 @@
import { Badge, Group, Pagination, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeftRight, History, MapPin, Minus, Plus, TrainFront, User } from "lucide-react";
import { useState } from "react";
import { api } from "@/services/api";
import type { TrainHistoryEntry } from "@/services/trainBuilder.service";
const PAGE_SIZE = 20;
const ACTION_META: Record<
TrainHistoryEntry["action"],
{ label: string; color: string; icon: typeof Plus }
> = {
ADD: { label: "Wagon attached", color: "edr-green", icon: Plus },
REMOVE: { label: "Wagon detached", color: "red", icon: Minus },
SWITCH: { label: "Wagon switched", color: "blue", icon: ArrowLeftRight },
};
/**
* "History" tab of the train-builder detail page: every wagon ever attached,
* detached or switched on this built train — builder edits and trip events
* (real cuts, mid-route couples, consist adjustments) alike, newest first.
*/
export default function TrainHistoryPanel({ trainId }: { trainId: string }) {
const [page, setPage] = useState(1);
const historyQuery = useQuery(
api.trainBuilder.history.queryOptions({
input: { id: trainId, page, pageSize: PAGE_SIZE },
enabled: Boolean(trainId),
// Keep the previous page on screen while the next one loads.
placeholderData: (prev) => prev,
}),
);
const entries = historyQuery.data?.items ?? [];
const totalPages = Math.max(1, historyQuery.data?.meta.totalPages ?? 1);
const total = historyQuery.data?.meta.total ?? 0;
return (
<Paper radius="xl" p="lg" withBorder>
<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">
Wagon history
</Text>
<Text size="sm" c="dimmed">
Who attached, detached or switched which wagon on this train from
the builder and from its trips 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 wagon changes recorded yet for this train.
</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.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}
{entry.scheduleReference ? (
<Badge
size="sm"
variant="light"
color="blue"
leftSection={<TrainFront size={10} />}
>
{entry.scheduleReference}
</Badge>
) : (
<Badge size="sm" variant="light" color="gray">
Builder
</Badge>
)}
</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>
</Timeline.Item>
);
})}
</Timeline>
)}
{totalPages > 1 ? (
<Group justify="space-between">
<Text size="xs" c="dimmed">
{total} change(s)
</Text>
<Pagination size="sm" value={page} onChange={setPage} total={totalPages} />
</Group>
) : null}
</Stack>
</Paper>
);
}

View File

@@ -2,20 +2,27 @@ import {
Alert,
Badge,
Button,
Checkbox,
Group,
Loader,
Modal,
NumberInput,
Pagination,
Paper,
ScrollArea,
Select,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Freight } from "@edr/types";
import { isAxiosError } from "axios";
import { AlertTriangle, Lock, MapPin } from "lucide-react";
import { AlertTriangle, Link2, Lock, MapPin, Plus, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { useToast } from "@/hooks/use-toast";
@@ -56,6 +63,21 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
const [pending, setPending] = useState<Record<string, string>>({});
/** wagonId → cut yard queued but not yet saved; null = queued clear (rides to destination). */
const [pendingCut, setPendingCut] = useState<Record<string, string | null>>({});
/** wagonId → real-cut flag queued but not yet saved. */
const [pendingRealCut, setPendingRealCut] = useState<Record<string, boolean>>({});
/** Loose wagons queued to couple: wagonId → couple stop + display data. */
const [pendingCouples, setPendingCouples] = useState<
Record<string, { yardId: string; wagonNumber: string; typeCode: string }>
>({});
/** Already-planned couples queued for removal. */
const [pendingUncouple, setPendingUncouple] = useState<string[]>([]);
// "Add wagon" modal + its filters.
const [coupleModalOpen, setCoupleModalOpen] = useState(false);
const [coupleYardFilter, setCoupleYardFilter] = useState<string | null>(null);
const [coupleType, setCoupleType] = useState<string | null>(null);
const [coupleSearch, setCoupleSearch] = useState("");
const [couplePage, setCouplePage] = useState(1);
const [debouncedCoupleSearch] = useDebouncedValue(coupleSearch, 300);
const [bulkType, setBulkType] = useState<string | null>(null);
const [bulkFrom, setBulkFrom] = useState<string | null>(null);
const [bulkTo, setBulkTo] = useState<string | null>(null);
@@ -63,6 +85,39 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
const editable = Boolean(canEdit && data?.editable);
const pickupStops = useMemo(() => (data?.stops ?? []).filter((s) => s.pickup), [data]);
/** Mid-route stops only — wagons are coupled between the origin and the destination. */
const intermediateStops = useMemo(() => {
const stops = data?.stops ?? [];
return stops.slice(1, -1).filter((s) => s.pickup);
}, [data]);
// Loose-wagon list for the "Add wagon" modal. A wagon can only be coupled
// where it physically stands, and only at a pickup stop of this route — the
// Add button carries that yard; off-route wagons render disabled.
const coupleListQuery = useQuery(
api.wagons.listPaged.queryOptions({
input: {
filters: {
status: Freight.WagonStatus.Available,
unassigned: true,
currentYardId: coupleYardFilter ?? undefined,
wagonTypeId: coupleType ?? undefined,
search: debouncedCoupleSearch || undefined,
page: couplePage,
pageSize: 8,
},
},
enabled: editable && coupleModalOpen,
placeholderData: (prev) => prev,
}),
);
const coupleCandidates = coupleListQuery.data?.items ?? [];
const coupleTotalPages = Math.max(1, coupleListQuery.data?.meta.totalPages ?? 1);
const yardsQuery = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000, enabled: coupleModalOpen }),
);
const wagonTypesQuery = useQuery(
api.wagonTypes.list.queryOptions({ staleTime: 5 * 60_000, enabled: coupleModalOpen }),
);
const yardOptions = pickupStops.map((s) => ({ value: s.yardId, label: s.label }));
const yardLabel = (id: string | null) =>
(data?.stops ?? []).find((s) => s.yardId === id)?.label ??
@@ -74,6 +129,8 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
const effectiveYard = (w: ScheduleWagonYardRow) => pending[w.id] ?? w.plannedYardId;
const effectiveCut = (w: ScheduleWagonYardRow) =>
w.id in pendingCut ? pendingCut[w.id] : w.cutYardId;
const effectiveRealCut = (w: ScheduleWagonYardRow) =>
(pendingRealCut[w.id] ?? w.realCut) && effectiveCut(w) != null;
const stopIndexOf = (yardId: string | null) =>
yardId == null ? -1 : (data?.stops ?? []).findIndex((s) => s.yardId === yardId);
/** Drop stops a wagon boarding at `boardYardId` can be cut at — strictly after
@@ -108,8 +165,13 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
cut: (data?.wagons ?? []).filter(
(w) => (w.id in pendingCut ? pendingCut[w.id] : w.cutYardId) === s.yardId,
).length,
coupled:
(data?.wagons ?? []).filter(
(w) => w.coupledYardId === s.yardId && !pendingUncouple.includes(w.id),
).length +
Object.values(pendingCouples).filter((c) => c.yardId === s.yardId).length,
})),
[data, pending, pendingCut],
[data, pending, pendingCut, pendingCouples, pendingUncouple],
);
const typeOptions = useMemo(() => {
const seen = new Map<string, string>();
@@ -117,7 +179,14 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
return [...seen].map(([value, label]) => ({ value, label }));
}, [data]);
const pendingCount = new Set([...Object.keys(pending), ...Object.keys(pendingCut)]).size;
const pendingCount =
new Set([
...Object.keys(pending),
...Object.keys(pendingCut),
...Object.keys(pendingRealCut),
]).size +
Object.keys(pendingCouples).length +
pendingUncouple.length;
const queueBulk = () => {
if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return;
@@ -152,7 +221,13 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
const handleSave = async () => {
if (!pendingCount) return;
try {
const wagonIds = [...new Set([...Object.keys(pending), ...Object.keys(pendingCut)])];
const wagonIds = [
...new Set([
...Object.keys(pending),
...Object.keys(pendingCut),
...Object.keys(pendingRealCut),
]),
];
const result = await save.mutateAsync({
scheduleId,
payload: {
@@ -160,11 +235,24 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
wagonId,
...(wagonId in pending ? { yardId: pending[wagonId] } : {}),
...(wagonId in pendingCut ? { cutYardId: pendingCut[wagonId] } : {}),
...(wagonId in pendingRealCut ? { realCut: pendingRealCut[wagonId] } : {}),
})),
...(Object.keys(pendingCouples).length
? {
couple: Object.entries(pendingCouples).map(([wagonId, c]) => ({
wagonId,
yardId: c.yardId,
})),
}
: {}),
...(pendingUncouple.length ? { uncouple: pendingUncouple } : {}),
},
});
setPending({});
setPendingCut({});
setPendingRealCut({});
setPendingCouples({});
setPendingUncouple([]);
toast({
title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`,
description: result.warnings.length ? result.warnings.join(" ") : undefined,
@@ -197,8 +285,11 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
<b>Planned</b> = where this departure boards the wagon (what customers can book per
origin). <b>Physical</b> = where the wagon stands now (train builder). <b>Cut at</b> ={" "}
where this departure detaches the wagon and leaves it blank means it rides to the
destination; booking capacity past the cut shrinks accordingly. Dispatch is blocked until
every wagon stands at its planned yard.
destination; booking capacity past the cut shrinks accordingly. Tick <b>Real cut</b> to
remove the wagon from the train build permanently at that yard (untick = it sits out this
trip only). <b>Coupled</b> wagons are loose wagons joining the train at a stop they
become part of the build for good. Dispatch is blocked until every wagon stands at its
planned yard.
{data.misaligned > 0 ? (
<Text component="span" c="orange" fw={600}>
{" "}
@@ -232,9 +323,19 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
Cut {s.cut}
</Badge>
) : null}
{s.coupled > 0 ? (
<Badge color="blue" variant="light">
+{s.coupled} coupled
</Badge>
) : null}
{!s.pickup ? (
<Badge color="blue" variant="light">
Through {data.wagons.length - perStop.reduce((sum, p) => sum + p.cut, 0)}
Through{" "}
{data.wagons.filter(
(w) => !w.coupledYardId || !pendingUncouple.includes(w.id),
).length +
Object.keys(pendingCouples).length -
perStop.reduce((sum, p) => sum + p.cut, 0)}
</Badge>
) : null}
</Group>
@@ -275,6 +376,214 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
</Paper>
) : null}
{editable ? (
<Group justify="space-between">
<Group gap={6}>
<Link2 size={16} />
<Text fw={600} size="sm">
Consist plan for this trip
</Text>
</Group>
<Button
leftSection={<Plus size={16} />}
variant="light"
onClick={() => setCoupleModalOpen(true)}
>
Add wagon
</Button>
</Group>
) : null}
<Modal
opened={coupleModalOpen}
onClose={() => setCoupleModalOpen(false)}
size="xl"
radius="md"
title={
<Group gap={8}>
<Link2 size={18} />
<Text fw={700}>Add wagons to this trip</Text>
</Group>
}
>
<Stack gap="sm">
<Alert color="blue" variant="light" p="xs">
A wagon is coupled where it physically stands, so it must be waiting at one of this
route&apos;s stops between the origin and the destination. Wagons elsewhere are listed
but cannot be added until they are moved.
</Alert>
<Group align="end" gap="sm" wrap="wrap">
<Select
label="Yard"
placeholder="All yards"
clearable
searchable
data={(yardsQuery.data ?? [])
.filter(
(y) =>
y.id !== data.stops[0]?.yardId &&
y.id !== data.stops[data.stops.length - 1]?.yardId,
)
.slice()
.sort((a, b) => a.label.localeCompare(b.label))
.map((y) => ({
value: y.id,
label: intermediateStops.some((s) => s.yardId === y.id)
? `${y.label} · route stop`
: y.label,
}))}
value={coupleYardFilter}
onChange={(v) => {
setCoupleYardFilter(v);
setCouplePage(1);
}}
w={220}
/>
<Select
label="Wagon type"
placeholder="Any type"
clearable
data={(wagonTypesQuery.data ?? []).map((t) => ({
value: t.id,
label: t.code ? `${t.name} (${t.code})` : t.name,
}))}
value={coupleType}
onChange={(v) => {
setCoupleType(v);
setCouplePage(1);
}}
w={200}
/>
<TextInput
label="Search"
placeholder="Wagon number…"
leftSection={<Search size={14} />}
value={coupleSearch}
onChange={(e) => {
setCoupleSearch(e.currentTarget.value);
setCouplePage(1);
}}
w={200}
/>
</Group>
{coupleListQuery.isLoading ? (
<Group justify="center" p="md">
<Loader size="sm" />
</Group>
) : (
<ScrollArea.Autosize mah={380}>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Wagon</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Standing at</Table.Th>
<Table.Th ta="right">Couple</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{coupleCandidates.map((w) => {
const onTrip = data.wagons.some((row) => row.id === w.id);
const queued = w.id in pendingCouples;
const stop = intermediateStops.find((s) => s.yardId === w.currentYardId);
return (
<Table.Tr key={w.id}>
<Table.Td>
<Text fw={600} size="sm">
{w.wagonNumber}
</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="gray">
{w.wagonType?.code ?? w.wagonTypeId}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{w.currentYard?.label ?? "No yard"}</Text>
</Table.Td>
<Table.Td ta="right">
{onTrip ? (
<Badge size="sm" variant="light" color="gray">
On this trip
</Badge>
) : queued ? (
<Button
size="compact-xs"
variant="subtle"
color="red"
onClick={() =>
setPendingCouples((prev) => {
const next = { ...prev };
delete next[w.id];
return next;
})
}
>
Queued remove
</Button>
) : stop ? (
<Button
size="compact-xs"
variant="light"
leftSection={<Plus size={12} />}
onClick={() =>
setPendingCouples((prev) => ({
...prev,
[w.id]: {
yardId: stop.yardId,
wagonNumber: w.wagonNumber,
typeCode: w.wagonType?.code ?? w.wagonTypeId,
},
}))
}
>
Couple at {stop.label}
</Button>
) : (
<Tooltip label="Not standing at a mid-route stop of this schedule (origin and destination excluded)">
<Button size="compact-xs" variant="default" disabled>
Off route
</Button>
</Tooltip>
)}
</Table.Td>
</Table.Tr>
);
})}
{coupleCandidates.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={4}>
<Text size="sm" c="dimmed" ta="center" py="sm">
No loose wagons match the filters.
</Text>
</Table.Td>
</Table.Tr>
) : null}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
)}
<Group justify="space-between">
{coupleTotalPages > 1 ? (
<Pagination
size="sm"
value={couplePage}
onChange={setCouplePage}
total={coupleTotalPages}
/>
) : (
<span />
)}
<Group gap="sm">
<Text size="sm" c="dimmed">
{Object.keys(pendingCouples).length} wagon(s) queued save the plan to apply
</Text>
<Button onClick={() => setCoupleModalOpen(false)}>Done</Button>
</Group>
</Group>
</Stack>
</Modal>
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
@@ -288,88 +597,218 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.wagons.map((w) => {
const planned = effectiveYard(w);
const cut = effectiveCut(w);
const changed = w.id in pending || w.id in pendingCut;
return (
<Table.Tr key={w.id} bg={changed ? "var(--mantine-color-yellow-light)" : undefined}>
<Table.Td>{w.sequenceNumber ?? "—"}</Table.Td>
<Table.Td>
<Text fw={600} size="sm">
{w.wagonNumber}
</Text>
</Table.Td>
<Table.Td>{w.wagonType.code}</Table.Td>
<Table.Td>{w.physicalYardLabel ?? "No yard"}</Table.Td>
<Table.Td>
{editable && !w.locked ? (
<Select
size="xs"
data={yardOptions}
value={planned}
onChange={(v) => {
setPending((prev) => {
const next = { ...prev };
if (!v || v === w.plannedYardId) delete next[w.id];
else next[w.id] = v;
return next;
});
setPendingCut((prev) =>
clearInvalidCut({ ...prev }, w, v ?? w.plannedYardId),
);
}}
w={180}
/>
) : (
<Group gap={4}>
<Text size="sm">{yardLabel(planned)}</Text>
{w.locked ? (
<Tooltip label={w.lockReason ?? "Locked"}>
<Lock size={14} />
{data.wagons
.filter((w) => !w.coupledYardId)
.map((w) => {
const planned = effectiveYard(w);
const cut = effectiveCut(w);
const changed = w.id in pending || w.id in pendingCut || w.id in pendingRealCut;
return (
<Table.Tr key={w.id} bg={changed ? "var(--mantine-color-yellow-light)" : undefined}>
<Table.Td>{w.sequenceNumber ?? "—"}</Table.Td>
<Table.Td>
<Text fw={600} size="sm">
{w.wagonNumber}
</Text>
</Table.Td>
<Table.Td>{w.wagonType.code}</Table.Td>
<Table.Td>{w.physicalYardLabel ?? "No yard"}</Table.Td>
<Table.Td>
{editable && !w.locked ? (
<Select
size="xs"
data={yardOptions}
value={planned}
onChange={(v) => {
setPending((prev) => {
const next = { ...prev };
if (!v || v === w.plannedYardId) delete next[w.id];
else next[w.id] = v;
return next;
});
setPendingCut((prev) =>
clearInvalidCut({ ...prev }, w, v ?? w.plannedYardId),
);
}}
w={180}
/>
) : (
<Group gap={4}>
<Text size="sm">{yardLabel(planned)}</Text>
{w.locked ? (
<Tooltip label={w.lockReason ?? "Locked"}>
<Lock size={14} />
</Tooltip>
) : null}
</Group>
)}
</Table.Td>
<Table.Td>
{editable ? (
// Locked wagons stay editable here — the server enforces the
// cargo-destination floor and the toast explains a 409.
<Stack gap={4}>
<Select
size="xs"
clearable
placeholder="Destination"
data={cutOptionsFor(planned)}
value={cut}
onChange={(v) => {
setPendingCut((prev) => {
const next = { ...prev };
if ((v ?? null) === w.cutYardId) delete next[w.id];
else next[w.id] = v ?? null;
return next;
});
if (!v) {
// No cut → no real-cut flag to keep.
setPendingRealCut((prev) => {
const next = { ...prev };
if (w.realCut) next[w.id] = false;
else delete next[w.id];
return next;
});
}
}}
w={180}
/>
{cut ? (
<Checkbox
size="xs"
label="Real cut (train loses wagon)"
checked={effectiveRealCut(w)}
onChange={(e) => {
const v = e.currentTarget.checked;
setPendingRealCut((prev) => {
const next = { ...prev };
if (v === w.realCut) delete next[w.id];
else next[w.id] = v;
return next;
});
}}
/>
) : null}
</Stack>
) : (
<Text size="sm">
{cut
? `${yardLabel(cut)}${effectiveRealCut(w) ? " (real cut)" : ""}`
: "Destination"}
</Text>
)}
</Table.Td>
<Table.Td>
{planned === w.physicalYardId ? (
<Badge color="teal" variant="light" size="sm">
Aligned
</Badge>
) : (
<Badge color="orange" variant="light" size="sm">
Needs move
</Badge>
)}
</Table.Td>
</Table.Tr>
);
})}
{data.wagons
.filter((w) => w.coupledYardId)
.map((w) => {
const queuedOff = pendingUncouple.includes(w.id);
return (
<Table.Tr
key={w.id}
bg={queuedOff ? "var(--mantine-color-yellow-light)" : undefined}
opacity={queuedOff ? 0.5 : undefined}
>
<Table.Td></Table.Td>
<Table.Td>
<Text fw={600} size="sm">
{w.wagonNumber}
</Text>
</Table.Td>
<Table.Td>{w.wagonType.code}</Table.Td>
<Table.Td>{w.physicalYardLabel ?? "No yard"}</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="blue" leftSection={<Link2 size={12} />}>
Coupled at {w.coupledYardLabel ?? w.coupledYardId}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">Destination</Text>
</Table.Td>
<Table.Td>
<Group gap={6}>
{w.aligned ? (
<Badge color="teal" variant="light" size="sm">
At couple yard
</Badge>
) : (
<Badge color="orange" variant="light" size="sm">
Not at couple yard
</Badge>
)}
{editable ? (
<Tooltip
label={w.locked ? w.lockReason ?? "Locked" : "Remove from couple plan"}
>
<Button
size="compact-xs"
variant="subtle"
color="red"
disabled={w.locked}
onClick={() =>
setPendingUncouple((prev) =>
queuedOff ? prev.filter((id) => id !== w.id) : [...prev, w.id],
)
}
>
{queuedOff ? "Keep" : "Uncouple"}
</Button>
</Tooltip>
) : null}
</Group>
)}
</Table.Td>
<Table.Td>
{editable ? (
// Locked wagons stay editable here — the server enforces the
// cargo-destination floor and the toast explains a 409.
<Select
size="xs"
clearable
placeholder="Destination"
data={cutOptionsFor(planned)}
value={cut}
onChange={(v) =>
setPendingCut((prev) => {
const next = { ...prev };
if ((v ?? null) === w.cutYardId) delete next[w.id];
else next[w.id] = v ?? null;
return next;
})
}
w={180}
/>
) : (
<Text size="sm">{cut ? yardLabel(cut) : "Destination"}</Text>
)}
</Table.Td>
<Table.Td>
{planned === w.physicalYardId ? (
<Badge color="teal" variant="light" size="sm">
Aligned
</Badge>
) : (
<Badge color="orange" variant="light" size="sm">
Needs move
</Badge>
)}
</Table.Td>
</Table.Tr>
);
})}
</Table.Td>
</Table.Tr>
);
})}
{Object.entries(pendingCouples).map(([wagonId, c]) => (
<Table.Tr key={wagonId} bg="var(--mantine-color-yellow-light)">
<Table.Td></Table.Td>
<Table.Td>
<Text fw={600} size="sm">
{c.wagonNumber}
</Text>
</Table.Td>
<Table.Td>{c.typeCode}</Table.Td>
<Table.Td>{yardLabel(c.yardId)}</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="blue" leftSection={<Link2 size={12} />}>
Coupled at {yardLabel(c.yardId)} (pending)
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">Destination</Text>
</Table.Td>
<Table.Td>
<Button
size="compact-xs"
variant="subtle"
color="red"
onClick={() =>
setPendingCouples((prev) => {
const next = { ...prev };
delete next[wagonId];
return next;
})
}
>
Remove
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
@@ -383,6 +822,9 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
onClick={() => {
setPending({});
setPendingCut({});
setPendingRealCut({});
setPendingCouples({});
setPendingUncouple([]);
}}
disabled={!pendingCount}
>

View File

@@ -347,6 +347,64 @@ export function ScheduleWorkspacePanel({
const pct = capacity > 0 ? Math.min(100, Math.round((used / capacity) * 100)) : 0;
const over = capacity > 0 && used > capacity;
// One confirmation dialog for every booking action; the action fires only
// after staff confirm, and the existing toasts report the outcome.
const [confirmAction, setConfirmAction] = useState<{
kind: "add" | "load" | "truckToTrain" | "unload" | "remove";
bookingId: string;
ref: string;
weightTons?: number;
} | null>(null);
const confirmMeta: Record<
NonNullable<typeof confirmAction>["kind"],
{ title: string; message: string; color: string; confirmLabel: string }
> = {
add: {
title: "Add booking to this train?",
message:
"The booking is assigned to this departure and wagons are auto-pinned. Adding past the pull-weight limit is allowed but flagged for review.",
color: "edr-green",
confirmLabel: "Add to train",
},
load: {
title: "Load cargo onto the train?",
message:
"Stamps the booking as loaded at this yard. The server checks the train is actually standing here.",
color: "edr-green",
confirmLabel: "Load",
},
truckToTrain: {
title: "Load as direct truck-to-train?",
message:
"Sets direct truck-to-train handover (no warehouse receipt, no GRN — the carriage acceptance sheet becomes the handover document) and loads the cargo.",
color: "blue",
confirmLabel: "Load direct",
},
unload: {
title: "Unload cargo at this yard?",
message: "Stamps the booking's arrival at this yard and frees its wagons for reuse.",
color: "orange",
confirmLabel: "Unload",
},
remove: {
title: "Remove booking from this train?",
message:
"Returns the booking to the unassigned pool, writes a removal log entry, and notifies the customer.",
color: "red",
confirmLabel: "Remove",
},
};
const runConfirmedAction = () => {
if (!confirmAction) return;
const { kind, bookingId, ref, weightTons } = confirmAction;
setConfirmAction(null);
if (kind === "add") forceAdd(bookingId, ref, weightTons ?? 0);
else if (kind === "load") doLoad(bookingId, ref);
else if (kind === "truckToTrain") doTruckToTrain(bookingId, ref);
else if (kind === "unload") doUnload(bookingId, ref);
else removeFromTrain(bookingId, ref);
};
const forceAdd = (bookingId: string, ref: string, weightTons: number) => {
const wouldOverfill = capacity > 0 && used + (weightTons || 0) > capacity;
assign
@@ -649,7 +707,14 @@ export function ScheduleWorkspacePanel({
radius="md"
rightSection={<ArrowRight size={14} />}
loading={assign.isPending}
onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
onClick={() =>
setConfirmAction({
kind: "add",
bookingId: b.id,
ref: b.reference,
weightTons: b.weightTons,
})
}
>
Add
</Button>
@@ -794,7 +859,9 @@ export function ScheduleWorkspacePanel({
loadJourney.isPending &&
loadJourney.variables?.bookingId === b.id
}
onClick={() => doLoad(b.id, ref)}
onClick={() =>
setConfirmAction({ kind: "load", bookingId: b.id, ref })
}
>
Load
</Button>
@@ -812,7 +879,13 @@ export function ScheduleWorkspacePanel({
radius="md"
leftSection={<Truck size={13} />}
loading={truckToTrainPending === b.id}
onClick={() => doTruckToTrain(b.id, ref)}
onClick={() =>
setConfirmAction({
kind: "truckToTrain",
bookingId: b.id,
ref,
})
}
>
Truck to Train
</Button>
@@ -838,7 +911,9 @@ export function ScheduleWorkspacePanel({
unloadJourney.isPending &&
unloadJourney.variables?.bookingId === b.id
}
onClick={() => doUnload(b.id, ref)}
onClick={() =>
setConfirmAction({ kind: "unload", bookingId: b.id, ref })
}
>
Unload
</Button>
@@ -857,7 +932,9 @@ export function ScheduleWorkspacePanel({
unassign.isPending &&
unassign.variables?.bookingId === b.id
}
onClick={() => removeFromTrain(b.id, ref)}
onClick={() =>
setConfirmAction({ kind: "remove", bookingId: b.id, ref })
}
>
Remove
</Button>
@@ -961,6 +1038,82 @@ export function ScheduleWorkspacePanel({
</Group>
</Stack>
</Modal>
{/* Confirm add / load / unload / remove */}
<Modal
opened={Boolean(confirmAction)}
onClose={() => setConfirmAction(null)}
centered
radius="lg"
size="md"
withCloseButton={false}
title={
confirmAction ? (
<Group gap={10} wrap="nowrap">
<ThemeIcon
size={40}
radius="md"
variant="light"
color={confirmMeta[confirmAction.kind].color}
>
{confirmAction.kind === "remove" ? (
<X size={21} />
) : confirmAction.kind === "unload" ? (
<PackageOpen size={21} />
) : confirmAction.kind === "truckToTrain" ? (
<Truck size={21} />
) : (
<PackageCheck size={21} />
)}
</ThemeIcon>
<div>
<Text fw={800}>{confirmMeta[confirmAction.kind].title}</Text>
<Text size="xs" c="dimmed">
{confirmAction.ref}
</Text>
</div>
</Group>
) : null
}
>
{confirmAction ? (
<Stack gap="md">
<Text size="sm">{confirmMeta[confirmAction.kind].message}</Text>
{confirmAction.kind === "add" &&
capacity > 0 &&
used + (confirmAction.weightTons ?? 0) > capacity ? (
<Group
gap={8}
p="xs"
wrap="nowrap"
style={{
borderRadius: 10,
background: "var(--mantine-color-red-0)",
border: "1px solid var(--mantine-color-red-2)",
}}
>
<AlertTriangle size={16} color="#B42318" />
<Text size="xs" c="red.8" fw={500}>
This add pushes the heaviest leg past the locomotive pull weight (
{(used + (confirmAction.weightTons ?? 0)).toFixed(1)}T / {capacity.toFixed(0)}T).
</Text>
</Group>
) : null}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={() => setConfirmAction(null)}>
Cancel
</Button>
<Button
color={confirmMeta[confirmAction.kind].color}
radius="md"
onClick={runConfirmedAction}
>
{confirmMeta[confirmAction.kind].confirmLabel}
</Button>
</Group>
</Stack>
) : null}
</Modal>
</Paper>
);
}