fix issues

This commit is contained in:
marshal
2026-09-06 13:50:07 +00:00
parent 75b75e3d4e
commit 19145306c9
26 changed files with 1716 additions and 14 deletions

View File

@@ -1,8 +1,10 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { ExternalLink, MoreHorizontal, Receipt } from "lucide-react";
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { OperationRescheduleModal } from "./OperationRescheduleModal";
import { useBookingActionDialog } from "./useBookingActionDialog";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
@@ -10,6 +12,7 @@ import {
isAllocateAction,
isClearanceNavAction,
isContractNavAction,
isRescheduleAction,
listRowHasActions,
type BookingActionContext,
} from "@/features/bookings/booking-actions.config";
@@ -44,6 +47,8 @@ export function BookingActionsMenu({
const flow = useBookingActionDialog(row.id, context);
const { actions, pendingAction, mutations } = flow;
// Day / train reschedule has its own modal (date + export train picker).
const [rescheduleOpen, setRescheduleOpen] = useState(false);
const goToContract = () =>
navigate(`/dashboard/booking-requests/${row.id}/contract`);
@@ -67,6 +72,8 @@ export function BookingActionsMenu({
goToClearanceTab();
} else if (isAllocateAction(action.id)) {
onAllocateBooking?.();
} else if (isRescheduleAction(action.id)) {
setRescheduleOpen(true);
} else {
flow.openAction(action);
}
@@ -109,6 +116,14 @@ export function BookingActionsMenu({
consolidationPartnerId={row.consolidationPartnerId}
consolidationPartnerReference={row.consolidationPartnerReference}
/>
<OperationRescheduleModal
bookingId={row.id}
opened={rescheduleOpen}
onClose={() => {
onSuppressRowClick?.();
setRescheduleOpen(false);
}}
/>
</>
);
}
@@ -183,6 +198,14 @@ export function BookingActionsMenu({
consolidationPartnerId={row.consolidationPartnerId}
consolidationPartnerReference={row.consolidationPartnerReference}
/>
<OperationRescheduleModal
bookingId={row.id}
opened={rescheduleOpen}
onClose={() => {
onSuppressRowClick?.();
setRescheduleOpen(false);
}}
/>
</Group>
);
}

View File

@@ -0,0 +1,303 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Group,
Loader,
Modal,
Select,
Stack,
Text,
Textarea,
ThemeIcon,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { CalendarClock, Info } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import {
useBookingDetail,
useBookingMutations,
} from "@/hooks/bookings/useBookings";
const EAT = "Africa/Addis_Ababa";
/** YYYY-MM-DD of an instant in East Africa Time — the booking day key. */
function eatDay(value: string | Date): string {
const date = typeof value === "string" ? new Date(value) : value;
return new Intl.DateTimeFormat("en-CA", {
timeZone: EAT,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
}
function formatEat(value: string | Date | null | undefined): string {
if (!value) return "—";
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("en-GB", {
timeZone: EAT,
weekday: "short",
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
/** Mirrors the API road-service rule: ServiceType.code ROAD, TRUCK, ROAD_*, TRUCK_* */
function isRoadServiceCode(code: string | null | undefined): boolean {
const c = (code ?? "").toUpperCase();
return (
c === "ROAD" ||
c === "TRUCK" ||
c.startsWith("ROAD_") ||
c.startsWith("TRUCK_")
);
}
export interface OperationRescheduleModalProps {
bookingId: string;
opened: boolean;
onClose: () => void;
}
/**
* Operations moves a pending operation request to another shipment day and,
* for export rail, another train — instead of returning it to the customer.
* The server re-runs the customer's own gates (open departure that day, wagon
* that can carry the cargo, export train with room) and refuses with the
* reason if the new day does not work.
*/
export function OperationRescheduleModal({
bookingId,
opened,
onClose,
}: OperationRescheduleModalProps) {
const detailQuery = useBookingDetail(opened ? bookingId : undefined);
const booking = detailQuery.data;
const mutations = useBookingMutations(bookingId);
const isExportRail =
booking?.tradeDirection === "EXPORT" &&
!isRoadServiceCode(booking.serviceType?.code);
const [day, setDay] = useState<Date | null>(null);
const [trainId, setTrainId] = useState<string | null>(null);
const [note, setNote] = useState("");
// Seed from the booking each time the modal opens: the current day and, for
// export, the train the customer picked (the detail's requested/allocated train).
useEffect(() => {
if (!opened || !booking) return;
setDay(booking.scheduledDate ? new Date(booking.scheduledDate) : null);
setTrainId(booking.trainScheduleSummary?.id ?? null);
setNote("");
}, [opened, booking]);
const dayKey = day ? eatDay(day) : null;
const currentDayKey = booking?.scheduledDate
? eatDay(booking.scheduledDate)
: null;
// Days with an open departure on the booking's route — a planning hint; the
// server still validates the pick.
const daysQuery = useQuery({
...api.trainScheduling.availableDays.queryOptions({
input: {
originYardId: booking?.originYard?.id ?? null,
destinationYardId: booking?.destinationYard?.id ?? null,
},
}),
enabled:
opened &&
Boolean(booking?.originYard?.id && booking?.destinationYard?.id),
});
const availableDays = useMemo(
() => new Set((daysQuery.data ?? []).map((d) => eatDay(d))),
[daysQuery.data],
);
const dayHasDeparture = dayKey ? availableDays.has(dayKey) : false;
// Export rail: the day's export trains with free space, so staff pick one.
const trainsQuery = useQuery({
...api.trainScheduling.exportTrains.queryOptions({
input: { bookingId, date: day ? day.toISOString() : "" },
}),
enabled: opened && isExportRail && Boolean(day),
});
const trainOptions = useMemo(
() =>
(trainsQuery.data ?? []).map((t) => ({
value: t.scheduleId,
label:
`${t.trainNumber ?? t.trainName ?? "Train"} · departs ${formatEat(t.departure)} · ` +
`${t.freeWagons} free / needs ${t.neededWagons}` +
(!t.isOpen ? " · closed" : !t.fits ? " · no room" : ""),
disabled: !t.isOpen || !t.fits,
})),
[trainsQuery.data],
);
// A train belongs to one day: changing the day drops a pick from another day.
useEffect(() => {
if (!isExportRail || !trainsQuery.data) return;
if (trainId && !trainsQuery.data.some((t) => t.scheduleId === trainId)) {
setTrainId(null);
}
}, [isExportRail, trainsQuery.data, trainId]);
const unchanged =
dayKey != null &&
dayKey === currentDayKey &&
(!isExportRail || trainId === (booking?.trainScheduleSummary?.id ?? null));
const canSave =
Boolean(day) && !unchanged && (!isExportRail || Boolean(trainId));
const handleSave = () => {
if (!day || !canSave) return;
mutations.rescheduleOperation.mutate(
{
scheduledDate: day.toISOString(),
...(isExportRail && trainId ? { trainScheduleId: trainId } : {}),
...(note.trim() ? { note: note.trim() } : {}),
},
{ onSuccess: () => onClose() },
);
};
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size="md"
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<CalendarClock size={18} />
</ThemeIcon>
<Box>
<Text fw={600} lh={1.2}>
Change train / shipment day
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{booking?.reference ?? "Booking"}
</Text>
</Box>
</Group>
}
>
{detailQuery.isLoading || !booking ? (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
) : (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Sets the shipment day
{isExportRail ? " and the export train " : " "}
for the customer, so nothing has to go back to them. The request
stays under review for the normal accept, and the customer is told
the new day.
{!isExportRail
? " Import and domestic trains are assigned by the batch engine on the chosen day."
: ""}
</Alert>
<Group gap="xs" wrap="wrap">
<Badge variant="light" color="gray">
Currently {currentDayKey ?? "no day"}
</Badge>
{booking.trainScheduleSummary ? (
<Badge variant="light" color="gray">
{booking.trainScheduleSummary.trainNumber ??
booking.trainScheduleSummary.reference ??
"train"}
{booking.trainScheduleSummary.isRequested ? " (requested)" : ""}
</Badge>
) : null}
</Group>
<DateInput
label="New shipment day"
description={
daysQuery.data && daysQuery.data.length
? "Days with an open departure on this route are selectable."
: "Pick the train departure day."
}
value={day}
onChange={(v) => setDay(v ? new Date(v) : null)}
minDate={new Date()}
excludeDate={
daysQuery.data && daysQuery.data.length
? (d) => !availableDays.has(eatDay(d))
: undefined
}
popoverProps={{ withinPortal: true }}
/>
{day &&
daysQuery.data &&
daysQuery.data.length &&
!dayHasDeparture ? (
<Text size="xs" c="red">
No open departure on this route for {dayKey}.
</Text>
) : null}
{isExportRail ? (
<Select
label="Export train"
placeholder={
!day
? "Pick a day first"
: trainsQuery.isLoading
? "Loading trains…"
: "Select a train with room"
}
data={trainOptions}
value={trainId}
onChange={setTrainId}
disabled={!day || trainsQuery.isLoading}
nothingFoundMessage="No export train on this day"
comboboxProps={{ withinPortal: true }}
searchable
/>
) : null}
<Textarea
label="Note to customer (optional)"
placeholder="Why the day is changing…"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end" mt="xs">
<Button variant="default" radius="md" onClick={onClose}>
Cancel
</Button>
<Button
radius="md"
color="edr-green"
leftSection={<CalendarClock size={16} />}
loading={mutations.rescheduleOperation.isPending}
disabled={!canSave}
onClick={handleSave}
>
Save new day
</Button>
</Group>
</Stack>
)}
</Modal>
);
}
export default OperationRescheduleModal;

View File

@@ -0,0 +1,259 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Divider,
Group,
Loader,
Modal,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { isAxiosError } from "axios";
import { Info, Unlock } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import DurationField from "@/components/trainScheduling/DurationField";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
const EAT = "Africa/Addis_Ababa";
/** "3 days" / "2 hr" / "45 min" for a minute count. */
function describeMinutes(minutes: number): string {
if (minutes <= 0) return "at departure";
if (minutes % 1440 === 0) {
const d = minutes / 1440;
return `${d} day${d === 1 ? "" : "s"}`;
}
if (minutes % 60 === 0) {
const h = minutes / 60;
return `${h} hr`;
}
return `${minutes} min`;
}
function formatEat(value: string | Date | null | undefined): string {
if (!value) return "—";
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("en-GB", {
timeZone: EAT,
weekday: "short",
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function parseError(error: unknown, fallback: string): string {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
return fallback;
}
export interface ReduceCloseOffsetModalProps {
scheduleId: string | null;
opened: boolean;
onClose: () => void;
/** Called after a successful save (e.g. to refetch a list). */
onSaved?: () => void;
}
/**
* Reopen a schedule whose booking shut ONLY because of its close offset, by
* shortening that offset (3 days → 1 day, 2 hours, …). The API decides
* eligibility (`closeOffsetReopen`); every other kind of closed window is
* explained and left alone.
*/
export default function ReduceCloseOffsetModal({
scheduleId,
opened,
onClose,
onSaved,
}: ReduceCloseOffsetModalProps) {
const { toast } = useToast();
const detailQuery = useQuery({
...api.trainScheduling.scheduleDetail.queryOptions({
input: { id: scheduleId ?? "" },
}),
enabled: opened && Boolean(scheduleId),
});
const schedule = detailQuery.data;
const reopen = schedule?.closeOffsetReopen ?? null;
const currentOffset = reopen?.offsetMinutes ?? null;
const save = useMutation(
api.trainScheduling.reduceScheduleCloseOffset.mutationOptions(),
);
// New offset, in minutes (what the API stores). Seeded to the current offset
// so the field reads as "shorten this", never as an empty box.
const [offsetMinutes, setOffsetMinutes] = useState<number | "">("");
useEffect(() => {
if (!opened) return;
setOffsetMinutes(currentOffset ?? "");
}, [opened, currentOffset]);
const departure = schedule?.scheduledDepartureDate
? new Date(schedule.scheduledDepartureDate)
: null;
const newCutoff = useMemo(() => {
if (!departure || offsetMinutes === "") return null;
const n = Number(offsetMinutes);
if (!Number.isFinite(n) || n < 0) return null;
return new Date(departure.getTime() - n * 60_000);
}, [departure, offsetMinutes]);
const value = offsetMinutes === "" ? NaN : Number(offsetMinutes);
const isShorter =
Number.isFinite(value) && currentOffset != null && value < currentOffset;
const cutoffInPast = newCutoff != null && newCutoff.getTime() <= Date.now();
const canSave =
reopen?.eligible === true && isShorter && value >= 0 && !cutoffInPast;
const handleSave = async () => {
if (!scheduleId || !canSave) return;
try {
await save.mutateAsync({
id: scheduleId,
payload: { closeOffsetMinutes: Math.round(value) },
});
toast({
title: "Booking window reopened",
description: `Booking now closes ${describeMinutes(Math.round(value))} before departure.`,
});
onSaved?.();
onClose();
} catch (err) {
toast({
title: "Could not reopen booking",
description: parseError(err, "The close offset was not changed."),
variant: "destructive",
});
}
};
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size="md"
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<Unlock size={18} />
</ThemeIcon>
<Box>
<Text fw={600} lh={1.2}>
Reopen booking shorten close offset
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{schedule?.route?.name ?? "This schedule"}
</Text>
</Box>
</Group>
}
>
{detailQuery.isLoading || !schedule ? (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
) : !reopen?.eligible ? (
<Alert
variant="light"
color="yellow"
icon={<Info size={16} />}
title="The close offset is not what closed this train"
>
{reopen?.reason ??
"This schedule cannot be reopened by shortening its close offset."}
</Alert>
) : (
<Stack gap="lg">
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Booking on this train closed early because of its close offset the
train itself has not left. Shorten the offset and the desk reopens
at its next opening (right away if it is open now) until the new
cutoff.
{schedule.direction !== "EXPORT"
? " Every train on this route departing the same day that is closed for the same reason reopens with it."
: ""}
</Alert>
<Box>
<Text size="sm" fw={600} mb={6}>
Current close
</Text>
<Group gap="xs" wrap="wrap">
<Badge variant="light" color="red">
Closes {describeMinutes(currentOffset ?? 0)} before departure
</Badge>
<Text size="xs" c="dimmed">
closed {formatEat(reopen.cutoffAt)} EAT · departs{" "}
{formatEat(departure)} EAT
</Text>
</Group>
</Box>
<Divider />
<DurationField
label="New close offset (before departure)"
description="Must be shorter than the current offset. 0 = booking stays open until the train departs."
value={offsetMinutes}
nativeUnit="minutes"
min={0}
onChange={setOffsetMinutes}
/>
{newCutoff ? (
<Text size="sm">
Booking would now close{" "}
<Text span fw={600}>
{formatEat(newCutoff)} EAT
</Text>
{cutoffInPast ? (
<Text span c="red">
{" "}
that is already in the past; shorten it further.
</Text>
) : !isShorter ? (
<Text span c="red">
{" "}
not shorter than the current offset.
</Text>
) : null}
</Text>
) : null}
<Group justify="flex-end" mt="xs">
<Button variant="default" radius="md" onClick={onClose}>
Cancel
</Button>
<Button
radius="md"
color="edr-green"
leftSection={<Unlock size={16} />}
loading={save.isPending}
disabled={!canSave}
onClick={() => void handleSave()}
>
Reopen booking
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -263,6 +263,8 @@ export const URL_CONSTANTS = {
`/bookings/${id}/clearance/export-release`,
// Re-request operation after Operations sent the booking back for changes.
CLEARANCE_PROCEED: (id: string) => `/bookings/${id}/clearance/proceed`,
// Operations changes a pending request's shipment day / train themselves.
OPERATION_RESCHEDULE: (id: string) => `/bookings/${id}/operation/reschedule`,
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
},
@@ -435,6 +437,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/booking-window`,
WINDOW_RULE: (id: string) =>
`/train-scheduling/schedules/${id}/window-rule`,
CLOSE_OFFSET: (id: string) =>
`/train-scheduling/schedules/${id}/close-offset`,
SCHEDULE_DATE: (id: string) =>
`/train-scheduling/schedules/${id}/schedule-date`,
MERGE_PREVIEW: (id: string, targetTrainId: string) =>

View File

@@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react";
import {
Ban,
CalendarClock,
Check,
MessageSquareWarning,
Play,
@@ -28,6 +29,7 @@ export type BookingActionId =
| "complete"
| "operationAccept"
| "operationRequestChanges"
| "operationReschedule"
| "cancel";
export type BookingActionInputKind =
@@ -128,6 +130,22 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [
},
];
/**
* Operations changes the shipment day / train themselves, instead of returning
* the request to the customer. Opens its own modal (day + export train picker),
* not the generic confirm dialog — see isRescheduleAction.
*/
const OPERATION_RESCHEDULE_ACTION: BookingActionDef = {
id: "operationReschedule",
label: "Change train / shipment day",
shortLabel: "Reschedule",
description: "Move the request to another shipment day or train yourself",
confirmTitle: "",
confirmDescription: "",
variant: "outline",
icon: CalendarClock,
};
// Marketing/operations review of a drawdown order's operation request.
const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
{
@@ -156,6 +174,7 @@ const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
inputLabel: "Message to customer",
inputPlaceholder: "Describe what needs to change…",
},
OPERATION_RESCHEDULE_ACTION,
];
const CANCEL_ACTION: BookingActionDef = {
@@ -202,6 +221,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
complete: FREIGHT_PERMS.bookings.operations,
operationAccept: FREIGHT_PERMS.bookings.operations,
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
operationReschedule: FREIGHT_PERMS.bookings.operations,
allocateBooking: FREIGHT_PERMS.trainScheduling.update,
cancel: FREIGHT_PERMS.bookings.cancel,
};
@@ -253,6 +273,11 @@ export function getBookingActions(
case "OPERATION_REQUEST_PENDING":
actions = withCancel(OPERATION_REVIEW_ACTIONS);
break;
case "OPERATION_CHANGES_REQUESTED":
// Waiting on the customer — but Operations may also resolve their own
// change request by setting the day / train directly.
actions = [OPERATION_RESCHEDULE_ACTION];
break;
case "PAID":
// Allocate is handled by the Operations "Ready to allocate" queue, not the
// per-booking action menu. Start transit was removed entirely. No per-row
@@ -300,6 +325,11 @@ export function isAllocateAction(id: BookingActionId): boolean {
return id === "allocateBooking";
}
/** Opens the day / train reschedule modal instead of the generic confirm dialog. */
export function isRescheduleAction(id: BookingActionId): boolean {
return id === "operationReschedule";
}
/** Opens the booking detail on the Clearance tab without a confirm dialog. */
export function isClearanceNavAction(id: BookingActionId): boolean {
return id === "reviewClearance";

View File

@@ -64,6 +64,17 @@ export function useBookingMutations(bookingId: string) {
onError: (error) => toast.error(parseApiError(error, "Failed to reject booking")),
});
const rescheduleOperation = useMutation({
mutationFn: (payload: {
scheduledDate: string;
trainScheduleId?: string;
note?: string;
}) => api.bookings.rescheduleOperation.call({ id: bookingId, ...payload }),
onSuccess: (data) => onSuccess(data, "Shipment day updated"),
onError: (error) =>
toast.error(parseApiError(error, "Failed to change the shipment day")),
});
const reviewOperation = useMutation({
mutationFn: (payload: {
decision: "ACCEPT" | "REQUEST_CHANGES";
@@ -162,6 +173,7 @@ export function useBookingMutations(bookingId: string) {
startTransit.isPending ||
complete.isPending ||
reviewOperation.isPending ||
rescheduleOperation.isPending ||
cancel.isPending;
return {
@@ -170,6 +182,7 @@ export function useBookingMutations(bookingId: string) {
requestChanges,
staffReject,
reviewOperation,
rescheduleOperation,
generateContract,
signContract,
payBooking,

View File

@@ -42,6 +42,7 @@ import {
Ruler,
Send,
Train,
Unlock,
Weight,
Workflow as WorkflowIcon,
Warehouse,
@@ -77,6 +78,7 @@ import { StationWorkControls } from "@/components/trainScheduling/StationWorkCon
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import ReduceCloseOffsetModal from "@/components/trainScheduling/ReduceCloseOffsetModal";
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
import { SwitchGovernmentBookingModal } from "@/components/trainScheduling/SwitchGovernmentBookingModal";
@@ -135,6 +137,8 @@ export default function TrainScheduleV2DetailPage() {
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
// Reopen a train whose booking shut only because of its close offset.
const [closeOffsetOpen, setCloseOffsetOpen] = useState(false);
const [mergeModalOpen, setMergeModalOpen] = useState(false);
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
const [gatepassReference, setGatepassReference] = useState("");
@@ -1441,6 +1445,15 @@ export default function TrainScheduleV2DetailPage() {
Window settings
</Menu.Item>
) : null}
{schedule.closeOffsetReopen?.eligible ? (
<Menu.Item
color="edr-green"
leftSection={<Unlock size={15} />}
onClick={() => setCloseOffsetOpen(true)}
>
Reopen booking (shorten close offset)
</Menu.Item>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Menu.Item onClick={() => setMaintenanceOpen(true)}>
Reschedule train
@@ -1820,6 +1833,13 @@ export default function TrainScheduleV2DetailPage() {
onSaved={() => void detailQuery.refetch()}
/>
<ReduceCloseOffsetModal
scheduleId={scheduleId ?? null}
opened={closeOffsetOpen}
onClose={() => setCloseOffsetOpen(false)}
onSaved={() => void detailQuery.refetch()}
/>
<LoadEmptyContainersModal
opened={loadEmptiesOpen}
onClose={() => setLoadEmptiesOpen(false)}

View File

@@ -37,6 +37,7 @@ import {
Send,
Table2,
Train,
Unlock,
Users,
Weight,
} from "lucide-react";
@@ -58,6 +59,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { directionColor, directionRowStyle } from "@/components/trainBuilder/trainStatus";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import ReduceCloseOffsetModal from "@/components/trainScheduling/ReduceCloseOffsetModal";
import CreateScheduleWindowFields, {
buildWindowRulePayload,
type WindowFormState,
@@ -145,6 +147,9 @@ export default function TrainScheduleV2ListPage() {
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
const [createOpen, setCreateOpen] = useState(false);
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
// "Shorten close offset": reopens a train whose booking shut only because
// of its close offset (the API flags exactly those rows).
const [closeOffsetId, setCloseOffsetId] = useState<string | null>(null);
// Dispatch is irreversible from this screen, so it goes through an explicit
// confirmation.
const [dispatchTarget, setDispatchTarget] = useState<TrainScheduleListItem | null>(null);
@@ -434,6 +439,15 @@ export default function TrainScheduleV2ListPage() {
Booking window settings
</Menu.Item>
) : null}
{schedule.closeOffsetReopen?.eligible ? (
<Menu.Item
color="edr-green"
leftSection={<Unlock size={15} />}
onClick={() => setCloseOffsetId(schedule.id)}
>
Reopen booking (shorten close offset)
</Menu.Item>
) : null}
{/* Start the run. Same transition as the detail page's
Dispatch button — that page also shows unassigned-wagon
and not-loaded warnings, so it stays the fuller surface. */}
@@ -805,6 +819,13 @@ export default function TrainScheduleV2ListPage() {
onSaved={() => void schedulesQuery.refetch()}
/>
<ReduceCloseOffsetModal
scheduleId={closeOffsetId}
opened={closeOffsetId != null}
onClose={() => setCloseOffsetId(null)}
onSaved={() => void schedulesQuery.refetch()}
/>
<EditScheduleDateModal
scheduleId={editDateSchedule?.id ?? null}
currentDate={editDateSchedule?.scheduleDate ?? null}

View File

@@ -91,6 +91,7 @@ import type {
TrainScheduleFilters,
TrainScheduleListFilters,
TrainScheduleListResponse,
ReduceScheduleCloseOffsetPayload,
UpdateScheduleWindowRulePayload,
TrainSchedulePreviewPayload,
TrainSchedulePreviewResponse,
@@ -713,6 +714,18 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
reduceScheduleCloseOffset: endpoint<
{ id: string; payload: ReduceScheduleCloseOffsetPayload },
TrainScheduleDetail
>(
"train-scheduling",
"reduce-schedule-close-offset",
({ id, payload }) =>
trainSchedulingService.reduceScheduleCloseOffset(id, payload),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
updateScheduleDate: endpoint<
{ id: string; scheduleDate: string },
TrainScheduleDetail
@@ -3220,6 +3233,18 @@ export const api = {
bookingsService.reviewOperation(id, decision, { note }),
),
rescheduleOperation: endpoint<
{
id: string;
scheduledDate: string;
trainScheduleId?: string;
note?: string;
},
BookingDetail
>("bookings", "rescheduleOperation", ({ id, ...payload }) =>
bookingsService.rescheduleOperation(id, payload),
),
proceedToOperation: endpoint<
{ id: string; scheduledDate: string },
BookingDetail

View File

@@ -358,6 +358,16 @@ export const bookingsService = {
proceedToOperation: (id: string, scheduledDate: string) =>
postBooking<BookingDetail>(B.CLEARANCE_PROCEED(id), { scheduledDate }),
/**
* Operations changes the shipment day (and, for export rail, the train) of a
* pending operation request on the customer's behalf — the booking stays at
* OPERATION_REQUEST_PENDING for the normal accept.
*/
rescheduleOperation: (
id: string,
payload: { scheduledDate: string; trainScheduleId?: string; note?: string },
) => postBooking<BookingDetail>(B.OPERATION_RESCHEDULE(id), payload),
generateContract: (id: string) =>
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),

View File

@@ -36,6 +36,7 @@ import type {
MarshallingStop,
ScheduleMergePreview,
TrainScheduleDetail,
ReduceScheduleCloseOffsetPayload,
UpdateScheduleWindowRulePayload,
TrainScheduleFilters,
TrainScheduleListFilters,
@@ -307,6 +308,17 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
reduceScheduleCloseOffset: async (
scheduleId: string,
payload: ReduceScheduleCloseOffsetPayload,
): Promise<TrainScheduleDetail> => {
const response = await client.patch<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.CLOSE_OFFSET(scheduleId),
payload,
);
return unwrap(response.data);
},
updateScheduleDate: async (
scheduleId: string,
scheduleDate: string,

View File

@@ -250,6 +250,9 @@ export interface TrainScheduleListItem {
totalLengthMeters: number;
bookingsCount: number;
status: TrainScheduleStatus | string;
windowPhase?: BookingWindowPhase | string | null;
/** Set when the API computed it: is this row shut only by its close offset? */
closeOffsetReopen?: CloseOffsetReopenInfo | null;
}
export type TrainScheduleSortField =
@@ -593,11 +596,35 @@ export interface ScheduleWindowRule {
windowDurationHours: number | null;
importWindowLeadDays: number | null;
exportBookingLeadHours: number | null;
/** Effective minutes-before-departure booking closes (import); null = at departure. */
importCloseOffsetMinutes?: number | null;
/** Effective minutes-before-departure booking closes (export); null = at departure. */
exportCloseOffsetMinutes?: number | null;
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */
docReviewMinutes: number;
paymentWindowMinutes: number;
}
/**
* Whether a schedule's booking shut ONLY because of its close offset — the one
* closed state staff can undo from the board by shortening that offset.
*/
export interface CloseOffsetReopenInfo {
eligible: boolean;
/** Why it is not eligible; null when it is. */
reason: string | null;
/** Minutes before departure booking currently closes; null without an offset. */
offsetMinutes: number | null;
/** ISO instant booking closed at (departure offset); null without an offset. */
cutoffAt: string | null;
}
/** Shorten a schedule's close offset so its booking window reopens. */
export interface ReduceScheduleCloseOffsetPayload {
/** New minutes-before-departure booking closes; 0 = close at departure. */
closeOffsetMinutes: number;
}
/** Editable window-rule override for one schedule; every field optional. */
export interface UpdateScheduleWindowRulePayload {
windowOpenHour?: number;
@@ -672,6 +699,8 @@ export interface TrainScheduleDetail {
paymentPhaseEndsAt?: string | null;
/** Booking-window rule snapshot — prefills the per-schedule settings editor. */
windowRule?: ScheduleWindowRule | null;
/** Is booking shut only by the close offset? Drives "shorten close offset". */
closeOffsetReopen?: CloseOffsetReopenInfo | null;
route?: {
id: string;
name: string;