Merge pull request #1422 from Tria-plc/freight_feature/usermanagement

feat: refine loading window validation and improve error messaging fo…
This commit is contained in:
Nathnael Wondisha
2026-08-27 08:26:44 +03:00
committed by GitHub
5 changed files with 51 additions and 92 deletions

View File

@@ -2902,27 +2902,17 @@ export class TrainSchedulingService {
schedule = reloaded;
}
}
// Loading is tracked per station: dispatching with cargo still to board at
// the origin marks it loaded (checklist + auto-load below), so the origin's
// loading time window must have been started first — same gate the
// per-booking load endpoint enforces.
const originBoarders = await this.unloadedOriginBoarderIds(
scheduleId,
schedule.originStationId,
);
const boardersToLoad = dto.loadedBookingIds
? originBoarders.filter((id) => new Set(dto.loadedBookingIds).has(id))
: originBoarders;
// Dispatch requires the origin's loading window to be COMPLETE: started
// and ended. Not started or still open both block — a train departs only
// after loading was formally opened and closed.
const originLoadingLog =
schedule.stationWorkLogs?.[schedule.originStationId]?.loading;
if (boardersToLoad.length && !originLoadingLog?.startedAt) {
if (!originLoadingLog?.startedAt) {
throw new BadRequestException(
'Start loading at the origin station before dispatching with cargo to load',
'Start (and end) the loading window at the origin station before dispatching',
);
}
// A train never departs mid-loading: once the origin's loading window was
// opened (or there is cargo to load), it must be ENDED before dispatch.
if ((boardersToLoad.length || originLoadingLog?.startedAt) && !originLoadingLog?.endedAt) {
if (!originLoadingLog?.endedAt) {
throw new BadRequestException(
'End the loading window at the origin station before dispatching',
);

View File

@@ -36,6 +36,13 @@ export function DoCollectionDateFields({
const outOfOrder =
Boolean(value.vesselArrival && value.doCollected) && !doDatesComplete(value);
const today = new Date();
today.setHours(0, 0, 0, 0);
const doMin =
value.vesselArrival && value.vesselArrival > today
? value.vesselArrival
: today;
return (
<Group grow align="flex-start" gap="sm" wrap="wrap">
<DateInput
@@ -45,7 +52,7 @@ export function DoCollectionDateFields({
onChange={(v) =>
onChange({ ...value, vesselArrival: v ? new Date(v) : null })
}
maxDate={new Date()}
minDate={today}
size="sm"
required
withAsterisk
@@ -57,8 +64,7 @@ export function DoCollectionDateFields({
onChange={(v) =>
onChange({ ...value, doCollected: v ? new Date(v) : null })
}
minDate={value.vesselArrival ?? undefined}
maxDate={new Date()}
minDate={doMin}
size="sm"
required
withAsterisk

View File

@@ -2055,9 +2055,10 @@ export default function GlCreateBookingForm() {
? bulkErrors.quantity
: undefined
}
onChange={(e) =>
setBulk((b) => ({ ...b, cargoWeightTons: e.currentTarget.value }))
}
onChange={(e) => {
const value = e.currentTarget.value;
setBulk((b) => ({ ...b, cargoWeightTons: value }));
}}
radius={10}
styles={fieldStyles}
/>
@@ -2074,9 +2075,10 @@ export default function GlCreateBookingForm() {
? bulkErrors.quantity
: undefined
}
onChange={(e) =>
setBulk((b) => ({ ...b, itemCount: e.currentTarget.value }))
}
onChange={(e) => {
const value = e.currentTarget.value;
setBulk((b) => ({ ...b, itemCount: value }));
}}
radius={10}
styles={fieldStyles}
/>
@@ -2091,12 +2093,10 @@ export default function GlCreateBookingForm() {
step={1}
value={bulk.requestedWagons}
error={showErrors ? bulkErrors.wagons : undefined}
onChange={(e) =>
setBulk((b) => ({
...b,
requestedWagons: e.currentTarget.value,
}))
}
onChange={(e) => {
const value = e.currentTarget.value;
setBulk((b) => ({ ...b, requestedWagons: value }));
}}
radius={10}
styles={fieldStyles}
/>
@@ -2110,12 +2110,10 @@ export default function GlCreateBookingForm() {
step={1}
value={bulk.hazardousQuantity}
error={showErrors ? bulkErrors.hazardous : undefined}
onChange={(e) =>
setBulk((b) => ({
...b,
hazardousQuantity: e.currentTarget.value,
}))
}
onChange={(e) => {
const value = e.currentTarget.value;
setBulk((b) => ({ ...b, hazardousQuantity: value }));
}}
radius={10}
styles={fieldStyles}
/>
@@ -2129,12 +2127,10 @@ export default function GlCreateBookingForm() {
step={1}
value={bulk.reeferQuantity}
error={showErrors ? bulkErrors.reefer : undefined}
onChange={(e) =>
setBulk((b) => ({
...b,
reeferQuantity: e.currentTarget.value,
}))
}
onChange={(e) => {
const value = e.currentTarget.value;
setBulk((b) => ({ ...b, reeferQuantity: value }));
}}
radius={10}
styles={fieldStyles}
/>

View File

@@ -1,4 +1,4 @@
import { Button, Divider, Group, Modal, SimpleGrid, Stack, Text, Textarea } from "@mantine/core";
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useMediaQuery } from "@mantine/hooks";
import { useEffect, useState } from "react";
@@ -67,6 +67,10 @@ export function CheckpointTimeModal({
const isSmallScreen = useMediaQuery("(max-width: 48em)");
const [at, setAt] = useState<Date | null>(null);
const [note, setNote] = useState("");
// The four station-work stamps are no longer edited HERE — the track page's
// "Loading & unloading windows" section owns start/end with its own
// permissions. The modal still carries any existing stamps through
// unchanged on submit, so editing a checkpoint never wipes them.
const [handling, setHandling] = useState<HandlingState>(EMPTY_HANDLING);
useEffect(() => {
if (!opened) return;
@@ -121,34 +125,6 @@ export function CheckpointTimeModal({
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"

View File

@@ -500,15 +500,10 @@ export default function TrainScheduleV2DetailPage() {
: undefined;
const originLoadingStarted = Boolean(originLoadingLog?.startedAt);
const originLoadingEnded = Boolean(originLoadingLog?.endedAt);
const dispatchBoardersKept = pendingOriginBoarders.some(
(b) => b.isGovernment || dispatchLoadedIds.has(b.id),
);
const dispatchNeedsLoadingStart = dispatchBoardersKept && !originLoadingStarted;
// A train never departs mid-loading: once the window opened (or cargo is to
// board), it must be ENDED before dispatch — same gate the server enforces.
const dispatchNeedsLoadingEnd =
(dispatchBoardersKept || originLoadingStarted) && !originLoadingEnded;
const dispatchBlockedByLoading = dispatchNeedsLoadingStart || dispatchNeedsLoadingEnd;
// Dispatch requires the origin's loading window to be COMPLETE (started AND
// ended): not started → disabled, in progress → disabled, ended → active.
// Same gate the server enforces.
const dispatchBlockedByLoading = !originLoadingEnded;
const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
@@ -967,15 +962,11 @@ export default function TrainScheduleV2DetailPage() {
phase="loading"
log={originLoadingLog}
/>
{dispatchNeedsLoadingStart ? (
{dispatchBlockedByLoading ? (
<Text size="xs" c="dimmed">
Start loading before dispatching the ticked bookings are marked
loaded at dispatch, which needs an open loading window.
</Text>
) : dispatchNeedsLoadingEnd ? (
<Text size="xs" c="dimmed">
End the loading window before dispatching a train never departs
mid-loading.
{originLoadingStarted
? "End the loading window before dispatching — a train never departs mid-loading."
: "Start and end the loading window before dispatching — dispatch needs a completed loading window."}
</Text>
) : null}
</Stack>
@@ -1711,9 +1702,9 @@ export default function TrainScheduleV2DetailPage() {
</Button>
<Tooltip
label={
dispatchNeedsLoadingStart
? "Start loading at the origin station first — dispatch marks the ticked bookings loaded"
: "End the loading window at the origin station first — a train never departs mid-loading"
originLoadingStarted
? "End the loading window at the origin station first — a train never departs mid-loading"
: "Start and end the loading window at the origin station first — dispatch needs a completed loading window"
}
disabled={!dispatchBlockedByLoading}
>