mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 20:05:41 +00:00
feat: enhance booking management with export train selection and rescheduling functionality
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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(
|
||||
() => ({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user