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