diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingDocumentsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingDocumentsPanel.tsx new file mode 100644 index 000000000..3c36f757e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingDocumentsPanel.tsx @@ -0,0 +1,146 @@ +import { useMemo } from "react"; +import { Box, Center, Group, Loader, Stack, Text } from "@mantine/core"; +import { FileText, FolderOpen } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import type { Freight } from "@edr/types"; + +import { bookingsService } from "@/services/bookings.service"; +import { downloadBookingFile } from "@/services/files.service"; +import { useFileViewer } from "@/hooks/useFileViewer"; +import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel"; +import { PhasedUploadedFileRow } from "@/components/contracts/PhasedUploadedFileRow"; +import { SectionCard } from "./SectionCard"; + +interface LabeledFile { + label: string; + file: { id: string; name: string }; +} + +/** + * Every document tied to a booking, in one tab: the customer/GL clearance + * documents, the customs workflow files (declaration/duty/transit/Djibouti), + * the duty-tax notice, and the final invoice + payment slip. All fetched from + * the booking's clearance view (the only endpoint that surfaces booking files), + * each with inline view + download. + */ +export function BookingDocumentsPanel({ bookingId }: { bookingId: string }) { + const { view, viewer } = useFileViewer(); + + const { data: clearance, isLoading, isError } = useQuery({ + queryKey: ["clearance", bookingId], + queryFn: () => bookingsService.getClearance(bookingId), + }); + + const onDownload = (f: { id: string; name: string }) => + void downloadBookingFile(f.id, f.name); + + // Uploaded customer + GL clearance documents (skip the not-yet-uploaded slots). + const clearanceDocs = useMemo< + Array<{ doc: Freight.ClearanceDocument; file: { id: string; name: string } }> + >( + () => + (clearance?.documents ?? []) + .filter((d) => d.file) + .map((d) => ({ doc: d, file: d.file! })), + [clearance], + ); + + const workflowFiles = useMemo( + () => (clearance?.workflowFiles ?? []).filter((f) => f.file), + [clearance], + ); + + // Duty notice + final invoice + payment slip — loose files that don't ride in + // the documents/workflow arrays. + const otherFiles = useMemo(() => { + const rows: LabeledFile[] = []; + const notice = clearance?.dutyAdvice?.noticeFile; + if (notice) rows.push({ label: "Duty & tax notice", file: notice }); + const inv = clearance?.finalInvoice; + if (inv?.invoiceFile) + rows.push({ label: `Final invoice · ${inv.invoiceNumber}`, file: inv.invoiceFile }); + if (inv?.slipFile) + rows.push({ label: "Final invoice payment slip", file: inv.slipFile }); + return rows; + }, [clearance]); + + if (isLoading) { + return ( +
+ + + Loading documents… + +
+ ); + } + + const hasAny = + clearanceDocs.length > 0 || workflowFiles.length > 0 || otherFiles.length > 0; + + if (isError || !hasAny) { + return ( + +
+ + + No documents yet + + {isError + ? "Couldn’t load this booking’s documents." + : "Documents attached to this booking will appear here as they’re uploaded."} + + +
+
+ ); + } + + return ( + + {clearanceDocs.length > 0 && ( + + + {clearanceDocs.map(({ doc, file }) => ( + + ))} + + + )} + + {workflowFiles.length > 0 && ( + + )} + + {otherFiles.length > 0 && ( + + + {otherFiles.map((row) => ( + + ))} + + + )} + + {viewer} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index 003f3d4de..b948cc5dc 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -1,6 +1,7 @@ export * from "./booking-detail.styles"; export * from "./SectionCard"; export * from "./ClearanceReviewSection"; +export * from "./BookingDocumentsPanel"; export * from "./ContractOrdersPanel"; export * from "./MetricTile"; export * from "./BookingDetailToolbar"; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx new file mode 100644 index 000000000..0271d199c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx @@ -0,0 +1,557 @@ +import { useMemo } from "react"; +import { + Box, + Group, + Paper, + Progress, + Stack, + Text, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { + Boxes, + CheckCircle2, + Clock, + Container, + Crown, + Hourglass, + Layers, + ListOrdered, + TrainFront, + Trophy, + XCircle, +} from "lucide-react"; +import { CountdownTimer } from "@edr/ui-common"; + +import type { + BatchBoardBookingDetail, + BatchBoardBookingState, + BatchBoardScheduleDetail, +} from "@/types/trainScheduling"; +import { WindowPhasePill } from "./batchVisuals"; + +/** + * Priority Tracking tab — live, glanceable ranking of every booking on this + * schedule in the exact order the batch engine boards them (government first, + * then rule-engine priority score, then oldest). Bookings above the train's + * wagon-capacity line render as "selected" (green), below it as the waiting + * list; during the PAYMENT phase selected bookings show a live pay-window + * countdown. Purely presentational — data comes from the batch-board detail + * response the page already polls (+ socket-invalidates). + */ + +type Props = { + data: BatchBoardScheduleDetail; + bookings: BatchBoardBookingDetail[]; +}; + +const STATE_STYLE: Record< + BatchBoardBookingState, + { label: string; color: string; icon: typeof CheckCircle2 } +> = { + ALLOCATED: { label: "Allocated", color: "edr-green", icon: CheckCircle2 }, + SELECTED_FOR_BATCH: { label: "Selected · pay now", color: "orange", icon: Clock }, + READY: { label: "Ready", color: "teal", icon: Hourglass }, + WAITING: { label: "Paid · waiting slot", color: "blue", icon: Hourglass }, + PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass }, + EXPIRED: { label: "Expired", color: "red", icon: XCircle }, +}; + +/** States that occupy a wagon slot on this train (i.e. are "in" the batch). */ +const OCCUPIES_SLOT: BatchBoardBookingState[] = [ + "ALLOCATED", + "SELECTED_FOR_BATCH", + "WAITING", +]; + +const cardVar = (color: string, shade: number) => + `var(--mantine-color-${color}-${shade})`; + +/** Highest score across the ranked pool → used to scale the priority mini-bar. */ +function maxScore(bookings: BatchBoardBookingDetail[]): number { + return bookings.reduce((m, b) => Math.max(m, b.priorityScore ?? 0), 0); +} + +function FreightIcon({ type }: { type: string | null }) { + const Icon = type === "BULK" ? Boxes : Container; + return ( + + + + + + ); +} + +/** One ranked booking row rendered as a card, colored by its batch state. */ +function RankedCard({ + rank, + booking, + scoreMax, + phase, + isPayPhase, +}: { + rank: number; + booking: BatchBoardBookingDetail; + scoreMax: number; + phase: string | null; + isPayPhase: boolean; +}) { + const style = STATE_STYLE[booking.state]; + const Icon = style.icon; + const selected = booking.state === "SELECTED_FOR_BATCH"; + const allocated = booking.state === "ALLOCATED"; + const expired = booking.state === "EXPIRED"; + // Green surface for the winners (allocated + selected); muted for the rest. + const surfaceColor = allocated + ? "edr-green" + : selected + ? "edr-green" + : expired + ? "red" + : "gray"; + const scorePct = + scoreMax > 0 ? Math.max(4, Math.round((booking.priorityScore / scoreMax) * 100)) : 0; + + return ( + + + + {/* Rank medallion */} + + {booking.isGovernment ? ( + + ) : ( + + {rank} + + )} + + + + + + {booking.reference} + + + {booking.isGovernment ? ( + + + + + + ) : null} + + + {booking.company} + + + + + + {/* Priority score with a mini strength bar */} + + + + + + {booking.priorityScore} + + + + + + + {/* Wagons */} + + + + {booking.wagons}w + + + + {/* State chip / pay countdown */} + + {selected && isPayPhase && booking.paymentDeadline ? ( + + ) : ( + + + + + + {style.label} + + + )} + + + + {/* phase hint only used for the a11y title; keeps `phase` referenced */} + + + ); +} + +/** The capacity cut line drawn between "in the batch" and "waiting list". */ +function CapacityDivider({ used, max }: { used: number; max: number | null }) { + const full = max != null && used >= max; + return ( + + + + + + + + Capacity line{max != null ? ` · ${used}/${max} wagons` : ` · ${used} wagons`} + {full ? " · FULL" : ""} + + + + + ); +} + +export function PriorityTrackingTab({ data, bookings }: Props) { + const phase = data.windowPhase; + const isPayPhase = phase === "PAYMENT"; + + // Rank exactly as the batch engine does: government first, then priority score + // desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the + // backend uses). The board already returns them in this order, but re-sort + // defensively so the tab is correct even if the source order ever changes. + const ranked = useMemo(() => { + const time = (b: BatchBoardBookingDetail) => + b.fullyExecutedAt ? new Date(b.fullyExecutedAt).getTime() : Number.MAX_SAFE_INTEGER; + return [...bookings].sort((a, b) => { + if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1; + if (b.priorityScore !== a.priorityScore) return b.priorityScore - a.priorityScore; + return time(a) - time(b); + }); + }, [bookings]); + + const scoreMax = useMemo(() => maxScore(ranked), [ranked]); + // maxWagons is not on the board DTO (capacity is length/weight-based), so the + // capacity line shows the wagons currently committed rather than a hard cap. + const maxWagons: number | null = null; + + // Split the ranking at the capacity line: cumulative wagons of slot-occupying + // bookings (allocated + selected + paid-waiting) up to the train's wagon cap. + const capUsed = useMemo( + () => + ranked + .filter((b) => OCCUPIES_SLOT.includes(b.state)) + .reduce((sum, b) => sum + b.wagons, 0), + [ranked], + ); + + // Group for the lane layout. + const lanes = useMemo(() => { + const inBatch = ranked.filter((b) => OCCUPIES_SLOT.includes(b.state)); + const waiting = ranked.filter( + (b) => b.state === "READY" || b.state === "PENDING_CONTRACT", + ); + const expired = ranked.filter((b) => b.state === "EXPIRED"); + return { inBatch, waiting, expired }; + }, [ranked]); + + if (ranked.length === 0) { + return ( + + + + + + No bookings on this schedule yet. + + + ); + } + + let rankNo = 0; + + return ( + + {/* Header: phase + capacity meter */} + + + + + + + + Priority ranking + + Government first, then rule-engine score, then earliest booked. + + + + + {phase ? ( + + ) : null} + {isPayPhase && data.paymentPhaseEndsAt ? ( + + ) : phase === "DOC_REVIEW" && data.docReviewEndsAt ? ( + + ) : phase === "OPEN" && data.windowClosesAt ? ( + + ) : null} + + + + {/* Capacity meter */} + + + + Wagon capacity used + + + {data.capacity.allocatedWagons} allocated ·{" "} + {capUsed} in batch + + + + 0 + ? Math.min(100, (data.capacity.allocatedWagons / capUsed) * 100) + : 0 + } + color="edr-green" + /> + 0 + ? Math.min( + 100, + ((capUsed - data.capacity.allocatedWagons) / capUsed) * 100, + ) + : 0 + } + color="orange" + /> + + + + + {/* Phase banner explaining what's happening now */} + + + {/* IN THE BATCH (green winners) — ranked */} + {lanes.inBatch.length > 0 ? ( + + + + + + + In the batch{" "} + + ({lanes.inBatch.length}) + + + + {lanes.inBatch.map((b) => { + rankNo += 1; + return ( + + ); + })} + + ) : null} + + + + {/* WAITING LIST — ranked, below the line */} + {lanes.waiting.length > 0 ? ( + + + + + + + Waiting list{" "} + + ({lanes.waiting.length}) — next in line if a slot frees up + + + + {lanes.waiting.map((b) => { + rankNo += 1; + return ( + + ); + })} + + ) : null} + + {/* EXPIRED */} + {lanes.expired.length > 0 ? ( + + + + + + + Expired{" "} + + ({lanes.expired.length}) — missed the payment window + + + + {lanes.expired.map((b) => ( + + ))} + + ) : null} + + ); +} + +/** Contextual banner describing the current window phase in plain language. */ +function PhaseBanner({ phase }: { phase: string | null }) { + const meta: Record = { + OPEN: { + color: "edr-green", + icon: Clock, + text: "Booking window OPEN — new bookings are ranked live as they arrive and get accepted.", + }, + DOC_REVIEW: { + color: "yellow", + icon: Hourglass, + text: "Document review — staff accept/reject; un-accepted bookings expire when review ends, then the batch runs.", + }, + PAYMENT: { + color: "blue", + icon: Clock, + text: "Payment window — selected bookings must pay before their countdown ends; unpaid slots pass to the waiting list.", + }, + PRE_WINDOW: { + color: "gray", + icon: Hourglass, + text: "Window not open yet — bookings are pre-ranked and will compete when it opens.", + }, + CLOSED_FOR_DAY: { + color: "gray", + icon: Hourglass, + text: "Window closed for the day — reopens for the next cycle if the train isn't full.", + }, + DONE: { + color: "gray", + icon: CheckCircle2, + text: "Booking cycles finished for this train.", + }, + }; + const m = phase ? meta[phase] : null; + if (!m) return null; + const Icon = m.icon; + return ( + + + + + + + {m.text} + + + + ); +} + +export default PriorityTrackingTab; diff --git a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts index 50c68b185..e6075f06f 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts @@ -130,6 +130,13 @@ export function useBookingWindowSocket(enabled: boolean = true) { }, ); + // Refresh the batch-board DETAIL for the schedule that transitioned so the + // Priority Tracking tab reranks + updates its countdowns immediately (the + // detail is a different shape from the list — invalidate, don't patch). + void qc.invalidateQueries({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(event.scheduleId), + }); + // Always refresh the batch board (different shape, not patched). When the // schedule wasn't in any window list either, refresh those too so a newly // announced window surfaces. Both debounced — no per-push stampede. diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index d1830b45a..622773d66 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -1,6 +1,7 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { ArrowLeft, + FolderOpen, Layers, LayoutGrid, Milestone, @@ -37,6 +38,7 @@ import { BookingContractSummaryCard, BookingContainerUnitsCard, ClearanceReviewSection, + BookingDocumentsPanel, ContractOrdersPanel, } from "@/components/bookings/detail"; import { WarehouseInfoCard } from "@/components/warehouses"; @@ -142,14 +144,17 @@ export default function BookingRequestDetailPage() { // A general contract drives an "Orders" tab: each drawdown order spawns a // child booking that staff manage (clearance/approval) independently. const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT"; - const showTabs = showClearanceTab || isGeneralContract; + // The Documents tab is always available — every booking can accrue clearance, + // customs-workflow, invoice or notice files — so the tab bar always renders. const requestedTab = searchParams.get("tab"); const activeTab = requestedTab === "clearance" && showClearanceTab ? "clearance" : requestedTab === "orders" && isGeneralContract ? "orders" - : "overview"; + : requestedTab === "documents" + ? "documents" + : "overview"; const setActiveTab = (tab: string | null) => { const next = new URLSearchParams(searchParams); if (tab && tab !== "overview") next.set("tab", tab); @@ -187,10 +192,10 @@ export default function BookingRequestDetailPage() { )} - {/* LEFT — primary content, split into tabs to keep each view focused */} + {/* LEFT — primary content, split into tabs to keep each view focused. + The Documents tab is always present, so the tab bar always renders. */} - {showTabs ? ( - )} + } + > + Documents + @@ -238,10 +249,10 @@ export default function BookingRequestDetailPage() { /> )} + + + - ) : ( - - )} {/* RIGHT — sticky action / summary rail */} 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 de6ecfa9b..6daf79221 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -33,6 +33,7 @@ import { RefreshCw, Ruler, TrainFront, + Trophy, Weight, XCircle, } from "lucide-react"; @@ -55,6 +56,8 @@ import { WindowStatusPill, } from "@/components/trainScheduling/batchVisuals"; import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals"; +import { PriorityTrackingTab } from "@/components/trainScheduling/PriorityTrackingTab"; +import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket"; import { BookingsManager } from "./BookingsManager"; import { useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; @@ -578,9 +581,23 @@ export default function BatchScheduleDetailPage() { api.trainScheduling.batchBoardDetail.queryOptions({ input: { scheduleId: scheduleId ?? "" }, enabled: Boolean(scheduleId), - refetchInterval: 30_000, + // Poll fast while a window cycle is actively moving (open / doc-review / + // payment) so the priority ranking + pay countdowns stay live; back off to + // 30s once the cycle is idle (pre-window / closed / done). + refetchInterval: (query) => { + const phase = (query.state.data as BatchBoardScheduleDetail | undefined) + ?.windowPhase; + return phase === "OPEN" || + phase === "DOC_REVIEW" || + phase === "PAYMENT" + ? 5_000 + : 30_000; + }, }), ); + // Keep the board in sync with server-pushed window-phase transitions too + // (invalidates the batch-board list + patches window carousels). + useBookingWindowSocket(Boolean(scheduleId)); const runAllocation = useMutation( api.trainScheduling.runAllocation.mutationOptions(), ); @@ -735,6 +752,13 @@ export default function BatchScheduleDetailPage() { Overview + } + > + Priority Tracking{" "} + {allBookings.length > 0 && `(${allBookings.length})`} + Train Composition{" "} {scheduleDetailQuery.data?.trainSet?.wagons && @@ -1095,6 +1119,10 @@ export default function BatchScheduleDetailPage() { + + + + {scheduleDetailQuery.data && scheduleDetailQuery.data.trainSet ? ( diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 5e3ab1b0e..29190846d 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -225,6 +225,10 @@ export interface BatchBoardBooking { lengthMeters: number; paymentDeadline: string | null; state: BatchBoardBookingState; + /** Rule-engine priority score used to rank the batch (higher = boards first). */ + priorityScore: number; + /** CONTAINER | BULK — for the priority-tracking visuals. */ + freightType: string | null; } /**