Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx
Marshal dc38e843a6 feat: full wagon cancel, leg board, wagon dates
feat(freight): editable train leg times, SL invoice payer
2026-08-15 10:12:37 +00:00

430 lines
15 KiB
TypeScript

import {
Alert,
Badge,
Button,
Divider,
Group,
Loader,
Modal,
Stack,
Table,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
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);
// When the train was here — defaults to now, past allowed (recorded after the fact).
const [passAt, setPassAt] = useState<Date | null>(null);
useEffect(() => {
setJustLogged(false);
setPassAt(new Date());
}, [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,
...(passAt ? { occurredAt: passAt.toISOString() } : {}),
},
},
{
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}
</>
)}
{!logged ? (
<DateTimePicker
label={isFinal ? "Arrival time" : "Time at station"}
description="Defaults to now — pick an earlier time if you are recording after the fact."
value={passAt}
onChange={(v) => setPassAt(v ? new Date(v) : null)}
maxDate={new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
maw={320}
/>
) : 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>
);
}