mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user