feat: enhance booking management with export train selection and rescheduling functionality

This commit is contained in:
marshal
2026-09-06 14:36:20 +00:00
parent 19145306c9
commit 3cfeec5433
8 changed files with 306 additions and 81 deletions

View File

@@ -23,44 +23,6 @@ import {
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;
@@ -83,9 +45,7 @@ export function OperationRescheduleModal({
const booking = detailQuery.data;
const mutations = useBookingMutations(bookingId);
const isExportRail =
booking?.tradeDirection === "EXPORT" &&
!isRoadServiceCode(booking.serviceType?.code);
const isExportRail = booking ? isExportRailBooking(booking) : false;
const [day, setDay] = useState<Date | null>(null);
const [trainId, setTrainId] = useState<string | null>(null);
@@ -132,15 +92,7 @@ export function OperationRescheduleModal({
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 ?? []).map(exportTrainOption),
[trainsQuery.data],
);
// A train belongs to one day: changing the day drops a pick from another day.

View File

@@ -1,11 +1,27 @@
import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import {
Alert,
Button,
Group,
Paper,
Select,
Stack,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Pencil, Send } from "lucide-react";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import toast from "react-hot-toast";
import { useBookingDetail } from "@/hooks/bookings/useBookings";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import {
eatDay,
exportTrainOption,
formatEatDay,
isExportRailBooking,
} from "@/features/bookings/shipmentDay";
export interface BookingChangesRequestedAlertProps {
bookingId: string;
@@ -27,8 +43,11 @@ export interface BookingChangesRequestedAlertProps {
*
* The customer cannot act on this — GL created the booking on their behalf — so
* the note and the way out both live here, on the page GL works from. Resubmit
* re-requests operation on the chosen shipment day; the server re-checks the day
* has a departure that can carry the cargo and refuses with the reason if not.
* re-requests operation on the chosen shipment day: only days with an open
* departure on the booking's route are selectable, and an export rail booking
* also picks the train it rides (the API refuses an export resubmit without
* one). The server re-checks the day and train and refuses with the reason if
* they no longer work.
*/
export function BookingChangesRequestedAlert({
bookingId,
@@ -39,16 +58,82 @@ export function BookingChangesRequestedAlert({
editHref,
onResubmitted,
}: BookingChangesRequestedAlertProps) {
const [day, setDay] = useState<Date | null>(
scheduledDate ? new Date(scheduledDate) : null,
// The chosen departure day, as an EAT day key (YYYY-MM-DD). Only days that
// actually have an open departure on the booking's route are offered.
const [dayKey, setDayKey] = useState<string | null>(
scheduledDate ? eatDay(scheduledDate) : null,
);
const [trainId, setTrainId] = useState<string | null>(null);
const [sending, setSending] = useState(false);
// The booking's route and direction decide which days are offered and
// whether a train has to be picked — fetched only when this user can resubmit.
const { data: booking } = useBookingDetail(
canResubmit ? bookingId : undefined,
);
const isExportRail = booking ? isExportRailBooking(booking) : false;
// Seed the train from the customer's / previous pick once the booking loads.
useEffect(() => {
if (booking?.trainScheduleSummary?.id) {
setTrainId((current) => current ?? booking.trainScheduleSummary!.id);
}
}, [booking]);
const daysQuery = useQuery({
...api.trainScheduling.availableDays.queryOptions({
input: {
originYardId: booking?.originYard?.id ?? null,
destinationYardId: booking?.destinationYard?.id ?? null,
},
}),
enabled:
canResubmit &&
Boolean(booking?.originYard?.id && booking?.destinationYard?.id),
});
const dayOptions = useMemo(
() =>
Array.from(new Set((daysQuery.data ?? []).map((d) => eatDay(d))))
.sort()
.map((key) => ({ value: key, label: formatEatDay(key) })),
[daysQuery.data],
);
// A previously held day that no longer has a departure is not offered — the
// select shows nothing until GL picks a real one.
const dayHasDeparture =
dayKey != null && dayOptions.some((o) => o.value === dayKey);
// Any instant inside the chosen EAT day; the API keys on the day.
const dayIso = dayKey ? `${dayKey}T12:00:00.000Z` : "";
const trainsQuery = useQuery({
...api.trainScheduling.exportTrains.queryOptions({
input: { bookingId, date: dayIso },
}),
enabled: canResubmit && isExportRail && dayHasDeparture,
});
const trainOptions = useMemo(
() => (trainsQuery.data ?? []).map(exportTrainOption),
[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 canSend = dayHasDeparture && (!isExportRail || Boolean(trainId));
const resubmit = async () => {
if (!day) return;
if (!canSend) return;
setSending(true);
try {
await bookingsService.proceedToOperation(bookingId, day.toISOString());
await bookingsService.proceedToOperation(
bookingId,
dayIso,
isExportRail && trainId ? trainId : undefined,
);
toast.success("Sent back to Operations for review");
onResubmitted?.();
} catch {
@@ -91,8 +176,9 @@ export function BookingChangesRequestedAlert({
)}
<Text size="sm">
This booking was created by GL Ethiopia, so the customer cannot fix it.
Make the correction Operations asked for, then send it back for review.{" "}
This booking was created by GL Ethiopia, so the customer cannot fix
it. Make the correction Operations asked for, then send it back for
review.{" "}
<Text
component={Link}
to={`/dashboard/bookings/${bookingId}/clearance`}
@@ -106,21 +192,54 @@ export function BookingChangesRequestedAlert({
{canResubmit ? (
<Group gap="sm" align="flex-end" wrap="wrap">
<DateInput
label="Shipment day"
description="Keep the day or pick another with an open departure"
value={day}
onChange={(v) => setDay(v ? new Date(v) : null)}
minDate={new Date()}
<Select
label="Departure day"
description="Existing departures on this route"
placeholder={
daysQuery.isLoading
? "Loading departures…"
: dayOptions.length
? "Select a departure day"
: "No open departure on this route"
}
data={dayOptions}
value={dayHasDeparture ? dayKey : null}
onChange={setDayKey}
disabled={daysQuery.isLoading || !dayOptions.length}
nothingFoundMessage="No open departure on this route"
comboboxProps={{ withinPortal: true }}
searchable
size="sm"
w={230}
/>
{isExportRail ? (
<Select
label="Export train"
description="The train this shipment rides"
placeholder={
!dayHasDeparture
? "Pick a day first"
: trainsQuery.isLoading
? "Loading trains…"
: "Select a train with room"
}
data={trainOptions}
value={trainId}
onChange={setTrainId}
disabled={!dayHasDeparture || trainsQuery.isLoading}
nothingFoundMessage="No export train on this day"
comboboxProps={{ withinPortal: true }}
searchable
size="sm"
w={340}
/>
) : null}
<Button
color="red"
radius="md"
size="sm"
loading={sending}
disabled={!day}
disabled={!canSend}
leftSection={<Send size={15} />}
onClick={() => void resubmit()}
>

View File

@@ -578,13 +578,39 @@ export default function GlCreateBookingForm() {
}
}, [bookingRequest, prefilled]);
// Rebook seed: copy the source booking's container lines once. (Bulk weight /
// item count isn't on the booking payload yet, so bulk rebooks fall through to
// the normal contract seed and GL re-enters the quantity.)
// Rebook seed: copy the source booking's real cargo once — container lines
// (with their per-unit details) or the bulk weight / item count / wagons.
useEffect(() => {
if (!copyFromBooking || prefilled) return;
const lines = copyFromBooking.bookingContainers ?? [];
if (!lines.length) return;
if (!lines.length) {
// Bulk booking: seed the quantity fields from what was actually booked.
// A break-bulk (per-item) booking stores the real tons in
// bulkTotalWeightTons and the item count in cargoTotalWeightVgm.
const perItem = copyFromBooking.bulkTotalWeightTons != null;
const tons = perItem
? copyFromBooking.bulkTotalWeightTons
: copyFromBooking.cargoTotalWeightVgm;
const items = perItem
? copyFromBooking.cargoTotalWeightVgm
: copyFromBooking.bulkItemCount;
if (!(Number(tons) > 0) && !(Number(items) > 0)) return;
setPrefilled(true);
if (copyFromBooking.cargoFreeText) {
setCargoDescription(copyFromBooking.cargoFreeText);
}
setBulk((b) => ({
...b,
cargoWeightTons: Number(tons) > 0 ? String(tons) : "",
itemCount: Number(items) > 0 ? String(items) : "",
requestedWagons:
copyFromBooking.bulkRequestedWagons != null &&
copyFromBooking.bulkRequestedWagons > 0
? String(copyFromBooking.bulkRequestedWagons)
: b.requestedWagons,
}));
return;
}
// The booking stores a numeric sizeFt (20) but the contract scope — and the
// create payload the server validates — uses its own size strings ("20ft").
// Seed with the scope's string so the rebook payload matches what a fresh
@@ -632,13 +658,23 @@ export default function GlCreateBookingForm() {
// Seed one shipment line per contracted size exactly once — same seeding the
// portal form does. Subsequent renders reuse the lines.
//
// Functional update on purpose: when the page is reached by an in-app click
// the contract AND the rebook source are both already cached, so this effect
// and the copyFrom seed above fire in the SAME commit. Reading
// `containerLines` from the closure here saw the pre-seed empty array and
// overwrote the copied lines with blank 0 × 20ft / 0 × 40ft rows (a hard
// refresh loaded them in sequence and looked fine). The updater sees the
// copied lines already queued and leaves them alone.
useEffect(() => {
if (!contract || prefilled || seededRef.current) return;
seededRef.current = true;
if (isContainer && containerSizes.length > 0 && containerLines.length === 0) {
setContainerLines(containerSizes.map(emptyLine));
if (isContainer && containerSizes.length > 0) {
setContainerLines((prev) =>
prev.length === 0 ? containerSizes.map(emptyLine) : prev,
);
}
}, [contract, prefilled, isContainer, containerSizes, containerLines.length]);
}, [contract, prefilled, isContainer, containerSizes]);
const quantities: GlShipmentQuantities = useMemo(
() => ({

View File

@@ -0,0 +1,88 @@
/**
* Shared helpers for the staff shipment-day / export-train pickers (the
* operation reschedule modal and the GL "returned for changes" resubmit).
*/
export const EAT_TIMEZONE = "Africa/Addis_Ababa";
/** YYYY-MM-DD of an instant in East Africa Time — the booking day key. */
export function eatDay(value: string | Date): string {
const date = typeof value === "string" ? new Date(value) : value;
return new Intl.DateTimeFormat("en-CA", {
timeZone: EAT_TIMEZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
}
/** "Mon, 07 Sep, 09:00" in EAT; "—" for a missing or invalid value. */
export 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_TIMEZONE,
weekday: "short",
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
/** "Wed, 09 Sep 2026" for a YYYY-MM-DD EAT day key. */
export function formatEatDay(dayKey: string): string {
const date = new Date(`${dayKey}T12:00:00.000Z`);
if (Number.isNaN(date.getTime())) return dayKey;
return new Intl.DateTimeFormat("en-GB", {
timeZone: EAT_TIMEZONE,
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
}).format(date);
}
/** Mirrors the API road-service rule: ServiceType.code ROAD, TRUCK, ROAD_*, TRUCK_* */
export function isRoadServiceCode(code: string | null | undefined): boolean {
const c = (code ?? "").toUpperCase();
return (
c === "ROAD" ||
c === "TRUCK" ||
c.startsWith("ROAD_") ||
c.startsWith("TRUCK_")
);
}
/** Export rail bookings are the only ones that carry a train pick. */
export function isExportRailBooking(booking: {
tradeDirection?: string | null;
serviceType?: { code?: string | null } | null;
}): boolean {
return (
booking.tradeDirection === "EXPORT" &&
!isRoadServiceCode(booking.serviceType?.code)
);
}
/** Select option for one export train; closed or too-small trains are disabled. */
export function exportTrainOption(t: {
scheduleId: string;
trainNumber: string | null;
trainName: string | null;
departure: string;
isOpen: boolean;
fits: boolean;
freeWagons: number;
neededWagons: number;
}): { value: string; label: string; disabled: boolean } {
return {
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,
};
}

View File

@@ -1329,6 +1329,19 @@ export default function TrainScheduleV2DetailPage() {
}
action={
<Group gap="sm" wrap="nowrap">
{/* Booking shut only by the close offset — the one closed state
staff can undo here, so it gets a visible button. */}
{schedule.closeOffsetReopen?.eligible ? (
<Button
variant="filled"
color="edr-green"
size="compact-sm"
leftSection={<Unlock size={14} />}
onClick={() => setCloseOffsetOpen(true)}
>
Reduce close offset
</Button>
) : null}
{/* Merging rewrites the consist, so it is offered only while
the departure can still be edited. */}
{canEditBookings ? (

View File

@@ -378,12 +378,26 @@ export default function TrainScheduleV2ListPage() {
},
{
id: "actions",
size: 210,
size: 330,
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => {
const schedule = row.original;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
{/* Booking shut only by the close offset: a visible button, since
this is the one closed state staff can fix from the board. */}
{schedule.closeOffsetReopen?.eligible ? (
<Button
variant="filled"
color="edr-green"
size="xs"
radius="md"
leftSection={<Unlock size={14} />}
onClick={() => setCloseOffsetId(schedule.id)}
>
Reduce offset
</Button>
) : null}
<Button
variant="light"
color="indigo"

View File

@@ -3246,10 +3246,10 @@ export const api = {
),
proceedToOperation: endpoint<
{ id: string; scheduledDate: string },
{ id: string; scheduledDate: string; trainScheduleId?: string },
BookingDetail
>("bookings", "proceedToOperation", ({ id, scheduledDate }) =>
bookingsService.proceedToOperation(id, scheduledDate),
>("bookings", "proceedToOperation", ({ id, scheduledDate, trainScheduleId }) =>
bookingsService.proceedToOperation(id, scheduledDate, trainScheduleId),
),
generateContract: endpoint<{ id: string }, BookingDetail>(

View File

@@ -355,8 +355,11 @@ export const bookingsService = {
* customer path uses the same endpoint from the portal; GL needs it here
* because a customs booking is GL's to fix, not the customer's.
*/
proceedToOperation: (id: string, scheduledDate: string) =>
postBooking<BookingDetail>(B.CLEARANCE_PROCEED(id), { scheduledDate }),
proceedToOperation: (id: string, scheduledDate: string, trainScheduleId?: string) =>
postBooking<BookingDetail>(B.CLEARANCE_PROCEED(id), {
scheduledDate,
...(trainScheduleId ? { trainScheduleId } : {}),
}),
/**
* Operations changes the shipment day (and, for export rail, the train) of a