From d6ae1ac18dd99d6cc551b18ec34eb151d6bc2a55 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 28 Jun 2026 22:23:51 +0000 Subject: [PATCH] implement BookingsManager component for managing batch bookings with search and filter functionality --- .../BatchScheduleDetailPage.tsx | 59 +- .../pages/trainScheduling/BookingsManager.tsx | 625 ++++++++++++++++++ .../TrainScheduleV2DetailPage.tsx | 25 +- .../portal/src/components/AppLayout.tsx | 22 +- .../src/pages/contracts/ContractsList.tsx | 74 ++- .../src/pages/contracts/NewShipmentPage.tsx | 15 +- .../contracts/contract-booking-action.ts | 16 +- .../src/pages/contracts/contract-ui.tsx | 16 +- 8 files changed, 791 insertions(+), 61 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/pages/trainScheduling/BookingsManager.tsx diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index d1f1e19a7..0ea7d107d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -55,6 +55,7 @@ import { WindowStatusPill, } from "@/components/trainScheduling/batchVisuals"; import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals"; +import { BookingsManager } from "./BookingsManager"; import { useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; import { useToast } from "@/hooks/use-toast"; @@ -482,18 +483,32 @@ export default function BatchScheduleDetailPage() { }), ); - // Batch bookings by state for the composition side panel (payment / expired lists). - const batchBookings = useMemo(() => { - if (!data) return { awaitingPayment: [], expired: [] }; - const all = [ + // Every booking on this schedule, flattened across windows + pending-contract, + // de-duplicated (a booking only appears once). Feeds the management table. + const allBookings = useMemo(() => { + if (!data) return [] as BatchBoardBookingDetail[]; + const merged = [ ...data.windows.flatMap((w) => w.bookings), ...data.pendingContract.bookings, ]; + const byId = new Map(); + for (const b of merged) if (!byId.has(b.id)) byId.set(b.id, b); + return [...byId.values()]; + }, [data]); + + // Batch bookings by state for the composition side panel (payment / expired lists). + const batchBookings = useMemo(() => { + const all = allBookings; return { awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"), expired: all.filter((b) => b.state === "EXPIRED"), }; - }, [data]); + }, [allBookings]); + + const bookingsReadOnly = useMemo( + () => ["DISPATCHED", "ARRIVED"].includes(data?.status ?? ""), + [data?.status], + ); // Group the flat window list into per-day sections (one per EAT calendar date). const dayGroups = useMemo(() => { @@ -822,6 +837,40 @@ export default function BatchScheduleDetailPage() { ) : null} + {/* Manage bookings — search, filter, remove / re-assign (bulk too) */} + + + + + + + Manage bookings + + Search and filter every booking on this train. Remove an + allocated booking to free its wagons, or re-assign one that + is not yet allocated — individually or in bulk. + + + + void refetch()} + readOnly={bookingsReadOnly} + /> + + {/* Batch windows */} + `${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`; + +const fmtDateTime = (iso: string | null) => + iso + ? new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "Africa/Addis_Ababa", + }).format(new Date(iso)) + : "—"; + +const initials = (name: string) => + name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((w) => w[0]) + .join("") + .toUpperCase() || "?"; + +const STATE_META: Record< + BatchBoardBookingState, + { label: string; color: string } +> = { + ALLOCATED: { label: "Allocated", color: "edr-green" }, + SELECTED_FOR_BATCH: { label: "Selected for batch", color: "orange" }, + READY: { label: "Ready for batch", color: "teal" }, + WAITING: { label: "Paid · waiting", color: "blue" }, + PENDING_CONTRACT: { label: "Pending contract", color: "gray" }, + EXPIRED: { label: "Expired", color: "red" }, +}; + +const ALLOC_META: Record< + BookingAllocationStatus, + { label: string; color: string } +> = { + ASSIGNED: { label: "Wagons assigned", color: "edr-green" }, + NOT_ATTEMPTED: { label: "Not allocated", color: "gray" }, + DEFERRED: { label: "Deferred", color: "orange" }, + FAILED: { label: "Allocation failed", color: "red" }, +}; + +const STATE_FILTERS = [ + { value: "ALL", label: "All states" }, + ...Object.entries(STATE_META).map(([value, m]) => ({ + value, + label: m.label, + })), +]; + +const ALLOC_FILTERS = [ + { value: "ALL", label: "All allocations" }, + ...Object.entries(ALLOC_META).map(([value, m]) => ({ + value, + label: m.label, + })), +]; + +export interface BookingsManagerProps { + scheduleId: string; + bookings: BatchBoardBookingDetail[]; + /** Re-pull the batch-board detail after a remove / re-assign mutation. */ + onChanged: () => void; + /** Read-only when the schedule can no longer be edited (dispatched / arrived). */ + readOnly?: boolean; +} + +/** + * Searchable, filterable, bulk-manageable booking table for the batch board. + * Staff can search by reference / customer, filter by batch state and wagon + * allocation status, and remove or re-assign bookings individually or in bulk. + * Wraps the shared DataTable; selection + actions are handled locally so the + * surrounding accordion / tab layout stays untouched. + */ +export function BookingsManager({ + scheduleId, + bookings, + onChanged, + readOnly = false, +}: BookingsManagerProps) { + const { toast } = useToast(); + const [query, setQuery] = useState(""); + const [stateFilter, setStateFilter] = useState("ALL"); + const [allocFilter, setAllocFilter] = useState("ALL"); + const [selected, setSelected] = useState>(new Set()); + const [confirm, setConfirm] = useState< + | { kind: "remove"; ids: string[]; label: string } + | { kind: "reassign"; ids: string[]; label: string } + | null + >(null); + + const unassign = useMutation( + api.trainScheduling.unassignBooking.mutationOptions(), + ); + const reassign = useMutation( + api.trainScheduling.assignUnassignedBooking.mutationOptions(), + ); + const busy = unassign.isPending || reassign.isPending; + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return bookings.filter((b) => { + if (stateFilter !== "ALL" && b.state !== stateFilter) return false; + if (allocFilter !== "ALL" && b.allocationStatus !== allocFilter) + return false; + if (!q) return true; + return ( + b.reference.toLowerCase().includes(q) || + b.company.toLowerCase().includes(q) + ); + }); + }, [bookings, query, stateFilter, allocFilter]); + + // Selection is bounded to whatever is currently visible (filtered) to avoid + // acting on rows the user can't see. + const visibleIds = useMemo(() => filtered.map((b) => b.id), [filtered]); + const selectedVisible = useMemo( + () => visibleIds.filter((id) => selected.has(id)), + [visibleIds, selected], + ); + const allVisibleSelected = + visibleIds.length > 0 && selectedVisible.length === visibleIds.length; + const someVisibleSelected = + selectedVisible.length > 0 && !allVisibleSelected; + + const toggleAll = () => + setSelected((prev) => { + const next = new Set(prev); + if (allVisibleSelected) { + visibleIds.forEach((id) => next.delete(id)); + } else { + visibleIds.forEach((id) => next.add(id)); + } + return next; + }); + + const toggleOne = (id: string) => + setSelected((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + + const clearSelection = () => setSelected(new Set()); + + const runRemove = async (ids: string[]) => { + let ok = 0; + let failed = 0; + // Sequential — each unassign mutates the schedule graph; parallel would race. + for (const id of ids) { + try { + await unassign.mutateAsync({ id: scheduleId, bookingId: id }); + ok += 1; + } catch { + failed += 1; + } + } + toast({ + title: "Bookings removed", + description: `${ok} removed${failed ? ` · ${failed} failed` : ""}`, + variant: failed ? "destructive" : "default", + }); + clearSelection(); + onChanged(); + }; + + const runReassign = async (ids: string[]) => { + let ok = 0; + let failed = 0; + for (const id of ids) { + try { + await reassign.mutateAsync({ id: scheduleId, bookingId: id }); + ok += 1; + } catch { + failed += 1; + } + } + toast({ + title: "Re-assignment run", + description: `${ok} re-assigned${failed ? ` · ${failed} failed` : ""}`, + variant: failed ? "destructive" : "default", + }); + clearSelection(); + onChanged(); + }; + + const confirmAction = async () => { + if (!confirm) return; + const ids = confirm.ids; + setConfirm(null); + if (confirm.kind === "remove") await runRemove(ids); + else await runReassign(ids); + }; + + const columns = useMemo[]>(() => { + const cols: ColumnDef[] = []; + + if (!readOnly) { + cols.push({ + id: "select", + meta: cellMeta, + header: () => ( + + ), + cell: ({ row }) => ( + toggleOne(row.original.id)} + /> + ), + }); + } + + cols.push( + { + id: "reference", + header: "Reference", + meta: cellMeta, + cell: ({ row }) => { + const b = row.original; + return ( + + + {b.reference} + + {b.isGovernment ? ( + + Gov + + ) : null} + + ); + }, + }, + { + id: "customer", + header: "Customer", + meta: cellMeta, + cell: ({ row }) => { + const b = row.original; + return ( + + + + {initials(b.company)} + + + + {b.company} + + + ); + }, + }, + { + id: "selectedForBatch", + header: "Selected for batch", + meta: cellMeta, + cell: ({ row }) => { + const b = row.original; + if (!b.selectedForBatchAt) + return ( + + — + + ); + return ( + <> + + {fmtDateTime(b.selectedForBatchAt)} EAT + + {b.paymentDeadline ? ( + + Pay by {fmtDateTime(b.paymentDeadline)} EAT + + ) : null} + + ); + }, + }, + { + id: "capacity", + header: "Capacity", + meta: cellMeta, + cell: ({ row }) => { + const b = row.original; + return ( + + + {b.wagons}w + + + {fmtTons(b.weightTons)} + + + ); + }, + }, + { + id: "state", + header: "Batch state", + meta: cellMeta, + cell: ({ row }) => { + const m = STATE_META[row.original.state]; + return ( + + {m.label} + + ); + }, + }, + { + id: "allocation", + header: "Wagon allocation", + meta: cellMeta, + cell: ({ row }) => { + const b = row.original; + const m = ALLOC_META[b.allocationStatus]; + const badge = ( + + {m.label} + + ); + if (!b.allocationIssue) return badge; + return ( + + + {badge} + + + + ); + }, + }, + ); + + if (!readOnly) { + cols.push({ + id: "actions", + header: "", + meta: cellMeta, + cell: ({ row }) => { + const b = row.original; + const isAssigned = b.allocationStatus === "ASSIGNED"; + return ( + + + + + + + + + } + disabled={isAssigned || busy} + onClick={() => + setConfirm({ + kind: "reassign", + ids: [b.id], + label: b.reference, + }) + } + > + Re-assign to wagons + + } + disabled={!isAssigned || busy} + onClick={() => + setConfirm({ + kind: "remove", + ids: [b.id], + label: b.reference, + }) + } + > + Remove from train + + + + + ); + }, + }); + } + + return cols; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + readOnly, + selected, + allVisibleSelected, + someVisibleSelected, + visibleIds, + busy, + ]); + + return ( + + {/* Toolbar: search + filters */} + + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + rightSection={ + query ? ( + setQuery("")} + aria-label="Clear search" + > + + + ) : null + } + /> + setAllocFilter(v ?? "ALL")} + aria-label="Filter by allocation" + /> + + {filtered.length} of {bookings.length} + + + + {/* Bulk action bar */} + {!readOnly && selectedVisible.length > 0 ? ( + + + + + {selectedVisible.length} selected + + + + + + + + + + ) : null} + + + + setConfirm(null)} + centered + radius="md" + title={ + confirm?.kind === "remove" + ? "Remove from train" + : "Re-assign to wagons" + } + > + + {confirm?.kind === "remove" + ? `Remove ${confirm?.label} from this train? Their wagon allocation will be released.` + : `Re-assign ${confirm?.label} to available wagons on this train?`} + + + + + + + + ); +} + +export default BookingsManager; diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 0b35b077e..621af9d32 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -52,7 +52,6 @@ import { PreviewSummary, ScheduleWarningsAlert, } from "@/components/trainScheduling/ScheduleWarningsAlert"; -import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util"; import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram"; import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid"; import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep"; @@ -137,26 +136,10 @@ export default function TrainScheduleV2DetailPage() { const containerUnits = previewResult?.containerUnits ?? []; const containerSlots = previewResult?.containerSlotSequenceNos ?? []; - const hasContainerStep = useMemo( - () => - shouldShowContainerPlacementStep({ - containerUnitCount: containerUnits.length, - scheduleFreightType: freightType, - bookingFreightTypes: [ - ...(schedule?.bookings ?? []).map((b) => b.freightType), - ...(eligibleQuery.data?.items ?? []) - .filter((item) => allSelectedIds.includes(item.id)) - .map((item) => item.freightType), - ], - }), - [ - allSelectedIds, - containerUnits.length, - eligibleQuery.data?.items, - freightType, - schedule?.bookings, - ], - ); + // Container-number placement step removed — the customer enters container + // numbers when booking, so scheduling skips straight from the wagon plan to + // finalize. Steps: select bookings → review wagons → finalize. + const hasContainerStep = false; const displayWagonPlan = useMemo(() => { const savedWagons = schedule?.trainSet?.wagons ?? []; diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 0718cc657..2d7fcd469 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -160,7 +160,6 @@ export function AppLayout({ const mutedColor = theme.colors["edr-muted"][6]; const textColor = theme.colors["edr-text"][6]; const accentColor = theme.colors["edr-accent"][6]; - const bgColor = theme.colors["edr-bg"][6]; const primaryColor = theme.colors["edr-green"][5]; const primaryDarkColor = theme.colors["edr-green"][7]; @@ -254,12 +253,9 @@ export function AppLayout({ {children} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx index e53b5869a..46ed36033 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx @@ -16,17 +16,24 @@ import { Title, } from "@mantine/core"; import { + CheckCircle2, ChevronLeft, ChevronRight, - CheckCircle2, + Eye, + FileSignature, FileStack, Inbox, Package, + PackagePlus, + PencilLine, Plus, + RotateCcw, Search, Timer, + Upload, Weight, X, + type LucideIcon, } from "lucide-react"; import { api } from "@/services/api"; @@ -53,23 +60,42 @@ function primaryRoute(contract: Freight.IContract) { }; } +// Customs (Path B) statuses where the customer still needs to upload / manage +// clearance docs. Once finalized (CLEARANCE_READY_FOR_BOOKING) the row falls +// through to the Book action instead. const PATH_B_CLEARANCE_STATUSES = [ "AWAITING_CLEARANCE_DOCUMENTS", "CLEARANCE_UNDER_REVIEW", - "CLEARANCE_READY_FOR_BOOKING", ]; +interface RowAction { + label: string; + to: string; + primary: boolean; + icon: LucideIcon; +} + /** The single most relevant next action for a customer's contract row. */ function getCustomerRowAction( contract: Freight.IContract, bookings: Freight.IBooking[], -): { label: string; to: string; primary: boolean } { +): RowAction { const id = contract.id; if (contract.status === "CONTRACT_READY") { - return { label: "View & sign", to: `/contracts/${id}/view`, primary: true }; + return { + label: "View & sign", + to: `/contracts/${id}/view`, + primary: true, + icon: FileSignature, + }; } if (contract.status === "CHANGES_REQUESTED") { - return { label: "Edit & resubmit", to: `/contracts/${id}`, primary: true }; + return { + label: "Edit & resubmit", + to: `/contracts/${id}`, + primary: true, + icon: PencilLine, + }; } if ( contract.customsClearingEnabled && @@ -79,16 +105,27 @@ function getCustomerRowAction( label: "Upload clearance", to: `/contracts/${id}/clearance`, primary: true, + icon: Upload, }; } const booking = getContractBookingAction(contract, bookings); if (booking.kind === "book") { - return { label: "Book shipment", to: booking.to, primary: true }; + return { + label: "Book shipment", + to: booking.to, + primary: true, + icon: PackagePlus, + }; } if (booking.kind === "rebook") { - return { label: "Re-book shipment", to: booking.to, primary: true }; + return { + label: "Re-book shipment", + to: booking.to, + primary: true, + icon: RotateCcw, + }; } - return { label: "View", to: `/contracts/${id}`, primary: false }; + return { label: "View", to: `/contracts/${id}`, primary: false, icon: Eye }; } export default function ContractsList() { @@ -338,6 +375,7 @@ export default function ContractsList() { verticalSpacing={14} horizontalSpacing={20} highlightOnHover + highlightOnHoverColor="#F4FBF8" styles={{ th: { fontSize: 11, @@ -348,6 +386,12 @@ export default function ContractsList() { background: "#F8FAFC", borderBottom: `1px solid ${BORDER}`, whiteSpace: "nowrap", + position: "sticky", + top: 0, + zIndex: 1, + }, + tr: { + transition: "background-color 120ms ease", }, td: { borderBottom: `1px solid ${BORDER}`, @@ -507,16 +551,26 @@ export default function ContractsList() { onClick={(e) => e.stopPropagation()} />