mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-01 05:27:39 +00:00
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
190 lines
6.1 KiB
TypeScript
190 lines
6.1 KiB
TypeScript
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, 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,
|
|
onClose,
|
|
title,
|
|
icon,
|
|
description,
|
|
initialOccurredAt,
|
|
initialNote,
|
|
initialHandling,
|
|
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;
|
|
initialHandling?: CheckpointHandlingTimes | null;
|
|
submitLabel: string;
|
|
submitColor?: string;
|
|
loading: boolean;
|
|
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 ?? "");
|
|
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
|
|
opened={opened}
|
|
onClose={onClose}
|
|
centered
|
|
fullScreen={isSmallScreen}
|
|
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()}
|
|
dropdownType={isSmallScreen ? "modal" : "popover"}
|
|
popoverProps={{ withinPortal: true }}
|
|
valueFormat="DD MMM YYYY HH:mm"
|
|
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"
|
|
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(),
|
|
unloadingStartedAt: handling.unloadingStartedAt?.toISOString() ?? null,
|
|
unloadingCompletedAt: handling.unloadingCompletedAt?.toISOString() ?? null,
|
|
loadingStartedAt: handling.loadingStartedAt?.toISOString() ?? null,
|
|
loadingCompletedAt: handling.loadingCompletedAt?.toISOString() ?? null,
|
|
})
|
|
}
|
|
>
|
|
{submitLabel}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|