mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 11:21:18 +00:00
feat: full wagon cancel, leg board, wagon dates
feat(freight): editable train leg times, SL invoice payer
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Time + note for one leg of a train's journey — used both to log a pass
|
||||
* (defaults to now) and to correct an already-logged leg (prefilled). Past
|
||||
* times are allowed (staff record after the fact); the future is not, and the
|
||||
* server additionally keeps legs in corridor order.
|
||||
*/
|
||||
export function CheckpointTimeModal({
|
||||
opened,
|
||||
onClose,
|
||||
title,
|
||||
icon,
|
||||
description,
|
||||
initialOccurredAt,
|
||||
initialNote,
|
||||
submitLabel,
|
||||
submitColor = "edr-green",
|
||||
loading,
|
||||
onSubmit,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
description?: string;
|
||||
/** ISO; omit to default to now. */
|
||||
initialOccurredAt?: string | null;
|
||||
initialNote?: string | null;
|
||||
submitLabel: string;
|
||||
submitColor?: string;
|
||||
loading: boolean;
|
||||
onSubmit: (values: { occurredAt: string; note: string }) => void;
|
||||
}) {
|
||||
const [at, setAt] = useState<Date | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
setAt(initialOccurredAt ? new Date(initialOccurredAt) : new Date());
|
||||
setNote(initialNote ?? "");
|
||||
}, [opened, initialOccurredAt, initialNote]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
radius="lg"
|
||||
title={
|
||||
<Group gap={8}>
|
||||
{icon}
|
||||
<Text fw={700}>{title}</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{description ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
<DateTimePicker
|
||||
label="Time"
|
||||
description="Defaults to now — pick an earlier time if you are recording after the fact."
|
||||
value={at}
|
||||
onChange={(v) => setAt(v ? new Date(v) : null)}
|
||||
maxDate={new Date()}
|
||||
valueFormat="DD MMM YYYY HH:mm"
|
||||
clearable={false}
|
||||
radius="md"
|
||||
/>
|
||||
<Textarea
|
||||
label="Note"
|
||||
placeholder="Optional"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
maxLength={500}
|
||||
radius="md"
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color={submitColor}
|
||||
loading={loading}
|
||||
disabled={!at}
|
||||
onClick={() =>
|
||||
at && onSubmit({ occurredAt: at.toISOString(), note: note.trim() })
|
||||
}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { ArrowLeftRight, Boxes, Info, MoveRight, Wheat, X } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
|
||||
type Slot = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
type Stop = { yardId: string; label: string };
|
||||
type Span = [number, number];
|
||||
|
||||
/** One physical wagon of the consist with every slot (leg load) pinned to it. */
|
||||
interface WagonRow {
|
||||
key: string;
|
||||
physicalWagonId: string | null;
|
||||
label: string;
|
||||
position: number;
|
||||
typeCode: string | null;
|
||||
capacityTons: number;
|
||||
slots: Array<{ slot: Slot; span: Span; loaded: boolean }>;
|
||||
}
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
const overlaps = (a: Span, b: Span) => a[0] < b[1] && b[0] < a[1];
|
||||
|
||||
/**
|
||||
* Leg board: rows = physical wagons in coupling order, columns = corridor legs
|
||||
* (A→B, B→C, …). A wagon reused on disjoint legs shows one load per leg on the
|
||||
* same row, so a "53 full on A→B, 53 full on C→D" train reads at a glance.
|
||||
* Loads move by click: pick a load, then click a wagon that is free on that
|
||||
* load's legs (move) or another load (swap). Same API as the consist strip.
|
||||
*/
|
||||
export function LegLoadBoardPanel({
|
||||
schedule,
|
||||
onChanged,
|
||||
}: {
|
||||
schedule: TrainScheduleDetail;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const stops: Stop[] = schedule.stops ?? [];
|
||||
const legs = useMemo(
|
||||
() => stops.slice(0, -1).map((from, i) => ({ from, to: stops[i + 1], idx: i })),
|
||||
[stops],
|
||||
);
|
||||
const canRearrange = !["DISPATCHED", "ARRIVED", "CANCELLED"].includes(schedule.status);
|
||||
|
||||
const spanOf = (slot: Slot): Span => {
|
||||
const from = slot.boardYardId ? stops.findIndex((s) => s.yardId === slot.boardYardId) : 0;
|
||||
const to = slot.alightYardId
|
||||
? stops.findIndex((s) => s.yardId === slot.alightYardId)
|
||||
: stops.length - 1;
|
||||
return [from < 0 ? 0 : from, to < 0 ? Math.max(1, stops.length - 1) : to];
|
||||
};
|
||||
|
||||
const rows: WagonRow[] = useMemo(() => {
|
||||
const byKey = new Map<string, WagonRow>();
|
||||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||
const key = slot.physicalWagonId ?? `slot:${slot.id}`;
|
||||
let row = byKey.get(key);
|
||||
if (!row) {
|
||||
row = {
|
||||
key,
|
||||
physicalWagonId: slot.physicalWagonId ?? null,
|
||||
label: slot.physicalWagonNumber ?? `#${slot.position ?? slot.sequenceNo}`,
|
||||
position: slot.position ?? slot.sequenceNo,
|
||||
typeCode: slot.wagonType?.code ?? null,
|
||||
capacityTons: slot.capacityTons ?? 0,
|
||||
slots: [],
|
||||
};
|
||||
byKey.set(key, row);
|
||||
}
|
||||
row.position = Math.min(row.position, slot.position ?? slot.sequenceNo);
|
||||
// Coupled-but-empty consist wagons carry no slot row: they are a target only.
|
||||
if (!slot.consistOnly) {
|
||||
row.slots.push({
|
||||
slot,
|
||||
span: spanOf(slot),
|
||||
loaded: (slot.allocations?.length ?? 0) > 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...byKey.values()].sort((a, b) => a.position - b.position);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schedule.trainSet?.wagons, stops]);
|
||||
|
||||
const [picked, setPicked] = useState<{ slotId: string; rowKey: string; span: Span } | null>(
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!picked) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setPicked(null);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [picked]);
|
||||
|
||||
const moveMutation = useMutation(api.trainScheduling.moveWagonLoad.mutationOptions());
|
||||
const doMove = async (targetWagonId: string, swap: boolean) => {
|
||||
if (!picked || moveMutation.isPending) return;
|
||||
try {
|
||||
await moveMutation.mutateAsync({
|
||||
scheduleId: schedule.id,
|
||||
wagonId: picked.slotId,
|
||||
targetWagonId,
|
||||
});
|
||||
toast({ title: swap ? "Loads swapped" : "Load moved" });
|
||||
setPicked(null);
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
const message = isAxiosError(error)
|
||||
? ((error.response?.data as { message?: string | string[] } | undefined)?.message ??
|
||||
null)
|
||||
: null;
|
||||
toast({
|
||||
title: "Could not move the load",
|
||||
description: Array.isArray(message)
|
||||
? message.join(", ")
|
||||
: (message ?? "The move was rejected — check wagon type, payload and leg."),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (stops.length < 2) {
|
||||
return (
|
||||
<Alert color="gray" icon={<Info size={16} />} radius="md">
|
||||
This schedule has no corridor stops yet — the leg board needs a route with at least
|
||||
two stops.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<Alert color="gray" icon={<Info size={16} />} radius="md">
|
||||
No wagons on this train yet.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const sharedRows = rows.filter((r) => r.slots.filter((s) => s.loaded).length > 1).length;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={2}>
|
||||
<Text fw={700} size="sm">
|
||||
Loads per wagon per leg
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
One row per physical wagon, one column per leg. A wagon reused on different legs
|
||||
shows one load per leg.{" "}
|
||||
{canRearrange
|
||||
? "Click a load to pick it up, then click a wagon free on those legs to move it, or another load to swap."
|
||||
: "Read-only — the train has departed."}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap="xs">
|
||||
{sharedRows > 0 ? (
|
||||
<Badge variant="light" color="violet" radius="sm">
|
||||
{sharedRows} wagon{sharedRows === 1 ? "" : "s"} shared across legs
|
||||
</Badge>
|
||||
) : null}
|
||||
{picked ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={() => setPicked(null)}
|
||||
>
|
||||
Cancel move (Esc)
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="md" style={{ overflowX: "auto" }}>
|
||||
<Table verticalSpacing={6} horizontalSpacing="sm" style={{ minWidth: 640 }}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ position: "sticky", left: 0, background: "var(--mantine-color-body)", zIndex: 1, width: 180 }}>
|
||||
Wagon
|
||||
</Table.Th>
|
||||
{legs.map((leg) => (
|
||||
<Table.Th key={leg.idx} style={{ minWidth: 200 }}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text size="xs" fw={700} truncate>
|
||||
{leg.from.label}
|
||||
</Text>
|
||||
<MoveRight size={12} />
|
||||
<Text size="xs" fw={700} truncate>
|
||||
{leg.to.label}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Th>
|
||||
))}
|
||||
<Table.Th style={{ width: 110 }}>Cargo</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => {
|
||||
const cargoTons = row.slots.reduce(
|
||||
(s, x) =>
|
||||
s +
|
||||
((x.slot.allocations ?? []).reduce(
|
||||
(a, al) => a + (al.allocatedWeightTons ?? 0),
|
||||
0,
|
||||
) || x.slot.assignedWeightTons || 0),
|
||||
0,
|
||||
);
|
||||
const isPickedRow = picked?.rowKey === row.key;
|
||||
// A row can take the picked load when nothing loaded on it rides
|
||||
// any of the picked load's legs.
|
||||
const rowFreeForPicked =
|
||||
!!picked &&
|
||||
!isPickedRow &&
|
||||
!row.slots.some((s) => s.loaded && overlaps(s.span, picked.span));
|
||||
// Where a "move here" lands: an existing empty slot on those legs,
|
||||
// else the physical wagon itself (the API mints the slot).
|
||||
const emptyTargetSlot = picked
|
||||
? row.slots.find((s) => !s.loaded && overlaps(s.span, picked.span))
|
||||
: undefined;
|
||||
const moveTargetId = emptyTargetSlot?.slot.id ?? row.physicalWagonId ?? null;
|
||||
|
||||
// Lay slots into leg columns; uncovered legs render as empty cells.
|
||||
const cells: React.ReactNode[] = [];
|
||||
let col = 0;
|
||||
const sorted = [...row.slots].sort((a, b) => a.span[0] - b.span[0]);
|
||||
// Empty cell = uncovered leg (target: the physical wagon) or an
|
||||
// empty slot (target: that slot). Both take the picked load when
|
||||
// the row is free on its legs.
|
||||
const emptyCell = (from: number, to: number, targetId = moveTargetId) => {
|
||||
const droppable = rowFreeForPicked && canRearrange && !!targetId &&
|
||||
!!picked && overlaps([from, to], picked.span);
|
||||
return (
|
||||
<Table.Td
|
||||
key={`e-${from}`}
|
||||
colSpan={Math.max(1, to - from)}
|
||||
onClick={droppable ? () => void doMove(targetId!, false) : undefined}
|
||||
style={{
|
||||
cursor: droppable ? "pointer" : "default",
|
||||
background: droppable ? "var(--mantine-color-teal-0)" : undefined,
|
||||
outline: droppable ? "1px dashed var(--mantine-color-teal-5)" : undefined,
|
||||
outlineOffset: -3,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
{droppable ? (
|
||||
<Text size="xs" c="teal.7" fw={600} ta="center">
|
||||
Move here
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
);
|
||||
};
|
||||
for (const s of sorted) {
|
||||
if (s.span[0] > col) cells.push(emptyCell(col, s.span[0]));
|
||||
if (!s.loaded) {
|
||||
cells.push(emptyCell(s.span[0], s.span[1], s.slot.id));
|
||||
col = Math.max(col, s.span[1]);
|
||||
continue;
|
||||
}
|
||||
const isPicked = picked?.slotId === s.slot.id;
|
||||
const swappable =
|
||||
!!picked && !isPicked && !isPickedRow && s.loaded && canRearrange;
|
||||
const allocs = s.slot.allocations ?? [];
|
||||
const bulk = allocs.some((a) => (a.loadType ?? "CONTAINER").toUpperCase() === "BULK");
|
||||
const containers = allocs.flatMap((a) => a.containerItems ?? []);
|
||||
cells.push(
|
||||
<Table.Td
|
||||
key={s.slot.id}
|
||||
colSpan={Math.max(1, s.span[1] - s.span[0])}
|
||||
onClick={
|
||||
!canRearrange
|
||||
? undefined
|
||||
: s.loaded && !picked
|
||||
? () => setPicked({ slotId: s.slot.id, rowKey: row.key, span: s.span })
|
||||
: swappable
|
||||
? () => void doMove(s.slot.id, true)
|
||||
: isPicked
|
||||
? () => setPicked(null)
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
cursor: canRearrange && (s.loaded || swappable) ? "pointer" : "default",
|
||||
padding: 4,
|
||||
}}
|
||||
>
|
||||
{s.loaded ? (
|
||||
<Paper
|
||||
radius="sm"
|
||||
px={8}
|
||||
py={6}
|
||||
style={{
|
||||
background: bulk
|
||||
? "var(--mantine-color-orange-0)"
|
||||
: "var(--mantine-color-cyan-0)",
|
||||
borderLeft: `4px solid ${
|
||||
bulk ? "var(--mantine-color-orange-6)" : "var(--mantine-color-cyan-6)"
|
||||
}`,
|
||||
outline: isPicked
|
||||
? "2px solid var(--mantine-color-edr-green-6)"
|
||||
: swappable
|
||||
? "1px dashed var(--mantine-color-orange-6)"
|
||||
: undefined,
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{bulk ? <Wheat size={13} /> : <Boxes size={13} />}
|
||||
<Text size="xs" fw={700} truncate>
|
||||
{[...new Set(allocs.map((a) => a.bookingReference ?? "—"))].join(", ")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{round1(
|
||||
allocs.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0),
|
||||
)}{" "}
|
||||
t
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={4} mt={2} wrap="wrap">
|
||||
{bulk
|
||||
? allocs.map((a) =>
|
||||
a.bulkLoad ? (
|
||||
<Badge key={a.id} size="xs" variant="light" color="orange" radius="sm">
|
||||
{a.bulkLoad.cargoDescription ?? "Bulk"} · {round1(a.bulkLoad.weightTons)} t
|
||||
</Badge>
|
||||
) : null,
|
||||
)
|
||||
: containers.map((c) => (
|
||||
<Badge key={c.id} size="xs" variant="light" color="cyan" radius="sm">
|
||||
{c.containerNumber ?? "no number"}
|
||||
</Badge>
|
||||
))}
|
||||
{swappable ? (
|
||||
<Badge size="xs" color="orange" radius="sm" leftSection={<ArrowLeftRight size={10} />}>
|
||||
swap
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Paper>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
empty
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>,
|
||||
);
|
||||
col = Math.max(col, s.span[1]);
|
||||
}
|
||||
if (col < legs.length) cells.push(emptyCell(col, legs.length));
|
||||
|
||||
return (
|
||||
<Table.Tr
|
||||
key={row.key}
|
||||
style={{
|
||||
background: isPickedRow
|
||||
? "var(--mantine-color-green-0)"
|
||||
: rowFreeForPicked
|
||||
? undefined
|
||||
: picked
|
||||
? "var(--mantine-color-gray-0)"
|
||||
: undefined,
|
||||
opacity: picked && !isPickedRow && !rowFreeForPicked ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
<Table.Td style={{ position: "sticky", left: 0, background: "var(--mantine-color-body)", zIndex: 1 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Badge variant="outline" color="gray" radius="sm" size="sm">
|
||||
#{row.position}
|
||||
</Badge>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={700}>
|
||||
{row.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.typeCode ?? "—"} · {round1(row.capacityTons)} t
|
||||
</Text>
|
||||
</Stack>
|
||||
{row.slots.filter((s) => s.loaded).length > 1 ? (
|
||||
<Tooltip label="This wagon carries different loads on different legs">
|
||||
<Badge size="xs" color="violet" variant="light" radius="sm">
|
||||
shared
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
{cells.map((c, i) => (
|
||||
<Fragment key={i}>{c}</Fragment>
|
||||
))}
|
||||
<Table.Td>
|
||||
<Text size="xs" fw={600} c={cargoTons > row.capacityTons + 0.001 ? "red.7" : undefined}>
|
||||
{round1(cargoTons)} / {round1(row.capacityTons)} t
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
@@ -111,7 +112,12 @@ export function LogPassYardWorkModal({
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [justLogged, setJustLogged] = useState(false);
|
||||
useEffect(() => setJustLogged(false), [station?.sequenceNo, opened]);
|
||||
// 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(
|
||||
@@ -133,7 +139,13 @@ export function LogPassYardWorkModal({
|
||||
const doLogPass = () => {
|
||||
if (!station) return;
|
||||
recordCheckpoint.mutate(
|
||||
{ id: scheduleId, payload: { sequenceNo: station.sequenceNo } },
|
||||
{
|
||||
id: scheduleId,
|
||||
payload: {
|
||||
sequenceNo: station.sequenceNo,
|
||||
...(passAt ? { occurredAt: passAt.toISOString() } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setJustLogged(true);
|
||||
@@ -373,6 +385,20 @@ export function LogPassYardWorkModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{!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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Fragment } from "react";
|
||||
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { Check, Flag, MapPin, Train } from "lucide-react";
|
||||
import { Check, Flag, MapPin, Pencil, Train } from "lucide-react";
|
||||
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
import type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling";
|
||||
@@ -14,6 +14,8 @@ export interface RouteCorridorTrackProps {
|
||||
canLog: boolean;
|
||||
loggingSeq?: number | null;
|
||||
onLogCheckpoint?: (sequenceNo: number) => void;
|
||||
/** Present when logged legs may be corrected (dispatched or arrived). */
|
||||
onEditCheckpoint?: (checkpoint: TrainCheckpoint) => void;
|
||||
}
|
||||
|
||||
const COLUMN_WIDTH = 150;
|
||||
@@ -31,6 +33,7 @@ export function RouteCorridorTrack({
|
||||
canLog,
|
||||
loggingSeq,
|
||||
onLogCheckpoint,
|
||||
onEditCheckpoint,
|
||||
}: RouteCorridorTrackProps) {
|
||||
const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c]));
|
||||
const lastIndex = stations.length - 1;
|
||||
@@ -160,14 +163,28 @@ export function RouteCorridorTrack({
|
||||
|
||||
{/* checkpoint time or action */}
|
||||
{checkpoint ? (
|
||||
<Text size="10px" c="dimmed" ta="center">
|
||||
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
<Stack gap={2} align="center">
|
||||
<Text size="10px" c="dimmed" ta="center">
|
||||
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
{onEditCheckpoint ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
radius="md"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<Pencil size={11} />}
|
||||
onClick={() => onEditCheckpoint(checkpoint)}
|
||||
>
|
||||
Edit time
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : isNext ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
|
||||
@@ -483,6 +483,8 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
|
||||
CHECKPOINTS: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/checkpoints`,
|
||||
CHECKPOINT: (id: string, sequenceNo: number) =>
|
||||
`/train-scheduling/schedules/${id}/checkpoints/${sequenceNo}`,
|
||||
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
||||
RESCHEDULE_PREVIEW: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/reschedule/preview`,
|
||||
|
||||
@@ -353,6 +353,10 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ id: "importTrainNumber", header: "Import train no.", accessorKey: "importTrainNumber", format: "code" },
|
||||
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
|
||||
// From the status-flip log: last time the wagon went to maintenance, and
|
||||
// last time it became available again (dash = never logged).
|
||||
{ id: "lastMaintenanceAt", header: "Last to maintenance", accessorKey: "lastMaintenanceAt", format: "date" },
|
||||
{ id: "lastAvailableAt", header: "Available since", accessorKey: "lastAvailableAt", format: "date" },
|
||||
],
|
||||
formFields: [
|
||||
// Run numbers are optional — a wagon sits in the fleet unassigned to any
|
||||
|
||||
@@ -77,9 +77,31 @@ function InfoField({
|
||||
);
|
||||
}
|
||||
|
||||
/** Billed-to company, with its contact/registration details as quick-info rows. */
|
||||
/**
|
||||
* Billed-to party: a customer company, or — for shipping-line credit invoices
|
||||
* (`companyId` null) — the shipping line itself. The two payers are mutually
|
||||
* exclusive (DB-enforced), so exactly one branch has data.
|
||||
*/
|
||||
function RecipientCard({ invoice }: { invoice: Invoice }) {
|
||||
const company = invoice.company;
|
||||
const shippingLine = invoice.shippingLineCompany;
|
||||
|
||||
if (!company && shippingLine) {
|
||||
const rows: FieldRowProps[] = [
|
||||
{ label: "Phone", value: shippingLine.phoneNumber },
|
||||
{ label: "Email", value: shippingLine.email },
|
||||
];
|
||||
return (
|
||||
<LinkedEntityCard
|
||||
icon={Building2}
|
||||
title="Billed to"
|
||||
name={shippingLine.name}
|
||||
rows={rows}
|
||||
emptyMessage="No additional shipping line details available."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const rows: FieldRowProps[] = [
|
||||
{ label: "Profile", value: invoice.companyProfile?.reference },
|
||||
{ label: "TIN", value: company?.tin },
|
||||
@@ -91,7 +113,7 @@ function RecipientCard({ invoice }: { invoice: Invoice }) {
|
||||
return (
|
||||
<LinkedEntityCard
|
||||
icon={Building2}
|
||||
title="Recipient"
|
||||
title="Billed to"
|
||||
name={company?.name ?? "Unnamed company"}
|
||||
to={invoice.companyId ? `/dashboard/customers/${invoice.companyId}` : null}
|
||||
rows={rows}
|
||||
|
||||
@@ -112,7 +112,9 @@ export default function InvoicesPanel() {
|
||||
header: "Billed to",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text" truncate maw={200}>
|
||||
{row.original.company?.name ?? "—"}
|
||||
{row.original.company?.name ??
|
||||
row.original.shippingLineCompany?.name ??
|
||||
"—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -580,14 +580,10 @@ const RuleEngineResourcePage = () => {
|
||||
config={config}
|
||||
layout="row"
|
||||
readOnly={!canUpdateControls}
|
||||
onEdit={
|
||||
config.slug === "container-types"
|
||||
? undefined
|
||||
: (record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
}
|
||||
}
|
||||
onEdit={(record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
onDelete={setDeleteTarget}
|
||||
onViewChain={
|
||||
config.slug === "approval-rules"
|
||||
@@ -958,9 +954,7 @@ const RuleEngineResourcePage = () => {
|
||||
totalCount={totalCount}
|
||||
onPaginationChange={setPagination}
|
||||
readOnly={!canUpdate && !canDelete}
|
||||
onEdit={
|
||||
canUpdate && config.slug !== "container-types" ? openEdit : undefined
|
||||
}
|
||||
onEdit={canUpdate ? openEdit : undefined}
|
||||
onDelete={canDelete ? setDeleteTarget : undefined}
|
||||
onViewChain={
|
||||
config.slug === "approval-rules"
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
MapPin,
|
||||
Navigation,
|
||||
PackageCheck,
|
||||
Pencil,
|
||||
Train,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -29,9 +30,10 @@ import {
|
||||
} from "@mantine/core";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
|
||||
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
|
||||
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
|
||||
import type { TrackStation } from "@/types/trainScheduling";
|
||||
import type { TrackStation, TrainCheckpoint } from "@/types/trainScheduling";
|
||||
import {
|
||||
RouteCorridor,
|
||||
StatusPill,
|
||||
@@ -156,6 +158,16 @@ export default function TrainScheduleTrackPage() {
|
||||
const recordCheckpoint = useMutation(
|
||||
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
||||
);
|
||||
const updateCheckpoint = useMutation(
|
||||
api.trainScheduling.updateCheckpoint.mutationOptions(),
|
||||
);
|
||||
// Time-entry dialogs: logging a pass at a yard with no work (the yard-work
|
||||
// modal carries its own picker), and correcting an already-logged leg.
|
||||
const [logModal, setLogModal] = useState<{
|
||||
station: TrackStation;
|
||||
isFinal: boolean;
|
||||
} | null>(null);
|
||||
const [editModal, setEditModal] = useState<TrainCheckpoint | null>(null);
|
||||
// Yard work drives the log-pass modal: which bookings board/alight per stop.
|
||||
const yardWorkQuery = useQuery(
|
||||
api.trainScheduling.yardWork.queryOptions({
|
||||
@@ -241,15 +253,30 @@ export default function TrainScheduleTrackPage() {
|
||||
|
||||
const handleLog = (sequenceNo: number) => {
|
||||
const station = track.stations.find((s) => s.sequenceNo === sequenceNo);
|
||||
if (!station) return;
|
||||
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
|
||||
if (station && stationHasWork(station)) {
|
||||
if (stationHasWork(station)) {
|
||||
setYardModal({ station, isFinal, alreadyLogged: false });
|
||||
return;
|
||||
}
|
||||
setLogModal({ station, isFinal });
|
||||
};
|
||||
|
||||
const submitLog = (values: { occurredAt: string; note: string }) => {
|
||||
if (!logModal) return;
|
||||
const { station, isFinal } = logModal;
|
||||
recordCheckpoint.mutate(
|
||||
{ id: scheduleId, payload: { sequenceNo } },
|
||||
{
|
||||
id: scheduleId,
|
||||
payload: {
|
||||
sequenceNo: station.sequenceNo,
|
||||
occurredAt: values.occurredAt,
|
||||
...(values.note ? { note: values.note } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setLogModal(null);
|
||||
toast({
|
||||
title: isFinal
|
||||
? "Train arrived — assets freed, moved to destination yard"
|
||||
@@ -266,6 +293,32 @@ export default function TrainScheduleTrackPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const submitEdit = (values: { occurredAt: string; note: string }) => {
|
||||
if (!editModal) return;
|
||||
updateCheckpoint.mutate(
|
||||
{
|
||||
id: scheduleId,
|
||||
sequenceNo: editModal.sequenceNo,
|
||||
payload: { occurredAt: values.occurredAt, note: values.note || null },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditModal(null);
|
||||
toast({ title: "Checkpoint updated" });
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not update checkpoint",
|
||||
description: parseError(err, "Please try again"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
// Legs stay correctable for as long as the journey exists — while rolling
|
||||
// and after arrival.
|
||||
const canEdit = track.status === "DISPATCHED" || track.status === "ARRIVED";
|
||||
|
||||
// "Forgot to load" catch: while the train sits at the current station, any
|
||||
// boarder there that is still unloaded can be loaded until the next pass.
|
||||
const currentStationObj = track.stations.find(
|
||||
@@ -502,6 +555,7 @@ export default function TrainScheduleTrackPage() {
|
||||
: null
|
||||
}
|
||||
onLogCheckpoint={handleLog}
|
||||
onEditCheckpoint={canEdit ? setEditModal : undefined}
|
||||
/>
|
||||
|
||||
{/* Cargo the operator forgot: boarders at the CURRENT station stay
|
||||
@@ -595,24 +649,38 @@ export default function TrainScheduleTrackPage() {
|
||||
)
|
||||
}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<Text fw={700} size="sm">
|
||||
{cp.label ?? `Station ${cp.sequenceNo}`}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={
|
||||
cp.kind === "ARRIVED"
|
||||
? "teal"
|
||||
: cp.kind === "DEPARTED"
|
||||
? "blue"
|
||||
: "edr-green"
|
||||
}
|
||||
>
|
||||
{cp.kind}
|
||||
</Badge>
|
||||
<Group gap="sm" justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<Text fw={700} size="sm">
|
||||
{cp.label ?? `Station ${cp.sequenceNo}`}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={
|
||||
cp.kind === "ARRIVED"
|
||||
? "teal"
|
||||
: cp.kind === "DEPARTED"
|
||||
? "blue"
|
||||
: "edr-green"
|
||||
}
|
||||
>
|
||||
{cp.kind}
|
||||
</Badge>
|
||||
</Group>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<Pencil size={12} />}
|
||||
onClick={() => setEditModal(cp)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
@@ -630,6 +698,39 @@ export default function TrainScheduleTrackPage() {
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<CheckpointTimeModal
|
||||
opened={logModal !== null}
|
||||
onClose={() => setLogModal(null)}
|
||||
title={
|
||||
logModal?.isFinal
|
||||
? `Mark arrived at ${logModal.station.label}`
|
||||
: `Log pass at ${logModal?.station.label ?? "station"}`
|
||||
}
|
||||
icon={logModal?.isFinal ? <Flag size={18} /> : <MapPin size={18} />}
|
||||
description={
|
||||
logModal?.isFinal
|
||||
? "Marks the train arrived: remaining bookings arrive, assets are freed."
|
||||
: undefined
|
||||
}
|
||||
submitLabel={logModal?.isFinal ? "Mark arrived" : "Log pass"}
|
||||
submitColor={logModal?.isFinal ? "teal" : "edr-green"}
|
||||
loading={recordCheckpoint.isPending}
|
||||
onSubmit={submitLog}
|
||||
/>
|
||||
|
||||
<CheckpointTimeModal
|
||||
opened={editModal !== null}
|
||||
onClose={() => setEditModal(null)}
|
||||
title={`Edit ${editModal?.label ?? "checkpoint"}`}
|
||||
icon={<Pencil size={18} />}
|
||||
description="Corrects this leg's time and note only — nothing else changes."
|
||||
initialOccurredAt={editModal?.occurredAt}
|
||||
initialNote={editModal?.note}
|
||||
submitLabel="Save"
|
||||
loading={updateCheckpoint.isPending}
|
||||
onSubmit={submitEdit}
|
||||
/>
|
||||
|
||||
<LogPassYardWorkModal
|
||||
opened={yardModal !== null}
|
||||
onClose={() => setYardModal(null)}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
Navigation,
|
||||
Package,
|
||||
PackageCheck,
|
||||
Grid3x3,
|
||||
Route as RouteIcon,
|
||||
Ruler,
|
||||
Send,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
Weight,
|
||||
Workflow as WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
|
||||
@@ -57,6 +59,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
|
||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
|
||||
import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
|
||||
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
|
||||
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
|
||||
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
|
||||
@@ -125,6 +128,13 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
|
||||
const [gatepassNotes, setGatepassNotes] = useState("");
|
||||
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
|
||||
// Actual departure — staff often dispatch on paper first and record it later,
|
||||
// so the time is picked (defaults to now when the dialog opens).
|
||||
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
|
||||
const openDispatchConfirm = () => {
|
||||
setDispatchAt(new Date());
|
||||
setDispatchConfirmOpen(true);
|
||||
};
|
||||
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
|
||||
const [visualization3DOpen, setVisualization3DOpen] = useState(false);
|
||||
const [loadEmptiesOpen, setLoadEmptiesOpen] = useState(false);
|
||||
@@ -477,7 +487,10 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const runDispatch = async () => {
|
||||
setDispatchConfirmOpen(false);
|
||||
try {
|
||||
await dispatch.mutateAsync(scheduleId);
|
||||
await dispatch.mutateAsync({
|
||||
id: scheduleId,
|
||||
payload: dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {},
|
||||
});
|
||||
await openMarshallingDocument({
|
||||
title: "Train dispatched",
|
||||
successDescription: "Marshalling document generated for the dispatched train.",
|
||||
@@ -873,7 +886,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
radius="md"
|
||||
leftSection={<Send size={18} />}
|
||||
loading={dispatch.isPending}
|
||||
onClick={() => setDispatchConfirmOpen(true)}
|
||||
onClick={openDispatchConfirm}
|
||||
>
|
||||
Dispatch train
|
||||
</Button>
|
||||
@@ -1271,6 +1284,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}>
|
||||
Leg capacity
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="leg-board" leftSection={<Grid3x3 size={16} />}>
|
||||
Leg board
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
@@ -1359,6 +1375,13 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<LegCapacityPanel schedule={schedule} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="leg-board">
|
||||
<LegLoadBoardPanel
|
||||
schedule={schedule}
|
||||
onChanged={() => void detailQuery.refetch()}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history">
|
||||
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
|
||||
</Tabs.Panel>
|
||||
@@ -1444,6 +1467,17 @@ export default function TrainScheduleV2DetailPage() {
|
||||
undone.
|
||||
</Text>
|
||||
|
||||
<DateTimePicker
|
||||
label="Actual departure"
|
||||
description="When the train left — defaults to now; a past time is fine."
|
||||
value={dispatchAt}
|
||||
onChange={(v) => setDispatchAt(v ? new Date(v) : null)}
|
||||
maxDate={new Date()}
|
||||
valueFormat="DD MMM YYYY HH:mm"
|
||||
clearable={false}
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
{hasDispatchWarnings ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
@@ -134,6 +135,8 @@ export default function TrainScheduleV2ListPage() {
|
||||
// confirmation.
|
||||
const [dispatchTarget, setDispatchTarget] =
|
||||
useState<TrainScheduleListItem | null>(null);
|
||||
// Actual departure — defaults to now when the dialog opens; past is fine.
|
||||
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
|
||||
// Cancelling is likewise irreversible — confirmed before the mutation fires.
|
||||
const [cancelTarget, setCancelTarget] =
|
||||
useState<TrainScheduleListItem | null>(null);
|
||||
@@ -508,7 +511,10 @@ export default function TrainScheduleV2ListPage() {
|
||||
{canDispatch && schedule.status === "SCHEDULED" ? (
|
||||
<Menu.Item
|
||||
leftSection={<Play size={15} />}
|
||||
onClick={() => setDispatchTarget(schedule)}
|
||||
onClick={() => {
|
||||
setDispatchAt(new Date());
|
||||
setDispatchTarget(schedule);
|
||||
}}
|
||||
>
|
||||
Start (dispatch) train
|
||||
</Menu.Item>
|
||||
@@ -959,6 +965,16 @@ export default function TrainScheduleV2ListPage() {
|
||||
wagons or cargo not yet marked loaded — those warnings are shown
|
||||
there, not here.
|
||||
</Text>
|
||||
<DateTimePicker
|
||||
label="Actual departure"
|
||||
description="When the train left — defaults to now; a past time is fine."
|
||||
value={dispatchAt}
|
||||
onChange={(v) => setDispatchAt(v ? new Date(v) : null)}
|
||||
maxDate={new Date()}
|
||||
valueFormat="DD MMM YYYY HH:mm"
|
||||
clearable={false}
|
||||
radius="md"
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setDispatchTarget(null)}>
|
||||
Cancel
|
||||
@@ -970,7 +986,12 @@ export default function TrainScheduleV2ListPage() {
|
||||
onClick={async () => {
|
||||
if (!dispatchTarget) return;
|
||||
try {
|
||||
await dispatchSchedule.mutateAsync(dispatchTarget.id);
|
||||
await dispatchSchedule.mutateAsync({
|
||||
id: dispatchTarget.id,
|
||||
payload: dispatchAt
|
||||
? { actualDepartureAt: dispatchAt.toISOString() }
|
||||
: {},
|
||||
});
|
||||
toast({ title: "Train dispatched" });
|
||||
setDispatchTarget(null);
|
||||
void schedulesQuery.refetch();
|
||||
|
||||
@@ -81,6 +81,8 @@ import type {
|
||||
LocomotiveRecord,
|
||||
PinWagonsPayload,
|
||||
RecordCheckpointPayload,
|
||||
UpdateCheckpointPayload,
|
||||
DispatchSchedulePayload,
|
||||
StaffBookingWindow,
|
||||
ScheduleMergePreview,
|
||||
TrainScheduleDetail,
|
||||
@@ -797,10 +799,13 @@ export const api = {
|
||||
],
|
||||
),
|
||||
|
||||
dispatchSchedule: endpoint<string, TrainScheduleDetail>(
|
||||
dispatchSchedule: endpoint<
|
||||
{ id: string; payload?: DispatchSchedulePayload },
|
||||
TrainScheduleDetail
|
||||
>(
|
||||
"train-scheduling",
|
||||
"dispatch-schedule",
|
||||
(id) => trainSchedulingService.dispatchSchedule(id),
|
||||
({ id, payload }) => trainSchedulingService.dispatchSchedule(id, payload),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
@@ -918,6 +923,18 @@ export const api = {
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
updateCheckpoint: endpoint<
|
||||
{ id: string; sequenceNo: number; payload: UpdateCheckpointPayload },
|
||||
TrainTrackResponse
|
||||
>(
|
||||
"train-scheduling",
|
||||
"update-checkpoint",
|
||||
({ id, sequenceNo, payload }) =>
|
||||
trainSchedulingService.updateCheckpoint(id, sequenceNo, payload),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
arriveSchedule: endpoint<string, TrainScheduleDetail>(
|
||||
"train-scheduling",
|
||||
"arrive-schedule",
|
||||
|
||||
@@ -28,6 +28,8 @@ import type {
|
||||
LocomotiveRecord,
|
||||
PinWagonsPayload,
|
||||
RecordCheckpointPayload,
|
||||
UpdateCheckpointPayload,
|
||||
DispatchSchedulePayload,
|
||||
StaffBookingWindow,
|
||||
ScheduleMergePreview,
|
||||
TrainScheduleDetail,
|
||||
@@ -524,10 +526,11 @@ export const trainSchedulingService = {
|
||||
|
||||
dispatchSchedule: async (
|
||||
scheduleId: string,
|
||||
payload: DispatchSchedulePayload = {},
|
||||
): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.post<TrainScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.DISPATCH(scheduleId),
|
||||
{},
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
@@ -696,6 +699,18 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
updateCheckpoint: async (
|
||||
scheduleId: string,
|
||||
sequenceNo: number,
|
||||
payload: UpdateCheckpointPayload,
|
||||
): Promise<TrainTrackResponse> => {
|
||||
const response = await client.patch<TrainTrackResponse>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINT(scheduleId, sequenceNo),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
arriveSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.post<TrainScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.ARRIVE(scheduleId),
|
||||
|
||||
@@ -26,6 +26,9 @@ export interface Wagon {
|
||||
lengthMeters?: number;
|
||||
} | null;
|
||||
status: Freight.WagonStatus;
|
||||
/** Latest status-log flip to MAINTENANCE / to AVAILABLE (list endpoint only). */
|
||||
lastMaintenanceAt?: string | null;
|
||||
lastAvailableAt?: string | null;
|
||||
currentYardId: string | null;
|
||||
currentYard?: { id: string; label?: string; code?: string } | null;
|
||||
/** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */
|
||||
|
||||
@@ -910,10 +910,22 @@ export interface TrainTrackResponse {
|
||||
export interface RecordCheckpointPayload {
|
||||
sequenceNo: number;
|
||||
kind?: TrainCheckpointKind;
|
||||
/** When the train was at the station; defaults to now. Past OK, future rejected. */
|
||||
occurredAt?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Edit an already-logged leg — pure correction, no side effects. */
|
||||
export interface UpdateCheckpointPayload {
|
||||
occurredAt?: string;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export interface DispatchSchedulePayload {
|
||||
/** Actual departure; defaults to now. Past OK, future rejected. */
|
||||
actualDepartureAt?: string;
|
||||
}
|
||||
|
||||
export interface TrainScheduleFilters {
|
||||
originStationId?: string;
|
||||
destinationStationId?: string;
|
||||
|
||||
Reference in New Issue
Block a user