From e29e3ff8371b692f36e634702b7f0750c49b6c94 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 10 Aug 2026 14:02:13 +0000 Subject: [PATCH 1/6] fix(freight-backoffice): render wagon transfer reason as sanitized HTML in a modal --- .../modules/trains/train-builder.service.ts | 16 ++++- .../src/pages/fleet/FleetResourcePage.tsx | 18 +++++- .../src/pages/fleet/config/resources.ts | 9 ++- .../src/pages/wagons/TransferHistoryPanel.tsx | 5 +- .../src/pages/wagons/WagonTransfersPage.tsx | 61 +++++++++++++++++-- .../src/pages/wagons/wagon-transfer-ui.tsx | 10 +++ 6 files changed, 108 insertions(+), 11 deletions(-) diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index fbb5301fb..9da3cecf0 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -510,6 +510,8 @@ export class TrainBuilderService { trainId: null, sequenceNumber: null, status: WagonStatus.Available, + importTrainNumber: null, + exportTrainNumber: null, }); await this.resequenceWagons(manager, train.id); await this.syncLiveScheduleAfterConsistChange( @@ -544,6 +546,8 @@ export class TrainBuilderService { trainId: null, sequenceNumber: null, status: WagonStatus.Maintenance, + importTrainNumber: null, + exportTrainNumber: null, }); // Audit row: which train it came off and when. The wagon does not change // yard here, so from/to are the same — the ledger is the wagon's history @@ -761,7 +765,13 @@ export class TrainBuilderService { .getRepository(Wagon) .update( { trainId: train.id }, - { trainId: null, sequenceNumber: null, status: WagonStatus.Available }, + { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Available, + importTrainNumber: null, + exportTrainNumber: null, + }, ); await manager.getRepository(TrainLocomotive).delete({ trainId: train.id }); await manager.getRepository(Train).remove(train); @@ -1021,6 +1031,10 @@ export class TrainBuilderService { trainId: train.id, sequenceNumber: sequence, status: WagonStatus.Assigned, + // Wagon inherits the train's run numbers on coupling — no per-wagon + // number entry, they ride whatever numbers the train was built with. + importTrainNumber: train.importTrainNumber, + exportTrainNumber: train.exportTrainNumber, }); } return toAttach; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 6bea10a8c..06d9a20ab 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -102,6 +102,7 @@ const FleetResourcePage = () => { const currentYardId = listFilterValues.currentYardId; const availability = listFilterValues.availability; const trainNumber = listFilterValues.trainNumber; + const trainId = listFilterValues.trainId; if (status && status !== "ALL") { (filters as { status?: string }).status = status; } @@ -114,6 +115,9 @@ const FleetResourcePage = () => { if (trainNumber && trainNumber !== "ALL") { (filters as { trainNumber?: string }).trainNumber = trainNumber; } + if (trainId && trainId !== "ALL") { + filters.trainId = trainId; + } // Wagons only: narrow the fleet to one wagon type (the API filters on it). const wagonTypeId = listFilterValues.wagonTypeId; if (wagonTypeId && wagonTypeId !== "ALL") { @@ -191,6 +195,11 @@ const FleetResourcePage = () => { const { data: drivers = [] } = useQuery( api.fleet.list.queryOptions({ input: { slug: "drivers" } }), ); + // Wagons-only: "Train" list filter needs every train's code to pick from. + const { data: trains = [], isLoading: trainsLoading } = useQuery({ + ...api.trains.list.queryOptions(), + enabled: slug === "wagons", + }); useEffect(() => { setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); @@ -247,6 +256,9 @@ const FleetResourcePage = () => { const yardOpts = (yards as Array<{ id: string; label?: string; code?: string }>).map( (y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }), ); + const trainOpts = (trains as Array<{ id: string; code: string; trainName?: string | null }>).map( + (t) => ({ value: t.id, label: t.trainName ? `${t.code} - ${t.trainName}` : t.code }), + ); // Carries capacity + trailer configuration so picking a truck type can // pre-fill the vehicle's capacity and drop the trailer plate on a rigid type. @@ -274,8 +286,9 @@ const FleetResourcePage = () => { wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts], containers: containerOpts, yards: yardOpts, + trains: trainOpts, }; - }, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards]); + }, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards, trains]); const listFilterSelects = useMemo(() => { if (!config?.listFilters?.length) return null; @@ -327,7 +340,8 @@ const FleetResourcePage = () => { truckTypesLoading || wagonsLoading || containersLoading || - yardsLoading; + yardsLoading || + trainsLoading; const filteredRows = useMemo(() => { if (!config) return allRows; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index 9ba826e6f..11ca236be 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -33,7 +33,8 @@ export type FleetDynamicOptions = | "truckTypes" | "wagons" | "containers" - | "yards"; + | "yards" + | "trains"; /** * A dynamic select option that can carry the record it came from. Picking a @@ -324,6 +325,12 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ allLabel: "All trains", options: TRAIN_RUN_FILTER_OPTIONS, }, + { + key: "trainId", + label: "Train", + allLabel: "All trains", + dynamicOptions: "trains", + }, ], cardTitleKey: "wagonNumber", cardSubtitleKey: "currentYard", diff --git a/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx b/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx index dff9cdaa3..807745b73 100644 --- a/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/wagons/TransferHistoryPanel.tsx @@ -35,6 +35,7 @@ import { STATUS_META, TransferProgress, TransferStatusBadge, + stripHtmlToText, wagonTypeLabel, yardLabel, } from "./wagon-transfer-ui"; @@ -119,9 +120,9 @@ function RequestItem({ request }: { request: WagonTransferRequest }) { {wagonTypeLabel(request.wagonType)} - {request.reason ? ( + {stripHtmlToText(request.reason) ? ( - {request.reason} + {stripHtmlToText(request.reason)} ) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx index 95705e964..1a08b5c54 100644 --- a/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/wagons/WagonTransfersPage.tsx @@ -10,6 +10,7 @@ import { Tabs, Text, TextInput, + UnstyledButton, } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; import { useQuery } from "@tanstack/react-query"; @@ -31,6 +32,7 @@ import { useMutation } from "@tanstack/react-query"; import { useAuth } from "@/auth/useAuth"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { sanitizeHtml } from "@/shared/lib/sanitize"; import { api } from "@/services/api"; import type { TransferRequestListFilter, @@ -55,6 +57,7 @@ import { fmtDateTime, isOpenRequest, outstandingOn, + stripHtmlToText, wagonTypeLabel, yardLabel, } from "./wagon-transfer-ui"; @@ -108,6 +111,9 @@ export default function WagonTransfersPage() { const [closingShort, setClosingShort] = useState( null, ); + const [viewingReason, setViewingReason] = useState( + null, + ); const filter: TransferRequestListFilter = useMemo( () => ({ @@ -197,11 +203,29 @@ export default function WagonTransfersPage() { { id: "reason", header: () => Reason, - cell: ({ row }) => ( - - {row.original.reason || "—"} - - ), + cell: ({ row }) => { + const text = stripHtmlToText(row.original.reason); + return text ? ( + setViewingReason(row.original)} + data-stop-row-click + > + + {text} + + + ) : ( + + — + + ); + }, }, { id: "filed", @@ -522,6 +546,33 @@ export default function WagonTransfersPage() { )} + setViewingReason(null)} + radius="md" + title="Reason" + > + {!viewingReason ? null : ( + + + {yardLabel(viewingReason.fromYard)}{" "} + {" "} + {yardLabel(viewingReason.toYard)} ·{" "} + {wagonTypeLabel(viewingReason.wagonType)} ·{" "} + {viewingReason.quantity} wagon(s) + + + + )} + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/wagons/wagon-transfer-ui.tsx b/apps/edr-freight-web/backoffice/src/pages/wagons/wagon-transfer-ui.tsx index e60480590..ea73a95d2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/wagons/wagon-transfer-ui.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/wagons/wagon-transfer-ui.tsx @@ -3,6 +3,16 @@ import { Badge, Box, Group, Progress, Text, Tooltip } from "@mantine/core"; import type { WagonTransferRequest } from "@/services/wagon.service"; +/** Reason/note fields come from a rich-text editor and store HTML — this + * gives a plain-text preview for list/table contexts (full formatting is + * shown via `sanitizeHtml` + `dangerouslySetInnerHTML` where there's room). */ +export const stripHtmlToText = (html?: string | null): string => + (html ?? "") + .replace(/<[^>]*>/g, " ") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); + export const yardLabel = (y?: { label?: string; code?: string } | null) => y?.label || y?.code || "—"; From 40672e9c41ab0b59013ac0bbb3c8140d13fc9b8e Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Mon, 10 Aug 2026 21:47:28 +0300 Subject: [PATCH 2/6] feat(freight): surface scheduling clock and gate pay during drain --- .../src/modules/bookings/bookings.service.ts | 58 +++++ .../train-scheduling/booking-batch.service.ts | 35 +-- .../detail/BookingSchedulingWindowCard.tsx | 215 ++++++++++++++++++ .../src/components/bookings/detail/index.ts | 1 + .../bookings/BookingRequestDetailPage.tsx | 2 + .../backoffice/src/types/booking.ts | 25 ++ .../MyPortalPage/components/BookingRow.tsx | 8 +- .../src/pages/MyPortalPage/constants.ts | 2 +- .../components/BookingPaymentPanel.tsx | 23 +- .../src/pages/bookings/BookingsListPage.tsx | 10 +- .../pages/bookings/payments/PayNowButton.tsx | 20 ++ .../payments/PaymentProcessingNotice.tsx | 84 +++++++ .../pages/bookings/payments/payment-drain.ts | 60 +++++ packages/types/src/freight/index.ts | 6 + 14 files changed, 524 insertions(+), 25 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/payments/PaymentProcessingNotice.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/payments/payment-drain.ts diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 2fb9b3032..e2a3d9b0c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -34,6 +34,7 @@ import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { paymentDrainEndsAtIso } from '../train-scheduling/booking-batch.constants'; import { BookingContractService } from './booking-contract.service'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; @@ -57,6 +58,24 @@ import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto' import { PdfRenderService } from '../billing/documents/pdf-render.service'; import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util'; +/** + * The allocated train as the backoffice booking detail page needs it: which + * train, its window phase, and both the planned and actual clock. Attached by + * `findById` only when the booking is on a schedule. + */ +export interface TrainScheduleSummary { + id: string; + reference: string | null; + trainNumber: string | null; + status: string | null; + scheduledDepartureDate: string | null; + scheduledArrivalDate: string | null; + actualDepartureAt: string | null; + actualArrivalAt: string | null; + windowPhase: string | null; + paymentPhaseEndsAt: string | null; +} + /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ export interface PaginatedBookings { items: Booking[]; @@ -1738,6 +1757,20 @@ export class BookingsService { (b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature = pending.has(b.id); } + this.attachPaymentDrainEnds(bookings); + } + + /** + * Derived, no query: end of the settlement drain tail after `paymentDeadline`. + * The portal hides "Pay now" between the deadline and this instant — a payment + * started just before the buzzer is still settling, so offering to pay again + * would invite a double payment. + */ + private attachPaymentDrainEnds(bookings: Booking[]): void { + for (const b of bookings) { + (b as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt = + paymentDrainEndsAtIso(b.paymentDeadline); + } } async findAll( @@ -2105,8 +2138,33 @@ export class BookingsService { .findOne({ where: { id: booking.trainScheduleId } }); (booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus = schedule?.status ?? null; + // Backoffice staff view: the allocated train's identity and clock, so the + // detail page can state which train the booking rides and when it runs + // without a second round-trip to the schedules API. + ( + booking as Booking & { trainScheduleSummary?: TrainScheduleSummary | null } + ).trainScheduleSummary = schedule + ? { + id: schedule.id, + reference: schedule.reference ?? null, + trainNumber: schedule.trainNumber ?? null, + status: schedule.status ?? null, + scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null, + scheduledArrivalDate: schedule.scheduledArrivalDate?.toISOString() ?? null, + actualDepartureAt: schedule.actualDepartureAt?.toISOString() ?? null, + actualArrivalAt: schedule.actualArrivalAt?.toISOString() ?? null, + windowPhase: schedule.windowPhase ?? null, + paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null, + } + : null; } + // End of this booking's own pay window including the settlement drain tail — + // the deadline staff should quote, since a payment landing inside the drain + // still counts (see paymentDrainEndsAtIso). + (booking as Booking & { paymentDrainEndsAt?: string | null }).paymentDrainEndsAt = + paymentDrainEndsAtIso(booking.paymentDeadline); + // A generated-but-unsigned handover means the customer must approve delivery // from the portal. Self-haul: booking-based, one per booking. EDR last-mile: // per delivering truck (generated on truck exit), signed one by one. 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 23024bfdf..cb568171a 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 @@ -3680,23 +3680,24 @@ export class BookingBatchService implements OnModuleInit { // (provider query errored / payment still in flight) means we could not // confirm "not paid" — never expire on unknown; the next settle tick // asks again. - if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) { - const reconcile = await this.billing.reconcilePayable(booking.id); - if (reconcile.paid) { - this.logger.log( - `[BATCH] expire skipped for ${booking.reference} — gateway ` + - `reconcile found a settled payment; payment.succeeded will allocate it`, - ); - return; - } - if (reconcile.unverifiable) { - this.logger.warn( - `[BATCH] expire deferred for ${booking.reference} — settlement ` + - `unverifiable at the gateway; retrying next settle tick`, - ); - return; - } - } + // TODO: CBE has no reconcile endpoint yet — re-enable once available. + // if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) { + // const reconcile = await this.billing.reconcilePayable(booking.id); + // if (reconcile.paid) { + // this.logger.log( + // `[BATCH] expire skipped for ${booking.reference} — gateway ` + + // `reconcile found a settled payment; payment.succeeded will allocate it`, + // ); + // return; + // } + // if (reconcile.unverifiable) { + // this.logger.warn( + // `[BATCH] expire deferred for ${booking.reference} — settlement ` + + // `unverifiable at the gateway; retrying next settle tick`, + // ); + // return; + // } + // } } const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx new file mode 100644 index 000000000..84f0645a8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx @@ -0,0 +1,215 @@ +import { useEffect, useState } from "react"; +import { Badge, Box, Group, Stack, Text } from "@mantine/core"; +import { CalendarClock } from "lucide-react"; + +import type { BookingDetail } from "@/types/booking"; +import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; + +import { SectionCard } from "./SectionCard"; + +export interface BookingSchedulingWindowCardProps { + booking: BookingDetail; +} + +/** Full date + time — staff read these against the operating clock, so no time is dropped. */ +function formatStamp(iso: string | null | undefined): string | null { + if (!iso) return null; + const ms = new Date(iso).getTime(); + if (!Number.isFinite(ms)) return null; + return new Date(ms).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** "in 2h 14m" / "12m ago" — the at-a-glance read next to an absolute stamp. */ +function formatRelative(iso: string, nowMs: number): string { + const diff = new Date(iso).getTime() - nowMs; + const past = diff < 0; + const totalMinutes = Math.floor(Math.abs(diff) / 60_000); + const days = Math.floor(totalMinutes / 1440); + const hours = Math.floor((totalMinutes % 1440) / 60); + const minutes = totalMinutes % 60; + + const parts: string[] = []; + if (days) parts.push(`${days}d`); + if (hours) parts.push(`${hours}h`); + // Keep minutes when they're the only unit, so sub-hour gaps never read "0". + if (minutes || parts.length === 0) parts.push(`${minutes}m`); + + const span = parts.slice(0, 2).join(" "); + return past ? `${span} ago` : `in ${span}`; +} + +function Row({ + label, + value, + hint, + tone, +}: { + label: string; + value: string; + hint?: string | null; + tone?: "muted" | "warning" | "danger"; +}) { + const valueColor = + tone === "danger" ? "red.7" : tone === "warning" ? "orange.7" : "dark"; + return ( + + + {label} + + + + {value} + + {hint ? ( + + {hint} + + ) : null} + + + ); +} + +/** + * Backoffice-only staff view of the scheduling clock: which batch/train the + * booking is scheduled for, when its pay window closes, and the train's + * planned vs actual departure/arrival (i.e. when the run actually ended). + */ +export function BookingSchedulingWindowCard({ + booking, +}: BookingSchedulingWindowCardProps) { + const schedule = booking.trainScheduleSummary ?? null; + + // The pay-window end staff should quote is the drain end (a payment landing + // inside the drain still counts); fall back to the raw deadline if the API + // predates that field. + const payWindowEndsAt = booking.paymentDrainEndsAt ?? booking.paymentDeadline ?? null; + + // One shared ticking clock so every relative label in the card stays in sync. + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const interval = setInterval(() => setNowMs(Date.now()), 30_000); + return () => clearInterval(interval); + }, []); + + const hasAnything = + Boolean(schedule) || Boolean(payWindowEndsAt) || Boolean(booking.holdExpiresAt); + if (!hasAnything) return null; + + const payWindowClosed = payWindowEndsAt + ? new Date(payWindowEndsAt).getTime() <= nowMs + : false; + + const trainLabel = + schedule?.trainNumber ?? + schedule?.reference ?? + (schedule ? "Assigned train" : null); + + return ( + } + > + + {trainLabel ? ( + + ) : ( + + )} + + {schedule?.status ? ( + + + Train status + + + {schedule.windowPhase ? ( + + {schedule.windowPhase.replace(/_/g, " ")} + + ) : null} + + {schedule.status} + + + + ) : null} + + {payWindowEndsAt ? ( + + ) : null} + + {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( + + ) : null} + + {schedule ? ( + <> + + + + ) : null} + + + ); +} 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 ecbb0488e..b0b024977 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 @@ -22,3 +22,4 @@ export * from "./BookingMileServicesCard"; export * from "./BookingCargoCard"; export * from "./BookingContractSummaryCard"; export * from "./BookingCompanyCard"; +export * from "./BookingSchedulingWindowCard"; 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 feee70b9d..4367fc708 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -40,6 +40,7 @@ import { BookingCompanyCard, BookingContractSummaryCard, BookingContainerUnitsCard, + BookingSchedulingWindowCard, BookingDocumentsPanel, BookingTrucksPanel, ContractOrdersPanel, @@ -246,6 +247,7 @@ export default function BookingRequestDetailPage() { + = { icon: Wallet, iconColor: "edr-amber-text", tile: "edr-amber-soft", - hint: "Selected for batch · payment due within 1 hour", + hint: "Selected for batch · payment due before the deadline", step: "edr-accent", badgeLabel: "Pay Now", badgeBg: "edr-amber-soft", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx index b94ea9fed..710c6b40f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx @@ -18,6 +18,8 @@ import { invoicesService, type PortalInvoice } from "@/services/invoices.service import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui"; import { paymentStatusLabel } from "@/pages/bookings/booking-display"; import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment"; +import { payWindowState } from "@/pages/bookings/payments/payment-drain"; +import { PaymentProcessingNotice } from "@/pages/bookings/payments/PaymentProcessingNotice"; import { saveBlob } from "@/utils/download"; import { @@ -216,6 +218,11 @@ export function BookingPaymentPanel({ (booking.status === "SELECTED_FOR_BATCH" || Boolean(booking.paymentDeadline)); + // Pay deadline passed but the settlement drain tail hasn't: in-flight payments + // are still landing, so the pay action gives way to a processing countdown. + const payWindow = payWindowState(booking); + const draining = payWindow.phase === "draining" && Boolean(payWindow.drainEndsAt); + const { data: invoices = [] } = useQuery({ queryKey: ["booking-invoices", booking.id], queryFn: () => invoicesService.listForSource("booking", booking.id), @@ -266,9 +273,11 @@ export function BookingPaymentPanel({ {paid ? : showCountdown ? : null} {paid ? "Paid" - : showCountdown - ? "Pay window open" - : paymentStatusLabel(booking.paymentStatus ?? "PENDING")} + : draining + ? "Payment processing" + : showCountdown + ? "Pay window open" + : paymentStatusLabel(booking.paymentStatus ?? "PENDING")} @@ -279,7 +288,11 @@ export function BookingPaymentPanel({ {/* USD: no online payment — bank transfer + slip to Finance, who confirm the payment (backoffice flow lands in a later phase). Shown for any unpaid USD booking, with or without an open pay window. */} - {!paid && offlineUsd && ( + {!paid && draining && payWindow.drainEndsAt && ( + + )} + + {!paid && !draining && offlineUsd && ( )} - {showCountdown && booking.paymentDeadline && ( + {showCountdown && !draining && booking.paymentDeadline && ( ; } // Contract ready for the customer's signature → full-page contract viewer. diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx index db50bd57f..63ca637b5 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx @@ -7,6 +7,8 @@ import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal"; import { priceTotal } from "../BookingDetailPage/utils"; import { isUsdOfflineBooking } from "./offline-payment"; +import { payWindowState } from "./payment-drain"; +import { PaymentProcessingNotice } from "./PaymentProcessingNotice"; import { useBookingPayment } from "./useBookingPayment"; interface PayNowButtonProps { @@ -29,6 +31,24 @@ export function PayNowButton({ }: PayNowButtonProps) { const pay = useBookingPayment(booking.id); const pricing = booking.pricingBreakdown; + const payWindow = payWindowState(booking); + + // Pay deadline passed but in-flight payments are still settling: show the + // drain countdown instead of any pay action, so nobody pays a second time. + // Checked before the USD branch — a bank transfer is just as double-payable. + if (payWindow.phase === "draining" && payWindow.drainEndsAt) { + return ( + + ); + } + + // Window fully over (drain included) — nothing to pay against anymore. + if (payWindow.phase === "closed") { + return null; + } // USD is paid by bank transfer and confirmed by Finance — no online payment. if (isUsdOfflineBooking(booking)) { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PaymentProcessingNotice.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PaymentProcessingNotice.tsx new file mode 100644 index 000000000..56b7897ee --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PaymentProcessingNotice.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { Box, Group, Text } from "@mantine/core"; +import { Loader2 } from "lucide-react"; + +/** mm:ss left until `target`; clamped at zero so it never shows a negative. */ +function secondsLeft(target: number, now: number): string { + const total = Math.max(0, Math.ceil((target - now) / 1000)); + const minutes = Math.floor(total / 60); + const seconds = total % 60; + return `${minutes}:${String(seconds).padStart(2, "0")}`; +} + +export interface PaymentProcessingNoticeProps { + /** ISO end of the drain tail — the countdown target. */ + drainEndsAt: string; + /** Compact single-line form for list rows; full block for the detail page. */ + variant?: "inline" | "block"; + /** Called once the drain elapses, so the parent can refetch the new state. */ + onElapsed?: () => void; +} + +/** + * Shown in place of "Pay now" during the settlement drain tail: the pay deadline + * has passed but in-flight payments are still landing, so the customer waits + * rather than paying again. + */ +export function PaymentProcessingNotice({ + drainEndsAt, + variant = "block", + onElapsed, +}: PaymentProcessingNoticeProps) { + const targetMs = new Date(drainEndsAt).getTime(); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + setNow(Date.now()); + const interval = setInterval(() => { + const next = Date.now(); + setNow(next); + if (next >= targetMs) { + clearInterval(interval); + onElapsed?.(); + } + }, 1000); + return () => clearInterval(interval); + }, [targetMs, onElapsed]); + + const remaining = secondsLeft(targetMs, now); + + if (variant === "inline") { + return ( + + + + Processing · {remaining} + + + ); + } + + return ( + + + + + Payment processing — {remaining} left + + + + The payment window has closed and we're confirming the payments that + came in. If you already paid, it can take a few minutes to appear — + please don't pay again. This page updates on its own. + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/payment-drain.ts b/apps/edr-freight-web/portal/src/pages/bookings/payments/payment-drain.ts new file mode 100644 index 000000000..61d97864b --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/payment-drain.ts @@ -0,0 +1,60 @@ +import { Freight } from "@edr/types"; + +/** + * Where a booking sits relative to its pay window. + * + * - `open` — the window is live; the customer can pay. + * - `draining` — the deadline passed but the settlement drain tail has not. A + * payment started just before the buzzer may still be settling, + * so we show "processing" and hide every pay action rather than + * invite a second payment for the same booking. + * - `closed` — the drain tail elapsed too; the window is over. + * - `none` — no deadline on the booking (nothing to gate). + */ +export type PayWindowPhase = "open" | "draining" | "closed" | "none"; + +export interface PayWindowState { + phase: PayWindowPhase; + /** True only while the customer may actually start a payment. */ + canPay: boolean; + /** End of the drain tail — the countdown target while `draining`. */ + drainEndsAt: string | null; +} + +type PayableBooking = Pick< + Freight.IBooking, + "paymentDeadline" | "paymentDrainEndsAt" +>; + +/** + * Classify a booking's pay window against `now`. + * + * Falls back to the raw deadline when the server sent no `paymentDrainEndsAt` + * (older payload): with no known tail there is no drain to wait out, so the + * window goes straight from open to closed. + */ +export function payWindowState( + booking: PayableBooking | null | undefined, + now: number = Date.now(), +): PayWindowState { + const deadline = booking?.paymentDeadline ?? null; + if (!deadline) { + return { phase: "none", canPay: true, drainEndsAt: null }; + } + + const deadlineMs = new Date(deadline).getTime(); + if (!Number.isFinite(deadlineMs)) { + return { phase: "none", canPay: true, drainEndsAt: null }; + } + if (now < deadlineMs) { + return { phase: "open", canPay: true, drainEndsAt: null }; + } + + const drainRaw = booking?.paymentDrainEndsAt ?? null; + const drainMs = drainRaw ? new Date(drainRaw).getTime() : NaN; + if (Number.isFinite(drainMs) && now < drainMs) { + return { phase: "draining", canPay: false, drainEndsAt: drainRaw }; + } + + return { phase: "closed", canPay: false, drainEndsAt: null }; +} diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 7ff14721c..68f48e187 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -717,6 +717,12 @@ export interface IBooking extends BaseEntity { selectedForBatchAt?: string | null; /** End of the pay window once the booking is SELECTED_FOR_BATCH. */ paymentDeadline?: string | null; + /** + * End of the settlement drain tail that follows `paymentDeadline`. Between the + * two, an in-flight payment can still land, so the customer is shown a + * "payment processing" state instead of a pay action. + */ + paymentDrainEndsAt?: string | null; containers?: Array<{ type: string; qty: number; vgm: number }> | null; From c743750ef6cd7bb37561d298792922cd84e79a1f Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Mon, 10 Aug 2026 22:14:04 +0300 Subject: [PATCH 3/6] feat(freight): surface scheduling clock and gate pay during drain --- .../detail/BookingSchedulingWindowCard.tsx | 45 ++++++++++++++++++- .../backoffice/src/types/booking.ts | 5 +++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx index 84f0645a8..9b5cf91f2 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx @@ -44,6 +44,27 @@ function formatRelative(iso: string, nowMs: number): string { return past ? `${span} ago` : `in ${span}`; } +/** + * Length of a window as "1h 30m" / "45m". Null unless both ends are real and + * ordered — the pay window is configurable per schedule, so this is read off the + * actual stamps rather than assuming any fixed duration. + */ +function formatDuration( + from: string | null | undefined, + to: string | null | undefined, +): string | null { + if (!from || !to) return null; + const fromMs = new Date(from).getTime(); + const toMs = new Date(to).getTime(); + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) return null; + const minutes = Math.round((toMs - fromMs) / 60_000); + if (minutes <= 0) return null; + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + if (!hours) return `${rest}m`; + return rest ? `${hours}h ${rest}m` : `${hours}h`; +} + function Row({ label, value, @@ -98,8 +119,18 @@ export function BookingSchedulingWindowCard({ return () => clearInterval(interval); }, []); + // How long the customer actually had to pay: start → the raw deadline, NOT the + // drain end (the drain is settlement grace, not payable time). + const windowDuration = formatDuration( + booking.selectedForBatchAt, + booking.paymentDeadline, + ); + const hasAnything = - Boolean(schedule) || Boolean(payWindowEndsAt) || Boolean(booking.holdExpiresAt); + Boolean(schedule) || + Boolean(payWindowEndsAt) || + Boolean(booking.selectedForBatchAt) || + Boolean(booking.holdExpiresAt); if (!hasAnything) return null; const payWindowClosed = payWindowEndsAt @@ -157,6 +188,18 @@ export function BookingSchedulingWindowCard({ ) : null} + {booking.selectedForBatchAt ? ( + + ) : null} + {payWindowEndsAt ? ( Date: Mon, 10 Aug 2026 23:30:18 +0300 Subject: [PATCH 4/6] feat(wagons): enforce wagon availability limits in transfer requests --- .../wagons/dto/create-transfer-request.dto.ts | 3 +- .../wagon-transfer-requests.service.spec.ts | 42 ++++++++++++- .../wagons/wagon-transfer-requests.service.ts | 23 +++++-- .../wagons/WagonYardWorkspaceModal.tsx | 16 +++-- .../pages/wagons/TransferRequestModals.tsx | 61 +++++++++++++++++-- 5 files changed, 127 insertions(+), 18 deletions(-) diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts index e747b69f2..85c879f70 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts @@ -14,7 +14,8 @@ import { * A count-only wagon-transfer request. The requester picks source yard, wagon * type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks * those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that - * type currently in the source yard, and a reason is mandatory. + * type currently in the source yard (enforced in the service, which is the only + * layer that can count them), and a reason is mandatory. */ export class CreateTransferRequestDto { @IsUUID() diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts index 205d86450..8f7457b37 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts @@ -199,7 +199,7 @@ describe('WagonTransferRequestsService — partial fulfilment', () => { }); describe('createRequest', () => { - it('accepts a count larger than what the yard holds today', async () => { + it('accepts a count up to what the yard holds today', async () => { wagonRepo.count.mockResolvedValue(20); await service.createRequest( @@ -207,14 +207,50 @@ describe('WagonTransferRequestsService — partial fulfilment', () => { fromYardId: 'yard-a', toYardId: 'yard-b', wagonTypeId: 'type-1', - quantity: 50, + quantity: 20, reason: 'Grain campaign', }, 'user-1', ); expect(requestRepo.save).toHaveBeenCalled(); - expect(stored.quantity).toBe(50); + expect(stored.quantity).toBe(20); + }); + + it('refuses a count larger than what the yard holds today', async () => { + wagonRepo.count.mockResolvedValue(20); + + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 50, + reason: 'Grain campaign', + }, + 'user-1', + ), + ).rejects.toThrow(/only 20 wagon\(s\).*available/i); + expect(requestRepo.save).not.toHaveBeenCalled(); + }); + + it('refuses when the yard has nothing of that type available', async () => { + wagonRepo.count.mockResolvedValue(0); + + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 1, + reason: 'Grain campaign', + }, + 'user-1', + ), + ).rejects.toThrow(/no available wagons/i); + expect(requestRepo.save).not.toHaveBeenCalled(); }); it('still refuses a same-yard move', async () => { diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts index 8d4159726..6b63460ad 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -75,10 +75,11 @@ export class WagonTransferRequestsService { ) {} /** - * Record a PENDING request. Count-only — no wagons are picked here, and the - * count is NOT capped by what the source yard holds today: OCC fulfils in - * instalments, so asking for 50 while only 20 sit there is a normal, useful - * request. A reason is mandatory and is shown on the OCC queue. + * Record a PENDING request. Count-only — no wagons are picked here, but the + * count IS capped by what the source yard can hand over right now: a request + * may not exceed the AVAILABLE, uncoupled wagons of that type in the source + * yard (the same number the yard desk shows). A reason is mandatory and is + * shown on the OCC queue. */ async createRequest( dto: CreateTransferRequestDto, @@ -89,6 +90,20 @@ export class WagonTransferRequestsService { 'Source and destination yard must be different', ); } + const available = await this.countAvailable( + dto.fromYardId, + dto.wagonTypeId, + ); + if (available === 0) { + throw new BadRequestException( + 'No available wagons of this type in the source yard', + ); + } + if (dto.quantity > available) { + throw new BadRequestException( + `Only ${available} wagon(s) of this type are available in the source yard — cannot request ${dto.quantity}`, + ); + } const request = this.requestRepo.create({ fromYardId: dto.fromYardId, toYardId: dto.toYardId, diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx index 4a92695ca..cb9fdf86b 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx @@ -44,9 +44,8 @@ const clampInt = (v: number | string, max: number): number => { /** * NumberInput + Slider + All/Half presets, kept in sync. `max` bounds the field - * for actions that move real wagons; omit it for a transfer REQUEST, which may - * legitimately ask for more than the yard holds today (OCC fulfils it in - * instalments) — the slider then just tracks the current value. + * to the wagons on hand; omitting it leaves the field unbounded and the slider + * simply tracks the current value. */ const QuantityField = ({ value, @@ -434,9 +433,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro {availableCount} available - {/* No max: the request may exceed what the yard holds - today — OCC fulfils it in instalments. */} - + {/* Capped at the wagons actually available in this yard + right now (uncoupled + Available) — a request may not + ask for more than the yard can hand over. */} +