diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx index 4b801d99f..3d8a0792a 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx @@ -13,6 +13,8 @@ const statusColorMap: Record = { FULLY_EXECUTED: "indigo", PNR_GENERATED: "violet", PAYMENT_VERIFICATION_IN_PROGRESS: "yellow", + SELECTED_FOR_BATCH: "orange", + EXPIRED: "red", PAID: "green", IN_TRANSIT: "cyan", COMPLETED: "indigo", diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingPaymentCountdownCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingPaymentCountdownCard.tsx new file mode 100644 index 000000000..2242fb278 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingPaymentCountdownCard.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from "react"; +import { Group, Stack, Text } from "@mantine/core"; +import { Timer } from "lucide-react"; + +import { SectionCard } from "./SectionCard"; + +export interface BookingPaymentCountdownCardProps { + /** ISO timestamp marking the end of the pay window. */ + paymentDeadline: string; +} + +interface Remaining { + days: number; + hours: number; + minutes: number; + seconds: number; + expired: boolean; +} + +function getRemaining(deadlineMs: number): Remaining { + const diff = deadlineMs - Date.now(); + if (diff <= 0) { + return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true }; + } + const totalSeconds = Math.floor(diff / 1000); + return { + days: Math.floor(totalSeconds / 86400), + hours: Math.floor((totalSeconds % 86400) / 3600), + minutes: Math.floor((totalSeconds % 3600) / 60), + seconds: totalSeconds % 60, + expired: false, + }; +} + +function Segment({ value, label }: { value: number; label: string }) { + return ( + + + {String(value).padStart(2, "0")} + + + {label} + + + ); +} + +/** Live countdown to the payment deadline. Ticks every second; shows an expired state past the deadline. */ +export function BookingPaymentCountdownCard({ paymentDeadline }: BookingPaymentCountdownCardProps) { + const deadlineMs = new Date(paymentDeadline).getTime(); + const [remaining, setRemaining] = useState(() => getRemaining(deadlineMs)); + + useEffect(() => { + setRemaining(getRemaining(deadlineMs)); + const interval = setInterval(() => { + const next = getRemaining(deadlineMs); + setRemaining(next); + if (next.expired) { + clearInterval(interval); + } + }, 1000); + return () => clearInterval(interval); + }, [deadlineMs]); + + const accent = remaining.expired ? "red" : "orange"; + + return ( + + {remaining.expired ? ( + + Expired + + ) : ( + + + + + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts index e12b7220b..c53cbccbd 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/booking-detail.styles.ts @@ -137,6 +137,8 @@ export interface BookingDetailView { priorityScore: number; cargoTotalWeightVgm: number; pnrCode?: string | null; + /** End of the pay window once the booking is SELECTED_FOR_BATCH. */ + paymentDeadline?: string | null; createdAt: string; updatedAt: string; company?: BookingNamedRefView; 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 06f3c1e5d..36d782730 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 @@ -9,6 +9,7 @@ export * from "./BookingContainersCard"; export * from "./BookingApprovalCard"; export * from "./BookingReviewNotesCard"; export * from "./BookingPaymentCard"; +export * from "./BookingPaymentCountdownCard"; export * from "./BookingFactsCard"; export * from "./BookingDocumentsCard"; export * from "./BookingRequestHero"; diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts index a220c1f06..834dcee3e 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts @@ -50,6 +50,14 @@ export const BOOKING_STATUS_STYLES: Record = { label: "Payment Verification", color: "bg-amber-50 text-amber-800 border-amber-200", }, + SELECTED_FOR_BATCH: { + label: "Selected for Batch", + color: "bg-orange-50 text-orange-700 border-orange-200", + }, + EXPIRED: { + label: "Expired", + color: "bg-red-50 text-red-700 border-red-200", + }, PAID: { label: "Paid", color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]", @@ -241,6 +249,8 @@ export const BOOKING_LIST_TABS = [ "FULLY_EXECUTED", "PNR_GENERATED", "PAYMENT_VERIFICATION_IN_PROGRESS", + "SELECTED_FOR_BATCH", + "EXPIRED", ], }, { @@ -270,6 +280,8 @@ export const WORKFLOW_STAGES = [ "FULLY_EXECUTED", "PNR_GENERATED", "PAYMENT_VERIFICATION_IN_PROGRESS", + "SELECTED_FOR_BATCH", + "EXPIRED", ], }, { diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx index e225bea0b..42cde3d90 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx @@ -9,6 +9,7 @@ import { BookingFactsCard, BookingLifecycleStepper, BookingPaymentCard, + BookingPaymentCountdownCard, BookingReviewNotesCard, BookingRouteCard, detailStyles, @@ -24,8 +25,9 @@ const BookingDetailPage = () => { const booking: BookingDetailView = { id: id || "a61955b7-af21-4664-84d3-7e4e66293b6f", reference: "BKG-2026-001456", - status: "IN_TRANSIT", + status: "SELECTED_FOR_BATCH", scheduledDate: "2026-06-15", + paymentDeadline: "2026-06-18T17:00:00Z", totalAmount: 15750.5, paymentCurrency: "USD", paymentStatus: "PAID", @@ -137,6 +139,9 @@ const BookingDetailPage = () => { {/* RIGHT — summary sidebar */} + {booking.status === "SELECTED_FOR_BATCH" && booking.paymentDeadline && ( + + )} } @@ -70,6 +74,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) reason={booking.latestChangeRequestNote} onRebook={() => navigate("/bookings/new")} /> + ) : isExpired ? ( + navigate("/bookings/new")} + /> ) : ( )} @@ -114,6 +125,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) } right={ <> + {showCountdown && ( + payMutation.mutate()} + paying={payMutation.isPending} + /> + )} + + {String(value).padStart(2, "0")} + + + {label} + + + ); +} + +export function PaymentDeadlineCard({ + paymentDeadline, + onPay, + paying, +}: { + /** ISO timestamp marking the end of the pay window. */ + paymentDeadline: string; + onPay?: () => void; + paying?: boolean; +}) { + const deadlineMs = new Date(paymentDeadline).getTime(); + const [remaining, setRemaining] = useState(() => getRemaining(deadlineMs)); + + useEffect(() => { + setRemaining(getRemaining(deadlineMs)); + const interval = setInterval(() => { + const next = getRemaining(deadlineMs); + setRemaining(next); + if (next.expired) clearInterval(interval); + }, 1000); + return () => clearInterval(interval); + }, [deadlineMs]); + + const accentBg = remaining.expired ? "#FBEAE7" : "#FDF3E0"; + const accentFg = remaining.expired ? "#C0392B" : "#9A5B00"; + + return ( + + + Payment deadline + + + {remaining.expired ? "Expired" : "Pay window open"} + + + + {remaining.expired ? ( + + The payment window has closed. Move this booking to another schedule or + contact support. + + ) : ( + <> + + + + + + + + Complete payment before the window closes to secure your slot. + + {onPay && ( + + )} + + )} + + + + Deadline:{" "} + {new Date(paymentDeadline).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts index 80f0b7ab2..c6e19fb9b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -32,6 +32,8 @@ export const PROGRESS_STAGES = [ label: "In Transit", icon: Train, statuses: [ + "SELECTED_FOR_BATCH", + "EXPIRED", "PNR_GENERATED", "PAYMENT_VERIFICATION_IN_PROGRESS", "PAID", @@ -99,6 +101,18 @@ export const STATUS_MAP: Record< description: "Signed by all parties. You can now proceed to payment.", stage: 2, }, + SELECTED_FOR_BATCH: { + title: "Selected for a train — payment due", + description: + "Your booking was selected for a scheduled train. Complete payment within the pay window to secure your slot.", + stage: 3, + }, + EXPIRED: { + title: "Pay window expired", + description: + "The payment window was missed. You can move this booking to another schedule or cancel it.", + stage: 3, + }, PNR_GENERATED: { title: "Payment reference generated", description: diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx index 6070f6157..55b81a816 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx @@ -56,7 +56,7 @@ api.bookings.list.queryOptions({ const origin = booking.originYard?.label || "Unknown"; const destination = booking.destinationYard?.label || "Unknown"; return { - value: booking.reference, + value: booking.id, label: `${booking.reference} - Route: ${origin} to ${destination}`, booking, }; @@ -75,8 +75,6 @@ api.bookings.list.queryOptions({ // Auto-fill from previous contract const booking = selected.booking; if (booking) { - form.setValue("originYard", booking.originYardId); - form.setValue("destinationYard", booking.destinationYardId); form.setValue("serviceTypeId", booking.serviceTypeId); form.setValue("cargoType", booking.freightType === "CONTAINER" ? "container" : "bulk"); form.setValue("equipmentReturn", booking.equipmentReturn === "WITH_RETURN" ? "with_return" : "without_return"); diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index a3c4f1a59..6672f95e9 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -282,6 +282,9 @@ export interface IBooking extends BaseEntity { totalAmount: number; paymentStatus: PaymentStatus; + shippingLineId?: string | null; + serviceTypeId: string; + contractType: "NEW" | "RENEWAL"; previousContractId?: string | null; serviceType: "RAIL_ONLY" | "RAIL_AND_FORWARDING"; @@ -311,6 +314,11 @@ export interface IBooking extends BaseEntity { endDate?: string | null; financialTerms?: string | null; + /** When the batch engine picked this booking and opened the pay window. */ + selectedForBatchAt?: string | null; + /** End of the pay window once the booking is SELECTED_FOR_BATCH. */ + paymentDeadline?: string | null; + containers?: Array<{ type: string; qty: number; vgm: number }> | null; versionNumber: number;