feat: full wagon cancel, leg board, wagon dates

feat(freight): editable train leg times, SL invoice payer
This commit is contained in:
Marshal
2026-08-15 10:12:37 +00:00
parent c9c2d4dcb3
commit dc38e843a6
31 changed files with 1619 additions and 217 deletions

View File

@@ -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)}

View File

@@ -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"

View File

@@ -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();