mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -1,13 +1,38 @@
|
||||
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||
import { Button, Divider, Group, Modal, SimpleGrid, Stack, Text, Textarea } from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { CheckpointHandlingTimes } from "@/types/trainScheduling";
|
||||
|
||||
/** The four station-work stamps, in the order they happen. */
|
||||
const HANDLING_FIELDS = [
|
||||
["unloadingStartedAt", "Unloading started"],
|
||||
["unloadingCompletedAt", "Unloading finished"],
|
||||
["loadingStartedAt", "Loading started"],
|
||||
["loadingCompletedAt", "Loading finished"],
|
||||
] as const;
|
||||
|
||||
type HandlingField = (typeof HANDLING_FIELDS)[number][0];
|
||||
type HandlingState = Record<HandlingField, Date | null>;
|
||||
|
||||
const EMPTY_HANDLING: HandlingState = {
|
||||
unloadingStartedAt: null,
|
||||
unloadingCompletedAt: null,
|
||||
loadingStartedAt: null,
|
||||
loadingCompletedAt: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Time, station work and 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.
|
||||
*
|
||||
* The four handling stamps are what the loading-and-unloading reports measure:
|
||||
* total handling is unloading start to loading end, and whatever is left of the
|
||||
* stay is other activity. All four are optional — a stop logged without them
|
||||
* still reports its staying time.
|
||||
*/
|
||||
export function CheckpointTimeModal({
|
||||
opened,
|
||||
@@ -17,6 +42,7 @@ export function CheckpointTimeModal({
|
||||
description,
|
||||
initialOccurredAt,
|
||||
initialNote,
|
||||
initialHandling,
|
||||
submitLabel,
|
||||
submitColor = "edr-green",
|
||||
loading,
|
||||
@@ -30,19 +56,37 @@ export function CheckpointTimeModal({
|
||||
/** ISO; omit to default to now. */
|
||||
initialOccurredAt?: string | null;
|
||||
initialNote?: string | null;
|
||||
initialHandling?: CheckpointHandlingTimes | null;
|
||||
submitLabel: string;
|
||||
submitColor?: string;
|
||||
loading: boolean;
|
||||
onSubmit: (values: { occurredAt: string; note: string }) => void;
|
||||
onSubmit: (
|
||||
values: { occurredAt: string; note: string } & Record<HandlingField, string | null>,
|
||||
) => void;
|
||||
}) {
|
||||
const isSmallScreen = useMediaQuery("(max-width: 48em)");
|
||||
const [at, setAt] = useState<Date | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
const [handling, setHandling] = useState<HandlingState>(EMPTY_HANDLING);
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
setAt(initialOccurredAt ? new Date(initialOccurredAt) : new Date());
|
||||
setNote(initialNote ?? "");
|
||||
}, [opened, initialOccurredAt, initialNote]);
|
||||
setHandling({
|
||||
unloadingStartedAt: initialHandling?.unloadingStartedAt
|
||||
? new Date(initialHandling.unloadingStartedAt)
|
||||
: null,
|
||||
unloadingCompletedAt: initialHandling?.unloadingCompletedAt
|
||||
? new Date(initialHandling.unloadingCompletedAt)
|
||||
: null,
|
||||
loadingStartedAt: initialHandling?.loadingStartedAt
|
||||
? new Date(initialHandling.loadingStartedAt)
|
||||
: null,
|
||||
loadingCompletedAt: initialHandling?.loadingCompletedAt
|
||||
? new Date(initialHandling.loadingCompletedAt)
|
||||
: null,
|
||||
});
|
||||
}, [opened, initialOccurredAt, initialNote, initialHandling]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -76,6 +120,35 @@ export function CheckpointTimeModal({
|
||||
clearable={false}
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
<Divider
|
||||
label="Station work (optional)"
|
||||
labelPosition="left"
|
||||
styles={{ label: { fontWeight: 600 } }}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt={-8}>
|
||||
Loading and unloading times for this stop. Total handling is unloading start to
|
||||
loading finish; the rest of the stay reports as other activity.
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
{HANDLING_FIELDS.map(([field, label]) => (
|
||||
<DateTimePicker
|
||||
key={field}
|
||||
label={label}
|
||||
value={handling[field]}
|
||||
onChange={(v) =>
|
||||
setHandling((prev) => ({ ...prev, [field]: v ? new Date(v) : null }))
|
||||
}
|
||||
maxDate={new Date()}
|
||||
dropdownType={isSmallScreen ? "modal" : "popover"}
|
||||
popoverProps={{ withinPortal: true }}
|
||||
valueFormat="DD MMM YYYY HH:mm"
|
||||
clearable
|
||||
radius="md"
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Textarea
|
||||
label="Note"
|
||||
placeholder="Optional"
|
||||
@@ -96,7 +169,15 @@ export function CheckpointTimeModal({
|
||||
loading={loading}
|
||||
disabled={!at}
|
||||
onClick={() =>
|
||||
at && onSubmit({ occurredAt: at.toISOString(), note: note.trim() })
|
||||
at &&
|
||||
onSubmit({
|
||||
occurredAt: at.toISOString(),
|
||||
note: note.trim(),
|
||||
unloadingStartedAt: handling.unloadingStartedAt?.toISOString() ?? null,
|
||||
unloadingCompletedAt: handling.unloadingCompletedAt?.toISOString() ?? null,
|
||||
loadingStartedAt: handling.loadingStartedAt?.toISOString() ?? null,
|
||||
loadingCompletedAt: handling.loadingCompletedAt?.toISOString() ?? null,
|
||||
})
|
||||
}
|
||||
>
|
||||
{submitLabel}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -897,9 +897,22 @@ export interface TrainCheckpoint {
|
||||
label: string | null;
|
||||
kind: TrainCheckpointKind;
|
||||
occurredAt: string;
|
||||
/** Station work during the stay this stop opens. Null = never logged. */
|
||||
unloadingStartedAt: string | null;
|
||||
unloadingCompletedAt: string | null;
|
||||
loadingStartedAt: string | null;
|
||||
loadingCompletedAt: string | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/** The four station-work stamps, as a payload fragment both endpoints accept. */
|
||||
export interface CheckpointHandlingTimes {
|
||||
unloadingStartedAt?: string | null;
|
||||
unloadingCompletedAt?: string | null;
|
||||
loadingStartedAt?: string | null;
|
||||
loadingCompletedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface TrainTrackResponse {
|
||||
scheduleId: string;
|
||||
status: TrainScheduleStatus | string;
|
||||
@@ -914,7 +927,7 @@ export interface TrainTrackResponse {
|
||||
checkpoints: TrainCheckpoint[];
|
||||
}
|
||||
|
||||
export interface RecordCheckpointPayload {
|
||||
export interface RecordCheckpointPayload extends CheckpointHandlingTimes {
|
||||
sequenceNo: number;
|
||||
kind?: TrainCheckpointKind;
|
||||
/** When the train was at the station; defaults to now. Past OK, future rejected. */
|
||||
@@ -923,7 +936,7 @@ export interface RecordCheckpointPayload {
|
||||
}
|
||||
|
||||
/** Edit an already-logged leg — pure correction, no side effects. */
|
||||
export interface UpdateCheckpointPayload {
|
||||
export interface UpdateCheckpointPayload extends CheckpointHandlingTimes {
|
||||
occurredAt?: string;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user