diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 586ef2f7c..41c8023f0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -32,8 +32,11 @@ import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; import { BookingNotifierService } from './booking-notifier.service'; -import { TrainSchedulingService } from './train-scheduling.service'; -import { eatDay } from './batch-window.util'; +import { + TrainSchedulingService, + effectiveWindowConfig, +} from './train-scheduling.service'; +import { eatDay, listConfigBookingWindows } from './batch-window.util'; import { BATCH_BOARD_STATUSES, BatchBoardQueryDto, @@ -167,6 +170,13 @@ export type BookingAllocationStatus = | "FAILED"; export interface BatchBoardBookingDetail extends BatchBoardBooking { + /** + * 0-based booking-window cycle this booking entered the pool in (derived from + * `fullyExecutedAt` against the schedule's window cycles). Ranking compares + * bookings within a cycle only — an earlier cycle always boards before a later + * one regardless of score. Null while the contract is still pending. + */ + windowCycleNo: number | null; fullyExecutedAt: string | null; selectedForBatchAt: string | null; allocationStatus: BookingAllocationStatus; @@ -1321,10 +1331,12 @@ export class BookingBatchService implements OnModuleInit { } } + const cycleOf = await this.windowCycleIndexer(s); const items: BatchBoardBookingDetail[] = bookings.map((b) => { const need = this.needFor(b, wagonDims); const alloc = allocationByBooking.get(b.id); return { + windowCycleNo: b.fullyExecutedAt ? cycleOf(b.fullyExecutedAt) : null, id: b.id, reference: b.reference ?? b.id.slice(0, 8), company: b.isGovernment @@ -1632,7 +1644,7 @@ export class BookingBatchService implements OnModuleInit { // Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill // must rank bulk bookings by their wagon-derived priority too. await this.recomputeBulkPriorities(pool, wagonDims); - this.resortPoolByPriority(pool); + this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule)); const units = this.groupConsolidatedPool(pool); let armed = false; let preempted = false; @@ -1847,6 +1859,9 @@ export class BookingBatchService implements OnModuleInit { armed: boolean; changed: boolean; }> = []; + // The day group shares one booking window (route+day grouping), so any + // member's window grid stands for the pool's cycle derivation. + let cycleSchedule: TrainSchedule | null = null; for (const id of scheduleIds) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); @@ -1857,6 +1872,7 @@ export class BookingBatchService implements OnModuleInit { ); continue; } + cycleSchedule ??= schedule; const limits = await this.capacityLimits(locomotive); await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); @@ -1877,7 +1893,10 @@ export class BookingBatchService implements OnModuleInit { // BULK bookings only get their real (wagon-derived) priority score now, at // batch time — stamp it and re-rank before the fill consumes the pool. await this.recomputeBulkPriorities(pool, wagonDims); - this.resortPoolByPriority(pool); + this.resortPoolByPriority( + pool, + cycleSchedule ? await this.windowCycleIndexer(cycleSchedule) : undefined, + ); // Consolidated partners collapse into one atomic unit (both-or-neither); a // consolidated booking whose partner isn't ready this cycle is skipped. const units = this.groupConsolidatedPool(pool); @@ -3257,11 +3276,66 @@ export class BookingBatchService implements OnModuleInit { } } - /** Restore the batch pool ordering (mirrors findBatchPool's ORDER BY) after scores changed. */ - private resortPoolByPriority(pool: Booking[]): void { + /** + * Maps a booking's pool-entry time (`fullyExecutedAt`) to the 0-based + * booking-window cycle it arrived in: the last window whose open is at/before + * the timestamp (a timestamp in the doc-review/payment gap belongs to the + * cycle that just closed). The cycle grid comes from the schedule's frozen + * window-rule snapshot — the exact windows the cycle engine runs. + */ + private async windowCycleIndexer( + schedule: TrainSchedule, + ): Promise<(ts: Date | null | undefined) => number> { + if (!schedule.scheduledDepartureDate) return () => 0; + let starts: number[]; + try { + const liveCfg = await this.trainSchedulingService.getWindowConfig(); + const cfg = effectiveWindowConfig(schedule, liveCfg); + const windows = listConfigBookingWindows( + schedule.direction, + schedule.scheduledDepartureDate, + { + ...cfg, + reopenGapMinutes: + schedule.ruleReopenDelayMinutes ?? + cfg.docReviewMinutes + cfg.paymentWindowMinutes, + }, + ); + starts = windows.map((w) => w.start.getTime()); + } catch (err) { + // A failed cycle derivation must never block the batch — fall back to one + // flat cycle (pure priority order, the old behaviour). + this.logger.warn( + `Window-cycle derivation failed for schedule ${schedule.id}: ` + + `${(err as Error).message}`, + ); + return () => 0; + } + return (ts) => { + if (!ts) return 0; + const ms = ts.getTime(); + let idx = 0; + for (let i = 0; i < starts.length; i += 1) { + if (ms >= starts[i]) idx = i; + } + return idx; + }; + } + + /** + * Rank the batch pool: government first, then WINDOW CYCLE (bookings compete + * only within the cycle they arrived in — an earlier cycle's booking always + * outranks a later cycle's, whatever the scores), then priority score, then + * oldest. `cycleOf` comes from {@link windowCycleIndexer}. + */ + private resortPoolByPriority( + pool: Booking[], + cycleOf: (ts: Date | null | undefined) => number = () => 0, + ): void { pool.sort( (a, b) => Number(b.isGovernment) - Number(a.isGovernment) || + cycleOf(a.fullyExecutedAt) - cycleOf(b.fullyExecutedAt) || Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) || (a.fullyExecutedAt?.getTime() ?? Infinity) - (b.fullyExecutedAt?.getTime() ?? Infinity) || diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx index 435baaf68..1bf9b55e7 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx @@ -99,16 +99,20 @@ export function ContractMilestonesTimeline({ }); } + // Every acted approval step, not just hazardous ones — this is the one + // place the approval-time record shows up in the page's main content + // (the sidebar's ContractApprovalStepsCard has the same times, but only + // there, and only while the chain is still actionable). for (const step of contract.approvalSteps ?? []) { - if (!(step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION)) continue; if (!step.actedAt) continue; + const hazard = step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION; items.push({ - key: `hazard-${step.id}`, + key: `step-${step.id}`, at: step.actedAt, - title: CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole, - detail: step.status === "REJECTED" ? "Rejected" : "Approved", - color: step.status === "REJECTED" ? "red" : "orange", - icon: Flame, + title: `${CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole} ${step.status === "REJECTED" ? "rejected" : "approved"}`, + detail: step.note ?? undefined, + color: step.status === "REJECTED" ? "red" : hazard ? "orange" : "edr-green", + icon: hazard ? Flame : ShieldCheck, }); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusBadge.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusBadge.tsx index c81758bae..068221400 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusBadge.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusBadge.tsx @@ -1,9 +1,10 @@ import { Badge, Group } from "@mantine/core"; -import { Repeat } from "lucide-react"; +import { Building2, Repeat, UserRound } from "lucide-react"; import { CONTRACT_STATUS_COLOR, CONTRACT_STATUS_STYLES, + contractCourt, } from "@/features/contracts/contract-status.config"; interface ContractStatusBadgeProps { @@ -69,3 +70,42 @@ export function ContractStatusBadge({ ); } + +/** Whose court the contract sits in: customer, EDR, or nobody ("—"). */ +export function ContractCourtBadge({ status }: { status: string }) { + const court = contractCourt(status); + if (!court) { + return ( + + — + + ); + } + const isCustomer = court === "customer"; + return ( + : + } + title={ + isCustomer + ? "Waiting on the customer to act" + : "Waiting on EDR staff to act" + } + style={{ + fontSize: "0.7rem", + letterSpacing: "0.05em", + display: "inline-flex", + whiteSpace: "nowrap", + }} + > + {isCustomer ? "With customer" : "With EDR"} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx index 23b654a55..8b5343f83 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx @@ -34,12 +34,13 @@ import type { } from "@/types/trainScheduling"; import { WindowPhasePill } from "./batchVisuals"; import { ForecastPanel } from "./ForecastPanel"; -import { forecastIsLive } from "./batchForecast"; +import { forecastIsLive, rankBookings } from "./batchForecast"; /** * 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 + * then window cycle — bookings compete only within their own cycle — 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 @@ -286,19 +287,11 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({ ); const showForecast = forecastAvailable && view === "forecast"; - // 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]); + // Rank exactly as the batch engine does: government first, then window cycle + // (bookings only compete within the cycle they arrived in — an earlier cycle + // boards before a later one regardless of score), then priority desc, then + // oldest. Shared with the forecast sim so both views agree. + const ranked = useMemo(() => rankBookings(bookings), [bookings]); const scoreMax = useMemo(() => maxScore(ranked), [ranked]); // Wagon-slot cap from the board DTO (derived from train length and the @@ -395,7 +388,8 @@ export const PriorityTrackingTab = memo(function PriorityTrackingTab({ Priority ranking - Government first, then rule-engine score, then earliest booked. + Government first, then booking window (earlier cycles board + first), then rule-engine score, then earliest booked. diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/batchForecast.ts b/apps/edr-freight-web/backoffice/src/components/trainScheduling/batchForecast.ts index f7365feda..c828798cb 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/batchForecast.ts +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/batchForecast.ts @@ -61,7 +61,12 @@ export interface ForecastResult { full: boolean; } -/** Engine rank order: government first, then priority desc, then oldest booked. */ +/** + * Engine rank order: government first, then window cycle asc (bookings compete + * only within the cycle they arrived in — earlier cycles board first no matter + * the score; pending-contract rows sink last), then priority desc, then oldest + * booked. + */ export function rankBookings( bookings: BatchBoardBookingDetail[], ): BatchBoardBookingDetail[] { @@ -69,8 +74,11 @@ export function rankBookings( b.fullyExecutedAt ? new Date(b.fullyExecutedAt).getTime() : Number.MAX_SAFE_INTEGER; + const cycle = (b: BatchBoardBookingDetail) => + b.windowCycleNo ?? Number.MAX_SAFE_INTEGER; return [...bookings].sort((a, b) => { if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1; + if (cycle(a) !== cycle(b)) return cycle(a) - cycle(b); if (b.priorityScore !== a.priorityScore) return b.priorityScore - a.priorityScore; return time(a) - time(b); diff --git a/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts b/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts index afbe4fc4a..9a719215b 100644 --- a/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts @@ -270,6 +270,41 @@ export const CONTRACT_STATUS_META: Record = { }, }; +/** Statuses where the next action sits with the customer (portal side). */ +const WITH_CUSTOMER_STATUSES = new Set([ + "DRAFT", + "PRICE_CHANGED_PENDING_CONFIRM", + "CHANGES_REQUESTED", + "CONTRACT_READY", // generated contract awaits the customer's signature + "AWAITING_CLEARANCE_DOCUMENTS", + "RENEWAL_DRAFT", +]); + +/** Statuses where the next action sits with EDR staff. */ +const WITH_EDR_STATUSES = new Set([ + "SUBMITTED", + "PENDING_APPROVAL", + "APPROVED", + "APPROVED_PENDING_SIGNATURE", + "SIGNED_CUSTOMER", + "CLEARANCE_UNDER_REVIEW", + "CLEARANCE_READY_FOR_BOOKING", + "RENEWAL_SUBMITTED", + "RENEWAL_PENDING_APPROVAL", +]); + +/** + * Whose court the contract is in. Null for states with no pending party + * (active, closed, rejected…). + */ +export function contractCourt( + status: ContractStatus | string, +): "customer" | "edr" | null { + if (WITH_CUSTOMER_STATUSES.has(status)) return "customer"; + if (WITH_EDR_STATUSES.has(status)) return "edr"; + return null; +} + export const CONTRACT_LIST_TABS = [ { key: "all", label: "All contracts", statuses: null as string[] | null }, { diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx index d31c60dd4..6a998d0a7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx @@ -9,6 +9,7 @@ import { TextInput, ThemeIcon, } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; import { useDebouncedValue } from "@mantine/hooks"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react"; @@ -16,6 +17,7 @@ import { useCallback, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; +import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { PageContainer, PageHeader } from "@/components/page"; import { bookingsService } from "@/services/bookings.service"; @@ -49,6 +51,34 @@ const BOOKING_STATUS_OPTIONS = [ { value: "CLEARANCE_READY", label: "Clearance ready" }, ]; +const TRADE_DIRECTION_OPTIONS = [ + { value: "IMPORT", label: "Import" }, + { value: "EXPORT", label: "Export" }, + { value: "DOMESTIC", label: "Domestic" }, +]; + +const FREIGHT_TYPE_OPTIONS = [ + { value: "CONTAINER", label: "Container" }, + { value: "BULK", label: "Bulk" }, +]; + +const OWNERSHIP_OPTIONS = [ + { value: "true", label: "Government" }, + { value: "false", label: "Private" }, +]; + +function startOfDayIso(d: Date): string { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x.toISOString(); +} + +function endOfDayIso(d: Date): string { + const x = new Date(d); + x.setHours(23, 59, 59, 999); + return x.toISOString(); +} + export default function ClearanceDocumentsPage() { const navigate = useNavigate(); const [query, setQuery] = useState(""); @@ -56,6 +86,11 @@ export default function ClearanceDocumentsPage() { const [bookingStatuses, setBookingStatuses] = useState( BOOKING_STATUS_OPTIONS[0].value, ); + const [directionFilter, setDirectionFilter] = useState(null); + const [freightTypeFilter, setFreightTypeFilter] = useState(null); + const [ownershipFilter, setOwnershipFilter] = useState(null); + const [createdFrom, setCreatedFrom] = useState(null); + const [createdTo, setCreatedTo] = useState(null); const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE }); const search = debouncedQuery.trim() || undefined; @@ -67,7 +102,18 @@ export default function ClearanceDocumentsPage() { const page = pagination.pageIndex + 1; const bookingsQuery = useQuery({ - queryKey: ["clearance-documents", "bookings", bookingStatuses, page, search], + queryKey: [ + "clearance-documents", + "bookings", + bookingStatuses, + directionFilter, + freightTypeFilter, + ownershipFilter, + createdFrom, + createdTo, + page, + search, + ], queryFn: () => // Self-clearance instances carry bookingType=ONE_TIME whatever their // contract kind, so customsClearingEnabled=false + the three per-booking @@ -78,6 +124,13 @@ export default function ClearanceDocumentsPage() { page, pageSize: PAGE_SIZE, search, + ...(directionFilter ? { tradeDirection: directionFilter } : {}), + ...(freightTypeFilter ? { freightType: freightTypeFilter } : {}), + ...(ownershipFilter + ? { isGovernment: ownershipFilter as "true" | "false" } + : {}), + ...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}), + ...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}), }), placeholderData: keepPreviousData, }); @@ -115,9 +168,18 @@ export default function ClearanceDocumentsPage() { { id: "contractRef", header: () => Contract, - cell: ({ row }) => ( - {row.original.contractReference ?? "—"} - ), + cell: ({ row }) => { + const b = row.original; + return b.contractId && b.contractReference ? ( + + ) : ( + + ); + }, }, { id: "shipment", @@ -238,6 +300,73 @@ export default function ClearanceDocumentsPage() { {total} record{total !== 1 ? "s" : ""} + + { + setFreightTypeFilter(v); + resetPage(); + }} + clearable + radius="lg" + style={{ minWidth: 140 }} + aria-label="Filter by freight type" + /> +