mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
feat(intercity): show intercity cargo across every train
Intercity bookings never get their own train — they ride whichever import/export train passes through their corridor — so the work is scattered across other people's schedules and there was nowhere to see it as a whole. The per-schedule ride-along panel answers "what can THIS train carry"; this answers "what is happening to intercity cargo". Purely additive: the existing ride-along panel and the schedule detail page are untouched, and loading/unloading still happens there, where the train's position is confirmed. This is a read-only view that points back to it. Each row carries both ends' facility status, because a booking whose origin or destination has no equipment can never be worked there — the operator should see that while the train is still coming, not when the load is refused. Those bookings are counted and called out. New GET /train-scheduling/intercity/bookings; the type is IntercityRideAlongRow, not IntercityBookingRow, which already means the per-schedule candidate row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, PackageCheck, TrainFront, Warehouse } from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import type { IntercityRideAlongRow } from "@/types/trainScheduling";
|
||||
|
||||
/**
|
||||
* Intercity cargo across every train.
|
||||
*
|
||||
* Intercity bookings never get their own train — they ride whichever
|
||||
* import/export train passes through their corridor — so the work is spread over
|
||||
* other people's schedules. This is the one place it's all visible.
|
||||
*/
|
||||
|
||||
const fmtTons = (t: number | null) => (t == null ? "—" : `${t} t`);
|
||||
|
||||
/** A booking can only be worked where the train actually is. */
|
||||
const atOrigin = (r: IntercityRideAlongRow) =>
|
||||
Boolean(r.trainAtYardId) && r.trainAtYardId === r.originYardId;
|
||||
const atDestination = (r: IntercityRideAlongRow) =>
|
||||
Boolean(r.trainAtYardId) && r.trainAtYardId === r.destinationYardId;
|
||||
|
||||
const isWaiting = (r: IntercityRideAlongRow) =>
|
||||
!r.loadedAt && r.status !== "IN_TRANSIT" && r.status !== "COMPLETED";
|
||||
const isRiding = (r: IntercityRideAlongRow) => r.status === "IN_TRANSIT";
|
||||
const isDone = (r: IntercityRideAlongRow) => r.status === "COMPLETED";
|
||||
|
||||
/** Yards with no equipment can never load/unload — surface it before the train arrives. */
|
||||
function FacilityCell({ yard, has }: { yard: string | null; has: boolean | null }) {
|
||||
if (!yard) return <Text size="sm">—</Text>;
|
||||
if (has) return <Text size="sm">{yard}</Text>;
|
||||
return (
|
||||
<Tooltip
|
||||
label="This yard has no load/unload facility — cargo cannot be handled here"
|
||||
withArrow
|
||||
multiline
|
||||
w={240}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<AlertTriangle size={13} color="var(--mantine-color-red-6)" />
|
||||
<Text size="sm" c="red">
|
||||
{yard}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Alert variant="light" color="gray">
|
||||
Nothing here.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table striped highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Load at</Table.Th>
|
||||
<Table.Th>Unload at</Table.Th>
|
||||
<Table.Th>Train</Table.Th>
|
||||
<Table.Th ta="right">Weight</Table.Th>
|
||||
<Table.Th>GRN</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r) => (
|
||||
<Table.Tr key={r.bookingId}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{r.reference ?? r.bookingId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{r.customer ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FacilityCell yard={r.origin} has={r.originHasFacility} />
|
||||
{atOrigin(r) && isWaiting(r) && (
|
||||
<Badge size="xs" color="edr-green" variant="light">
|
||||
train here
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FacilityCell yard={r.destination} has={r.destinationHasFacility} />
|
||||
{atDestination(r) && isRiding(r) && (
|
||||
<Badge size="xs" color="edr-green" variant="light">
|
||||
train here
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{r.trainNumber ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<TrainFront size={13} />
|
||||
<Text size="sm">{r.trainNumber}</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
not on a train
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Text size="sm">{fmtTons(r.weightTons)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c={r.grnNumber ? undefined : "dimmed"}>
|
||||
{r.grnNumber ?? "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{r.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
color?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{icon}
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={800} fz={22} lh={1.1} c={color}>
|
||||
{value}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IntercityPage() {
|
||||
const [tab, setTab] = useState("waiting");
|
||||
const { data: rows = [], isLoading } = useQuery(
|
||||
api.trainScheduling.intercityBookings.queryOptions({ input: undefined }),
|
||||
);
|
||||
|
||||
const waiting = useMemo(() => rows.filter(isWaiting), [rows]);
|
||||
const riding = useMemo(() => rows.filter(isRiding), [rows]);
|
||||
const done = useMemo(() => rows.filter(isDone), [rows]);
|
||||
// A booking whose end has no equipment is stuck until someone flags the yard.
|
||||
const blocked = useMemo(
|
||||
() =>
|
||||
rows.filter(
|
||||
(r) => !isDone(r) && (!r.originHasFacility || !r.destinationHasFacility),
|
||||
),
|
||||
[rows],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Intercity"
|
||||
subtitle="Domestic cargo riding passing trains — loaded at its origin facility, unloaded at its destination."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, xs: 2, md: 4 }} spacing="sm" mb="md">
|
||||
<Stat
|
||||
icon={<PackageCheck size={18} />}
|
||||
label="Waiting to load"
|
||||
value={waiting.length}
|
||||
/>
|
||||
<Stat icon={<TrainFront size={18} />} label="On a train" value={riding.length} />
|
||||
<Stat icon={<Warehouse size={18} />} label="Completed" value={done.length} />
|
||||
<Stat
|
||||
icon={<AlertTriangle size={18} />}
|
||||
label="No facility"
|
||||
value={blocked.length}
|
||||
color={blocked.length > 0 ? "red" : undefined}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{blocked.length > 0 && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="red"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title={`${blocked.length} booking${blocked.length === 1 ? "" : "s"} cannot be handled`}
|
||||
mb="md"
|
||||
>
|
||||
Their origin or destination yard has no load/unload facility. Mark the yard as
|
||||
a facility in Configuration → Yards, or the cargo can never be worked there.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Tabs value={tab} onChange={(v) => setTab(v ?? "waiting")}>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab
|
||||
value="waiting"
|
||||
rightSection={
|
||||
<Badge size="xs" variant="light">
|
||||
{waiting.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
Waiting to load
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="riding"
|
||||
rightSection={
|
||||
<Badge size="xs" variant="light">
|
||||
{riding.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
On a train
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="done"
|
||||
rightSection={
|
||||
<Badge size="xs" variant="light">
|
||||
{done.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
Completed
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="waiting">
|
||||
<Rows rows={waiting} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="riding">
|
||||
<Rows rows={riding} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="done">
|
||||
<Rows rows={done} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
<Text size="xs" c="dimmed" mt="sm">
|
||||
Loading and unloading happen on the train's schedule page, where the ride-along
|
||||
panel confirms the train is at the yard.
|
||||
</Text>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user