feat(reports): record loading and unloading time per stop

The OCC report publishes total loading and unloading time and the other
activity left over from a station stay, but nothing recorded when handling
started or ended — the July 2026 seed had to write the figure into a
checkpoint note. Four nullable stamps now ride the stop's arrival row, which
is the row the staying-time report builds a stay from (a turnaround's
departure belongs to a different schedule).

Handling is unloading start to loading end, so a container stop reads as one
window and a bulk station that only loads or only unloads still reports its
half; other activity is the rest of the stay. Both stay NULL where nothing
was logged rather than collapsing to zero.

- station-staying-time: + loading/unloading and other activity per stop
- turnaround-cycle: + the same, summed over the cycle's stops
- loading-unloading (new): per train per station per period, so a week or
  month view is that train's average over its stops
- the stop/stay query moves to operations-classification, shared by both
This commit is contained in:
Nathnael
2026-08-24 12:09:04 +00:00
parent 2286135228
commit 17c2dca3cc
16 changed files with 763 additions and 106 deletions

View File

@@ -33,7 +33,11 @@ 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, TrainCheckpoint } from "@/types/trainScheduling";
import type {
CheckpointHandlingTimes,
TrackStation,
TrainCheckpoint,
} from "@/types/trainScheduling";
import {
RouteCorridor,
StatusPill,
@@ -46,6 +50,51 @@ import { useToast } from "@/hooks/use-toast";
import { openPdfBlob } from "@/components/warehouses/pdf";
import { trainSchedulingService } from "@/services/trainScheduling.service";
type CheckpointModalValues = { occurredAt: string; note: string } & Required<
Record<keyof CheckpointHandlingTimes, string | null>
>;
/**
* The station-work stamps off the modal.
*
* On an edit a cleared picker means "remove this stamp", so nulls are sent.
* On a first log there is nothing to remove, and the record endpoint takes no
* nulls — the untouched pickers are dropped instead.
*/
const pickHandling = (
values: CheckpointModalValues,
keepNulls: boolean,
): CheckpointHandlingTimes =>
Object.fromEntries(
(
[
"unloadingStartedAt",
"unloadingCompletedAt",
"loadingStartedAt",
"loadingCompletedAt",
] as const
)
.map((field) => [field, values[field]] as const)
.filter(([, value]) => keepNulls || value !== null),
);
/**
* Total loading and unloading at a stop, the way the reports measure it:
* earliest start to latest finish, so a stop that only loaded or only unloaded
* still reads. Null when nothing was logged.
*/
const handlingHours = (cp: TrainCheckpoint): number | null => {
const times = [cp.unloadingStartedAt, cp.loadingStartedAt]
.filter((v): v is string => Boolean(v))
.map((v) => new Date(v).getTime());
const ends = [cp.loadingCompletedAt, cp.unloadingCompletedAt]
.filter((v): v is string => Boolean(v))
.map((v) => new Date(v).getTime());
if (!times.length || !ends.length) return null;
const hours = (Math.max(...ends) - Math.min(...times)) / 3_600_000;
return Math.round(hours * 10) / 10;
};
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const data = error.response?.data as Record<string, unknown> | undefined;
@@ -262,7 +311,7 @@ export default function TrainScheduleTrackPage() {
setLogModal({ station, isFinal });
};
const submitLog = (values: { occurredAt: string; note: string }) => {
const submitLog = (values: CheckpointModalValues) => {
if (!logModal) return;
const { station, isFinal } = logModal;
recordCheckpoint.mutate(
@@ -272,6 +321,8 @@ export default function TrainScheduleTrackPage() {
sequenceNo: station.sequenceNo,
occurredAt: values.occurredAt,
...(values.note ? { note: values.note } : {}),
// Nothing to clear on a first log — send only what was entered.
...pickHandling(values, false),
},
},
{
@@ -293,13 +344,18 @@ export default function TrainScheduleTrackPage() {
);
};
const submitEdit = (values: { occurredAt: string; note: string }) => {
const submitEdit = (values: CheckpointModalValues) => {
if (!editModal) return;
updateCheckpoint.mutate(
{
id: scheduleId,
sequenceNo: editModal.sequenceNo,
payload: { occurredAt: values.occurredAt, note: values.note || null },
payload: {
occurredAt: values.occurredAt,
note: values.note || null,
// Nulls are meaningful here: clearing a picker clears the stamp.
...pickHandling(values, true),
},
},
{
onSuccess: () => {
@@ -687,6 +743,11 @@ export default function TrainScheduleTrackPage() {
<Text size="xs" c="dimmed">
{formatDateTime(cp.occurredAt)}
</Text>
{handlingHours(cp) !== null ? (
<Text size="xs" c="dimmed" mt={2}>
Loading + unloading {handlingHours(cp)} h
</Text>
) : null}
{cp.note ? (
<Text size="xs" mt={2}>
{cp.note}
@@ -723,9 +784,10 @@ export default function TrainScheduleTrackPage() {
onClose={() => setEditModal(null)}
title={`Edit ${editModal?.label ?? "checkpoint"}`}
icon={<Pencil size={18} />}
description="Corrects this leg's time and note only — nothing else changes."
description="Corrects this leg's time, station work and note only — nothing else changes."
initialOccurredAt={editModal?.occurredAt}
initialNote={editModal?.note}
initialHandling={editModal}
submitLabel="Save"
loading={updateCheckpoint.isPending}
onSubmit={submitEdit}