mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- 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>
845 lines
32 KiB
TypeScript
845 lines
32 KiB
TypeScript
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, Link2, Lock, MapPin, Plus, Search } from "lucide-react";
|
|
import { useMemo, useState } from "react";
|
|
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { api } from "@/services/api";
|
|
import type { ScheduleWagonYardRow } from "@/services/trainBuilder.service";
|
|
|
|
/**
|
|
* Schedule yards tab: where THIS departure plans to board each consist wagon,
|
|
* side by side with where the wagon physically stands (the train builder's
|
|
* truth). Booking capacity per origin reads the plan; dispatch refuses to
|
|
* leave until plan and physical yards agree. Edits are queued locally and
|
|
* saved in one PATCH.
|
|
*/
|
|
|
|
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;
|
|
};
|
|
|
|
interface Props {
|
|
scheduleId: string;
|
|
canEdit: boolean;
|
|
}
|
|
|
|
export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
|
const { toast } = useToast();
|
|
const query = useQuery(
|
|
api.trainScheduling.scheduleWagonYards.queryOptions({ input: { scheduleId } }),
|
|
);
|
|
const save = useMutation(api.trainScheduling.updateScheduleWagonYards.mutationOptions());
|
|
const data = query.data;
|
|
|
|
/** wagonId → yardId queued but not yet saved. */
|
|
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);
|
|
const [bulkCount, setBulkCount] = useState<number | string>(1);
|
|
|
|
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 ??
|
|
data?.wagons.find((w) => w.plannedYardId === id)?.plannedYardLabel ??
|
|
data?.wagons.find((w) => w.physicalYardId === id)?.physicalYardLabel ??
|
|
id ??
|
|
"—";
|
|
|
|
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
|
|
* boarding, excluding the destination (that's the cleared/default state). */
|
|
const cutOptionsFor = (boardYardId: string | null) => {
|
|
const stops = data?.stops ?? [];
|
|
const boardIdx = Math.max(0, stopIndexOf(boardYardId));
|
|
return stops
|
|
.slice(boardIdx + 1, stops.length - 1)
|
|
.map((s) => ({ value: s.yardId, label: s.label }));
|
|
};
|
|
/** Board-yard changes can invalidate a cut (server rejects cut ≤ board) — queue a clear. */
|
|
const clearInvalidCut = (
|
|
next: Record<string, string | null>,
|
|
w: ScheduleWagonYardRow,
|
|
boardYardId: string | null,
|
|
) => {
|
|
const cut = w.id in next ? next[w.id] : w.cutYardId;
|
|
if (cut != null && stopIndexOf(cut) <= stopIndexOf(boardYardId)) {
|
|
if (w.cutYardId == null) delete next[w.id];
|
|
else next[w.id] = null;
|
|
}
|
|
return next;
|
|
};
|
|
|
|
const perStop = useMemo(
|
|
() =>
|
|
(data?.stops ?? []).map((s) => ({
|
|
...s,
|
|
planned: (data?.wagons ?? []).filter((w) => (pending[w.id] ?? w.plannedYardId) === s.yardId)
|
|
.length,
|
|
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, pendingCouples, pendingUncouple],
|
|
);
|
|
const typeOptions = useMemo(() => {
|
|
const seen = new Map<string, string>();
|
|
for (const w of data?.wagons ?? []) seen.set(w.wagonType.id, w.wagonType.code);
|
|
return [...seen].map(([value, label]) => ({ value, label }));
|
|
}, [data]);
|
|
|
|
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;
|
|
const n = Number(bulkCount) || 0;
|
|
const picked = data.wagons
|
|
.filter(
|
|
(w) =>
|
|
!w.locked &&
|
|
effectiveYard(w) === bulkFrom &&
|
|
(!bulkType || w.wagonType.id === bulkType),
|
|
)
|
|
.slice(0, n);
|
|
if (!picked.length) {
|
|
toast({ title: "No free wagons match", variant: "destructive" });
|
|
return;
|
|
}
|
|
setPending((prev) => {
|
|
const next = { ...prev };
|
|
for (const w of picked) {
|
|
if (w.plannedYardId === bulkTo) delete next[w.id];
|
|
else next[w.id] = bulkTo;
|
|
}
|
|
return next;
|
|
});
|
|
setPendingCut((prev) => {
|
|
let next = { ...prev };
|
|
for (const w of picked) next = clearInvalidCut(next, w, bulkTo);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!pendingCount) return;
|
|
try {
|
|
const wagonIds = [
|
|
...new Set([
|
|
...Object.keys(pending),
|
|
...Object.keys(pendingCut),
|
|
...Object.keys(pendingRealCut),
|
|
]),
|
|
];
|
|
const result = await save.mutateAsync({
|
|
scheduleId,
|
|
payload: {
|
|
moves: wagonIds.map((wagonId) => ({
|
|
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,
|
|
variant: result.warnings.length ? "destructive" : undefined,
|
|
});
|
|
} catch (err) {
|
|
toast({
|
|
title: "Update failed",
|
|
description: parseError(err, "Could not update the schedule's wagon yards"),
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
if (query.isLoading) return <Loader size="sm" />;
|
|
if (query.isError || !data) {
|
|
return (
|
|
<Alert color="red" icon={<AlertTriangle size={16} />}>
|
|
{parseError(
|
|
query.error,
|
|
"This schedule has no wagon yard plan (not created from a built train).",
|
|
)}
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
<Alert color="blue" icon={<MapPin size={16} />} variant="light">
|
|
<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. 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}>
|
|
{" "}
|
|
{data.misaligned} wagon(s) currently misaligned.
|
|
</Text>
|
|
) : null}
|
|
</Alert>
|
|
|
|
<SimpleGrid cols={{ base: 2, sm: 3, md: Math.min(5, Math.max(2, perStop.length)) }}>
|
|
{perStop.map((s) => (
|
|
<Paper key={s.yardId} withBorder p="sm" radius="md">
|
|
<Group justify="space-between" mb={4}>
|
|
<Text fw={600} size="sm">
|
|
{s.label}
|
|
</Text>
|
|
{!s.pickup ? (
|
|
<Badge size="xs" color="gray" variant="light">
|
|
destination
|
|
</Badge>
|
|
) : null}
|
|
</Group>
|
|
<Group gap="xs">
|
|
<Badge color="edr-green" variant="filled">
|
|
Planned {s.planned}
|
|
</Badge>
|
|
<Badge color={s.physical === s.planned ? "gray" : "orange"} variant="light">
|
|
Physical {s.physical}
|
|
</Badge>
|
|
{s.cut > 0 ? (
|
|
<Badge color="red" variant="light">
|
|
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.filter(
|
|
(w) => !w.coupledYardId || !pendingUncouple.includes(w.id),
|
|
).length +
|
|
Object.keys(pendingCouples).length -
|
|
perStop.reduce((sum, p) => sum + p.cut, 0)}
|
|
</Badge>
|
|
) : null}
|
|
</Group>
|
|
</Paper>
|
|
))}
|
|
</SimpleGrid>
|
|
|
|
{editable ? (
|
|
<Paper withBorder p="sm" radius="md">
|
|
<Group align="end" gap="sm" wrap="wrap">
|
|
<NumberInput
|
|
label="Move"
|
|
min={1}
|
|
max={data.wagons.length}
|
|
value={bulkCount}
|
|
onChange={setBulkCount}
|
|
w={90}
|
|
/>
|
|
<Select
|
|
label="Wagon type"
|
|
placeholder="Any"
|
|
clearable
|
|
data={typeOptions}
|
|
value={bulkType}
|
|
onChange={setBulkType}
|
|
w={140}
|
|
/>
|
|
<Select label="From" data={yardOptions} value={bulkFrom} onChange={setBulkFrom} w={170} />
|
|
<Select label="To" data={yardOptions} value={bulkTo} onChange={setBulkTo} w={170} />
|
|
<Button
|
|
variant="light"
|
|
onClick={queueBulk}
|
|
disabled={!bulkFrom || !bulkTo || bulkFrom === bulkTo}
|
|
>
|
|
Queue
|
|
</Button>
|
|
</Group>
|
|
</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'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>
|
|
<Table.Th>#</Table.Th>
|
|
<Table.Th>Wagon</Table.Th>
|
|
<Table.Th>Type</Table.Th>
|
|
<Table.Th>Physical yard</Table.Th>
|
|
<Table.Th>Planned yard (this schedule)</Table.Th>
|
|
<Table.Th>Cut at (rides to)</Table.Th>
|
|
<Table.Th>Status</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{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.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>
|
|
|
|
{editable ? (
|
|
<Group justify="flex-end">
|
|
<Text size="sm" c="dimmed">
|
|
{pendingCount} pending change(s)
|
|
</Text>
|
|
<Button
|
|
variant="default"
|
|
onClick={() => {
|
|
setPending({});
|
|
setPendingCut({});
|
|
setPendingRealCut({});
|
|
setPendingCouples({});
|
|
setPendingUncouple([]);
|
|
}}
|
|
disabled={!pendingCount}
|
|
>
|
|
Discard
|
|
</Button>
|
|
<Button
|
|
onClick={() => void handleSave()}
|
|
loading={save.isPending}
|
|
disabled={!pendingCount}
|
|
>
|
|
Save plan
|
|
</Button>
|
|
</Group>
|
|
) : null}
|
|
</Stack>
|
|
);
|
|
}
|