mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
404 lines
14 KiB
TypeScript
404 lines
14 KiB
TypeScript
import {
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Divider,
|
|
Group,
|
|
Loader,
|
|
Modal,
|
|
Stack,
|
|
Table,
|
|
Text,
|
|
ThemeIcon,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import {
|
|
CheckCircle2,
|
|
Flag,
|
|
MapPin,
|
|
PackageCheck,
|
|
TrainFront,
|
|
} from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
import { Freight } from "@edr/types";
|
|
|
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { api } from "@/services/api";
|
|
import type { TrackStation, YardWorkBookingRow } from "@/types/trainScheduling";
|
|
|
|
const parseError = (error: unknown, fallback: string) => {
|
|
const message = (error as { response?: { data?: { message?: string | string[] } } })
|
|
?.response?.data?.message;
|
|
if (Array.isArray(message)) return message.join("; ");
|
|
return message || (error as Error)?.message || fallback;
|
|
};
|
|
|
|
const fmtDate = (iso: string) => {
|
|
const d = new Date(iso);
|
|
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
|
};
|
|
|
|
const DIRECTION_COLORS: Record<string, string> = {
|
|
IMPORT: "blue",
|
|
EXPORT: "teal",
|
|
DOMESTIC: "violet",
|
|
};
|
|
|
|
/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
|
|
const DIRECTION_LABELS: Record<string, string> = Freight.TRADE_DIRECTION_LABELS;
|
|
|
|
function DirectionChip({ direction }: { direction: string }) {
|
|
return (
|
|
<Badge size="sm" variant="light" color={DIRECTION_COLORS[direction] ?? "gray"}>
|
|
{DIRECTION_LABELS[direction] ?? direction}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
function SectionLabel({
|
|
icon,
|
|
title,
|
|
count,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
title: string;
|
|
count: number;
|
|
}) {
|
|
return (
|
|
<Group gap={8} align="center">
|
|
<ThemeIcon size={26} radius="md" variant="light" color="edr-green">
|
|
{icon}
|
|
</ThemeIcon>
|
|
<Text fw={700} size="sm">
|
|
{title}
|
|
</Text>
|
|
<Badge size="sm" variant="light" color="gray" radius="sm">
|
|
{count}
|
|
</Badge>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Yard-work modal for the track page's "Log pass" step.
|
|
*
|
|
* A train runs A→B→C→D and bookings board/alight at any stop, so logging the
|
|
* pass at a yard is the moment its yard work happens: bookings destined here
|
|
* flip to ARRIVED (import/export) or COMPLETED (intercity) automatically the
|
|
* instant the pass is logged, and bookings boarding here become loadable —
|
|
* the server only accepts a load while the train's latest checkpoint is this
|
|
* yard. The modal therefore drives the sequence: log the pass first, then
|
|
* load anything that boards here (including cargo the operator forgot — it
|
|
* stays loadable until the next pass is logged).
|
|
*/
|
|
export function LogPassYardWorkModal({
|
|
opened,
|
|
onClose,
|
|
scheduleId,
|
|
station,
|
|
isFinal,
|
|
alreadyLogged,
|
|
}: {
|
|
opened: boolean;
|
|
onClose: () => void;
|
|
scheduleId: string;
|
|
station: TrackStation | null;
|
|
isFinal: boolean;
|
|
/** True when opened for the current station (pass already logged). */
|
|
alreadyLogged: boolean;
|
|
}) {
|
|
const { toast } = useToast();
|
|
const [justLogged, setJustLogged] = useState(false);
|
|
useEffect(() => setJustLogged(false), [station?.sequenceNo, opened]);
|
|
const logged = alreadyLogged || justLogged;
|
|
|
|
const yardWorkQuery = useQuery(
|
|
api.trainScheduling.yardWork.queryOptions({
|
|
input: { scheduleId },
|
|
enabled: opened && Boolean(scheduleId),
|
|
}),
|
|
);
|
|
const recordCheckpoint = useMutation(
|
|
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
|
);
|
|
const load = useMutation(api.trainScheduling.loadScheduleBooking.mutationOptions());
|
|
|
|
const yard = yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId);
|
|
const boarders: YardWorkBookingRow[] = yard?.toLoad ?? [];
|
|
const arrivals: YardWorkBookingRow[] = yard?.toUnload ?? [];
|
|
const pendingBoarders = boarders.filter((r) => !r.loadedAt);
|
|
|
|
const doLogPass = () => {
|
|
if (!station) return;
|
|
recordCheckpoint.mutate(
|
|
{ id: scheduleId, payload: { sequenceNo: station.sequenceNo } },
|
|
{
|
|
onSuccess: () => {
|
|
setJustLogged(true);
|
|
toast({
|
|
title: isFinal
|
|
? "Train arrived — remaining bookings marked arrived, assets freed"
|
|
: `Pass logged at ${station.label}`,
|
|
description: isFinal
|
|
? undefined
|
|
: arrivals.some((r) => r.canUnload)
|
|
? "Bookings arriving here have been marked arrived."
|
|
: undefined,
|
|
});
|
|
void yardWorkQuery.refetch();
|
|
},
|
|
onError: (err) =>
|
|
toast({
|
|
title: "Could not log checkpoint",
|
|
description: parseError(err, "Please try again"),
|
|
variant: "destructive",
|
|
}),
|
|
},
|
|
);
|
|
};
|
|
|
|
const doLoad = (row: YardWorkBookingRow) => {
|
|
load.mutate(
|
|
{ scheduleId, bookingId: row.id },
|
|
{
|
|
onSuccess: () => {
|
|
toast({
|
|
title: `${row.reference ?? "Booking"} loaded`,
|
|
description: `Cargo boarded the train at ${station?.label ?? "this yard"}.`,
|
|
});
|
|
void yardWorkQuery.refetch();
|
|
},
|
|
onError: (err) =>
|
|
toast({
|
|
title: "Could not load booking",
|
|
description: parseError(err, "Please try again"),
|
|
variant: "destructive",
|
|
}),
|
|
},
|
|
);
|
|
};
|
|
|
|
const hasWork = boarders.length > 0 || arrivals.length > 0;
|
|
|
|
return (
|
|
<Modal
|
|
opened={opened}
|
|
onClose={onClose}
|
|
size="xl"
|
|
radius="lg"
|
|
title={
|
|
<Group gap={8}>
|
|
{isFinal ? <Flag size={18} /> : <MapPin size={18} />}
|
|
<Text fw={700}>
|
|
{isFinal ? "Arrival" : "Yard work"} — {station?.label ?? ""}
|
|
</Text>
|
|
{logged ? (
|
|
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
|
{isFinal ? "Arrived" : "Pass logged"}
|
|
</Badge>
|
|
) : null}
|
|
</Group>
|
|
}
|
|
>
|
|
<Stack gap="md">
|
|
{yardWorkQuery.isLoading ? (
|
|
<Group justify="center" py="lg">
|
|
<Loader size="sm" color="edr-green" />
|
|
</Group>
|
|
) : !hasWork ? (
|
|
<Alert color="gray" variant="light" radius="md" icon={<MapPin size={16} />}>
|
|
No bookings board or alight at this station.
|
|
</Alert>
|
|
) : (
|
|
<>
|
|
{/* ── Arriving here ─────────────────────────────────────────── */}
|
|
{arrivals.length > 0 ? (
|
|
<Stack gap="xs">
|
|
<SectionLabel
|
|
icon={<Flag size={14} />}
|
|
title="Arriving at this yard"
|
|
count={arrivals.length}
|
|
/>
|
|
{!logged ? (
|
|
<Text size="xs" c="dimmed">
|
|
Logging the pass marks the loaded bookings below as Arrived
|
|
(import/export) or Completed (intercity) automatically.
|
|
</Text>
|
|
) : null}
|
|
<Table.ScrollContainer minWidth={620}>
|
|
<Table verticalSpacing="xs" highlightOnHover>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Booking</Table.Th>
|
|
<Table.Th>Customer</Table.Th>
|
|
<Table.Th>Direction</Table.Th>
|
|
<Table.Th>Status</Table.Th>
|
|
<Table.Th>Arrived</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{arrivals.map((row) => (
|
|
<Table.Tr key={row.id}>
|
|
<Table.Td>
|
|
<Text size="sm" fw={600}>
|
|
{row.reference ?? row.id.slice(0, 8)}
|
|
</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm">{row.customer}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<DirectionChip direction={row.tradeDirection} />
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<BookingStatusBadge status={row.status} />
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="xs" c="dimmed">
|
|
{row.arrivedAt ? fmtDate(row.arrivedAt) : "—"}
|
|
</Text>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Table.ScrollContainer>
|
|
</Stack>
|
|
) : null}
|
|
|
|
{arrivals.length > 0 && boarders.length > 0 ? <Divider /> : null}
|
|
|
|
{/* ── Boarding here ─────────────────────────────────────────── */}
|
|
{boarders.length > 0 ? (
|
|
<Stack gap="xs">
|
|
<SectionLabel
|
|
icon={<TrainFront size={14} />}
|
|
title="Boarding at this yard"
|
|
count={boarders.length}
|
|
/>
|
|
{!logged && pendingBoarders.length > 0 ? (
|
|
<Text size="xs" c="dimmed">
|
|
Log the pass first — the train must be at {station?.label} before
|
|
cargo can be loaded.
|
|
</Text>
|
|
) : null}
|
|
<Table.ScrollContainer minWidth={620}>
|
|
<Table verticalSpacing="xs" highlightOnHover>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Booking</Table.Th>
|
|
<Table.Th>Customer</Table.Th>
|
|
<Table.Th>Direction</Table.Th>
|
|
<Table.Th>Status</Table.Th>
|
|
<Table.Th>Loaded</Table.Th>
|
|
<Table.Th />
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{boarders.map((row) => (
|
|
<Table.Tr key={row.id}>
|
|
<Table.Td>
|
|
<Group gap={6} wrap="nowrap">
|
|
<Text size="sm" fw={600}>
|
|
{row.reference ?? row.id.slice(0, 8)}
|
|
</Text>
|
|
{row.isGovernment ? (
|
|
<Badge size="xs" variant="light" color="grape">
|
|
GOV
|
|
</Badge>
|
|
) : null}
|
|
</Group>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="sm">{row.customer}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<DirectionChip direction={row.tradeDirection} />
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<BookingStatusBadge status={row.status} />
|
|
</Table.Td>
|
|
<Table.Td>
|
|
{row.loadedAt ? (
|
|
<Group gap={4} wrap="nowrap">
|
|
<CheckCircle2
|
|
size={13}
|
|
color="var(--mantine-color-edr-green-7)"
|
|
/>
|
|
<Text size="xs" c="dimmed">
|
|
{fmtDate(row.loadedAt)}
|
|
</Text>
|
|
</Group>
|
|
) : (
|
|
<Text size="xs" c="dimmed">
|
|
Not loaded
|
|
</Text>
|
|
)}
|
|
</Table.Td>
|
|
<Table.Td>
|
|
{!row.loadedAt ? (
|
|
<Tooltip
|
|
label={
|
|
!logged
|
|
? "Log the pass first — the train must be at this yard"
|
|
: !row.canLoad
|
|
? "Booking is not ready to load (payment pending)"
|
|
: "Confirm cargo loaded onto the train"
|
|
}
|
|
>
|
|
<Button
|
|
size="compact-xs"
|
|
variant="light"
|
|
leftSection={<PackageCheck size={13} />}
|
|
disabled={!logged || !row.canLoad}
|
|
loading={
|
|
load.isPending && load.variables?.bookingId === row.id
|
|
}
|
|
onClick={() => doLoad(row)}
|
|
>
|
|
Load
|
|
</Button>
|
|
</Tooltip>
|
|
) : null}
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Table.ScrollContainer>
|
|
</Stack>
|
|
) : null}
|
|
</>
|
|
)}
|
|
|
|
<Group justify="space-between" mt="xs">
|
|
<Text size="xs" c="dimmed">
|
|
{logged && pendingBoarders.length > 0
|
|
? `${pendingBoarders.length} booking${pendingBoarders.length === 1 ? "" : "s"} still to load before the next station.`
|
|
: ""}
|
|
</Text>
|
|
<Group gap="sm">
|
|
<Button variant="default" onClick={onClose}>
|
|
Close
|
|
</Button>
|
|
{!logged ? (
|
|
<Button
|
|
color={isFinal ? "teal" : "edr-green"}
|
|
leftSection={isFinal ? <Flag size={15} /> : <MapPin size={15} />}
|
|
loading={recordCheckpoint.isPending}
|
|
onClick={doLogPass}
|
|
>
|
|
{isFinal
|
|
? `Mark arrived at ${station?.label ?? "destination"}`
|
|
: `Log pass at ${station?.label ?? "station"}`}
|
|
</Button>
|
|
) : null}
|
|
</Group>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|