mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Merge pull request #1399 from Tria-plc/freight_feature/usermanagement
feat(train-scheduling): mid-route consist changes, audit history, saf…
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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}
|
||||
>
|
||||
|
||||
@@ -352,6 +352,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
|
||||
@@ -654,7 +712,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>
|
||||
@@ -801,7 +866,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>
|
||||
@@ -824,7 +891,13 @@ export function ScheduleWorkspacePanel({
|
||||
disabled={!canLoad}
|
||||
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>
|
||||
@@ -852,7 +925,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>
|
||||
@@ -871,7 +946,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>
|
||||
@@ -975,6 +1052,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Modal,
|
||||
Progress,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
@@ -17,8 +18,10 @@ import { isAxiosError } from "axios";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CalendarClock,
|
||||
History,
|
||||
MapPin,
|
||||
MoreHorizontal,
|
||||
PackageOpen,
|
||||
Power,
|
||||
PowerOff,
|
||||
Replace,
|
||||
@@ -36,6 +39,8 @@ import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel
|
||||
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
|
||||
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
|
||||
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
|
||||
import DetachedWagonsPanel from "@/components/trainBuilder/DetachedWagonsPanel";
|
||||
import TrainHistoryPanel from "@/components/trainBuilder/TrainHistoryPanel";
|
||||
import {
|
||||
directionColor,
|
||||
locomotiveStatusColor,
|
||||
@@ -383,7 +388,22 @@ export default function TrainBuilderDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{composition.wagonYards.length > 1 ? (
|
||||
<Tabs defaultValue="build" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="build" leftSection={<TrainIcon size={14} />}>
|
||||
Build
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="detached" leftSection={<PackageOpen size={14} />}>
|
||||
Detached wagons
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={14} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="build" pt="md">
|
||||
<Stack gap="lg">
|
||||
{composition.wagonYards.length > 1 ? (
|
||||
<Alert color="blue" icon={<MapPin size={16} />}>
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={600}>
|
||||
@@ -575,6 +595,22 @@ export default function TrainBuilderDetailPage() {
|
||||
</Stack>
|
||||
</Card>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="detached" pt="md">
|
||||
<DetachedWagonsPanel
|
||||
trainId={composition.id}
|
||||
canAttach={composition.editable && canAssign}
|
||||
attachPending={assignWagons.isPending}
|
||||
onAttach={handleAssign}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history" pt="md">
|
||||
<TrainHistoryPanel trainId={composition.id} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<ChangeLocomotivesModal
|
||||
composition={composition}
|
||||
|
||||
@@ -231,6 +231,8 @@ import {
|
||||
type BuildTrainPayload,
|
||||
type BuiltTrainListFilters,
|
||||
type BuiltTrainListResponse,
|
||||
type DetachedWagonRow,
|
||||
type TrainHistoryEntry,
|
||||
type ScheduleConsist,
|
||||
type ScheduleWagonYards,
|
||||
type UpdateScheduleWagonYardsPayload,
|
||||
@@ -2103,6 +2105,22 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.TRAIN_BUILDER.composition(id),
|
||||
),
|
||||
|
||||
// Keys derive to ["train-builder", "history"|"detachedWagons", input] — the
|
||||
// shared TRAIN_BUILDER.ROOT invalidation refreshes both after every edit.
|
||||
history: endpoint<
|
||||
{ id: string; page: number; pageSize: number },
|
||||
PaginatedResponse<TrainHistoryEntry>
|
||||
>("train-builder", "history", ({ id, page, pageSize }) =>
|
||||
trainBuilderService.getHistory(id, page, pageSize).then((r) => r.data),
|
||||
),
|
||||
|
||||
detachedWagons: endpoint<
|
||||
{ id: string; page: number; pageSize: number },
|
||||
PaginatedResponse<DetachedWagonRow>
|
||||
>("train-builder", "detachedWagons", ({ id, page, pageSize }) =>
|
||||
trainBuilderService.getDetachedWagons(id, page, pageSize).then((r) => r.data),
|
||||
),
|
||||
|
||||
// Key derives to ["train-builder", "usedTrainNumbers"], so the shared
|
||||
// TRAIN_BUILDER.ROOT invalidation refreshes it after every build/edit.
|
||||
usedTrainNumbers: endpoint<void, UsedTrainNumbers>(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PaginatedResponse } from "@edr/types";
|
||||
|
||||
import { api as apiClient } from "../auth/http";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -300,6 +302,29 @@ export interface ScheduleHistoryEntry {
|
||||
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
|
||||
export type AdjustConsistResult = ScheduleConsist & { warnings: string[] };
|
||||
|
||||
/** One wagon adjustment on a built train (History tab): builder edits and trip events alike. */
|
||||
export interface TrainHistoryEntry {
|
||||
id: string;
|
||||
action: "ADD" | "REMOVE" | "SWITCH";
|
||||
subject: string | null;
|
||||
yardLabel: string | null;
|
||||
actor: string | null;
|
||||
/** Set when the change came from a trip (schedule); null = train-builder edit. */
|
||||
scheduleReference: string | null;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
/** Wagon last detached from this train and still loose — the re-attach shortlist. */
|
||||
export interface DetachedWagonRow {
|
||||
wagonId: string;
|
||||
wagonNumber: string;
|
||||
wagonTypeCode: string | null;
|
||||
currentYardLabel: string | null;
|
||||
detachedAt: string;
|
||||
detachedYardLabel: string | null;
|
||||
detachedBy: string | null;
|
||||
}
|
||||
|
||||
/** One consist wagon in the schedule-yards tab: where this departure plans it vs where it stands. */
|
||||
export interface ScheduleWagonYardRow {
|
||||
id: string;
|
||||
@@ -313,6 +338,11 @@ export interface ScheduleWagonYardRow {
|
||||
/** Drop stop this departure cuts the wagon at; null = rides to the destination. */
|
||||
cutYardId: string | null;
|
||||
cutYardLabel: string | null;
|
||||
/** true = REAL cut: the built train permanently loses the wagon at the cut yard. */
|
||||
realCut: boolean;
|
||||
/** Set on planned-couple rows: the pickup stop this loose wagon joins the train at. */
|
||||
coupledYardId: string | null;
|
||||
coupledYardLabel: string | null;
|
||||
aligned: boolean;
|
||||
locked: boolean;
|
||||
lockReason: string | null;
|
||||
@@ -327,6 +357,8 @@ export interface ScheduleWagonYardStop {
|
||||
physical: number;
|
||||
/** Wagons this departure cuts (detaches and leaves) at this stop. */
|
||||
cut: number;
|
||||
/** Loose wagons this departure couples onto the train at this stop. */
|
||||
coupled: number;
|
||||
}
|
||||
|
||||
export interface ScheduleWagonYards {
|
||||
@@ -340,7 +372,16 @@ export interface ScheduleWagonYards {
|
||||
|
||||
export interface UpdateScheduleWagonYardsPayload {
|
||||
/** Omit a field to leave it unchanged; cutYardId null clears the cut (rides to destination). */
|
||||
moves: Array<{ wagonId: string; yardId?: string; cutYardId?: string | null }>;
|
||||
moves?: Array<{
|
||||
wagonId: string;
|
||||
yardId?: string;
|
||||
cutYardId?: string | null;
|
||||
realCut?: boolean;
|
||||
}>;
|
||||
/** Loose wagons to plan-couple at a pickup stop (they must stand at that yard). */
|
||||
couple?: Array<{ wagonId: string; yardId: string }>;
|
||||
/** Wagon ids to drop from the couple plan. */
|
||||
uncouple?: string[];
|
||||
}
|
||||
|
||||
export type UpdateScheduleWagonYardsResult = ScheduleWagonYards & { warnings: string[] };
|
||||
@@ -349,6 +390,14 @@ export const trainBuilderService = {
|
||||
list: (filters: BuiltTrainListFilters = {}) =>
|
||||
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
|
||||
getComposition: (id: string) => apiClient.get<TrainComposition>(`${BASE}/${id}`),
|
||||
getHistory: (id: string, page: number, pageSize: number) =>
|
||||
apiClient.get<PaginatedResponse<TrainHistoryEntry>>(
|
||||
`${BASE}/${id}/history?page=${page}&pageSize=${pageSize}`,
|
||||
),
|
||||
getDetachedWagons: (id: string, page: number, pageSize: number) =>
|
||||
apiClient.get<PaginatedResponse<DetachedWagonRow>>(
|
||||
`${BASE}/${id}/detached-wagons?page=${page}&pageSize=${pageSize}`,
|
||||
),
|
||||
/** Import/export run numbers already claimed by existing trains. */
|
||||
usedTrainNumbers: () => apiClient.get<UsedTrainNumbers>(`${BASE}/used-train-numbers`),
|
||||
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
|
||||
|
||||
Reference in New Issue
Block a user