mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 08:25:43 +00:00
fix issues
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user