diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx index a52bcbfca..8fa1d5c46 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx @@ -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(null); const [trainId, setTrainId] = useState(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. diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx index f3b56a464..ff30c1ec2 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx @@ -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( - 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( + scheduledDate ? eatDay(scheduledDate) : null, ); + const [trainId, setTrainId] = useState(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({ )} - 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.{" "} - setDay(v ? new Date(v) : null)} - minDate={new Date()} + + ) : null} + ) : null} {/* Merging rewrites the consist, so it is offered only while the departure can still be edited. */} {canEditBookings ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index 4c8788de6..3570a3e37 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -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 ( 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 ? ( + + ) : null}