mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(freight): surface scheduling clock and gate pay during drain
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 (
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="md">
|
||||
<Text size="sm" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Box style={{ textAlign: "right", minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} c={valueColor}>
|
||||
{value}
|
||||
</Text>
|
||||
{hint ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{hint}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<SectionCard
|
||||
icon={CalendarClock}
|
||||
title="Scheduling & payment window"
|
||||
subtitle="Staff view — batch allocation and the operating clock"
|
||||
accent="indigo"
|
||||
extra={<SchedulingStatusBadge status={booking.schedulingStatus} />}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{trainLabel ? (
|
||||
<Row
|
||||
label="Scheduled on train"
|
||||
value={trainLabel}
|
||||
hint={
|
||||
schedule?.reference && schedule.reference !== trainLabel
|
||||
? schedule.reference
|
||||
: null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Row
|
||||
label="Scheduled on train"
|
||||
value="Not yet allocated"
|
||||
tone="muted"
|
||||
hint="The booking has not been placed on a train schedule"
|
||||
/>
|
||||
)}
|
||||
|
||||
{schedule?.status ? (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
Train status
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{schedule.windowPhase ? (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{schedule.windowPhase.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="light" color="indigo" size="sm">
|
||||
{schedule.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{payWindowEndsAt ? (
|
||||
<Row
|
||||
label="Payment window ends"
|
||||
value={formatStamp(payWindowEndsAt) ?? "—"}
|
||||
tone={payWindowClosed ? "danger" : "warning"}
|
||||
hint={
|
||||
payWindowClosed
|
||||
? `Closed ${formatRelative(payWindowEndsAt, nowMs)}`
|
||||
: `Closes ${formatRelative(payWindowEndsAt, nowMs)}`
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
|
||||
<Row
|
||||
label="Wagon hold expires"
|
||||
value={formatStamp(booking.holdExpiresAt) ?? "—"}
|
||||
tone="warning"
|
||||
hint={formatRelative(booking.holdExpiresAt, nowMs)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{schedule ? (
|
||||
<>
|
||||
<Row
|
||||
label="Departure"
|
||||
value={
|
||||
formatStamp(schedule.actualDepartureAt) ??
|
||||
formatStamp(schedule.scheduledDepartureDate) ??
|
||||
"—"
|
||||
}
|
||||
hint={
|
||||
schedule.actualDepartureAt
|
||||
? `Actual · planned ${formatStamp(schedule.scheduledDepartureDate) ?? "—"}`
|
||||
: "Planned"
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label={schedule.actualArrivalAt ? "Arrived (trip ended)" : "Arrival"}
|
||||
value={
|
||||
formatStamp(schedule.actualArrivalAt) ??
|
||||
formatStamp(schedule.scheduledArrivalDate) ??
|
||||
"—"
|
||||
}
|
||||
hint={
|
||||
schedule.actualArrivalAt
|
||||
? `Actual · planned ${formatStamp(schedule.scheduledArrivalDate) ?? "—"}`
|
||||
: "Planned — the train has not arrived yet"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -22,3 +22,4 @@ export * from "./BookingMileServicesCard";
|
||||
export * from "./BookingCargoCard";
|
||||
export * from "./BookingContractSummaryCard";
|
||||
export * from "./BookingCompanyCard";
|
||||
export * from "./BookingSchedulingWindowCard";
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
BookingCompanyCard,
|
||||
BookingContractSummaryCard,
|
||||
BookingContainerUnitsCard,
|
||||
BookingSchedulingWindowCard,
|
||||
BookingDocumentsPanel,
|
||||
BookingTrucksPanel,
|
||||
ContractOrdersPanel,
|
||||
@@ -246,6 +247,7 @@ export default function BookingRequestDetailPage() {
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<Stack gap="lg">
|
||||
<BookingCompanyCard booking={booking} />
|
||||
<BookingSchedulingWindowCard booking={booking} />
|
||||
<BookingPricingSummary booking={booking} />
|
||||
<Box id="warehouse-payments">
|
||||
<WarehouseInfoCard
|
||||
|
||||
@@ -131,6 +131,20 @@ export interface BookingFile {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/** The allocated train's identity, window phase, and planned/actual clock. */
|
||||
export interface BookingTrainScheduleSummary {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface BookingDetail {
|
||||
id: string;
|
||||
reference: string;
|
||||
@@ -188,6 +202,17 @@ export interface BookingDetail {
|
||||
wagonsRequired?: number | null;
|
||||
scheduledAt?: string | null;
|
||||
trainScheduleId?: string | null;
|
||||
/** Operational status of the allocated train (null until scheduled). */
|
||||
trainScheduleStatus?: string | null;
|
||||
/** The allocated train's identity + clock, attached by the detail endpoint. */
|
||||
trainScheduleSummary?: BookingTrainScheduleSummary | null;
|
||||
/** End of this booking's pay window (batch/offer deadline). */
|
||||
paymentDeadline?: string | null;
|
||||
/**
|
||||
* End of the pay window including the settlement drain tail — the deadline
|
||||
* staff should quote, since a payment landing inside the drain still counts.
|
||||
*/
|
||||
paymentDrainEndsAt?: string | null;
|
||||
pnrCode?: string | null;
|
||||
firstMilePickupAddress?: string | null;
|
||||
lastMileDeliveryAddress?: string | null;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { memo } from "react";
|
||||
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
|
||||
import { Stepper } from "./Stepper";
|
||||
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
||||
import { payWindowState } from "@/pages/bookings/payments/payment-drain";
|
||||
import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton";
|
||||
import { bookingHasInlineAction } from "@/pages/bookings/clearance/bookingNextAction";
|
||||
import {
|
||||
@@ -35,8 +36,13 @@ export const BookingRow = memo(function BookingRow({
|
||||
booking.bookingType === "GENERAL_CONTRACT"
|
||||
? "FULLY_EXECUTED"
|
||||
: "SELECTED_FOR_BATCH";
|
||||
// A fully-closed pay window (deadline + drain both elapsed) has nothing to pay
|
||||
// against, so the row falls back to its normal action instead of an empty slot.
|
||||
// The drain itself still routes here — PayNowButton renders the wait notice.
|
||||
const canPay =
|
||||
booking.status === payableStatus && booking.paymentStatus !== "PAID";
|
||||
booking.status === payableStatus &&
|
||||
booking.paymentStatus !== "PAID" &&
|
||||
payWindowState(booking).phase !== "closed";
|
||||
// Clearance/operation steps + changes-requested resubmit can be done in place
|
||||
// via a modal on the row.
|
||||
const hasInlineAction = bookingHasInlineAction(booking);
|
||||
|
||||
@@ -302,7 +302,7 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
|
||||
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",
|
||||
|
||||
@@ -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 ? <CheckCircle2 size={13} /> : showCountdown ? <Timer size={13} /> : null}
|
||||
{paid
|
||||
? "Paid"
|
||||
: showCountdown
|
||||
? "Pay window open"
|
||||
: paymentStatusLabel(booking.paymentStatus ?? "PENDING")}
|
||||
: draining
|
||||
? "Payment processing"
|
||||
: showCountdown
|
||||
? "Pay window open"
|
||||
: paymentStatusLabel(booking.paymentStatus ?? "PENDING")}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -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 && (
|
||||
<PaymentProcessingNotice drainEndsAt={payWindow.drainEndsAt} />
|
||||
)}
|
||||
|
||||
{!paid && !draining && offlineUsd && (
|
||||
<Box
|
||||
mt={14}
|
||||
p={14}
|
||||
@@ -301,7 +314,7 @@ export function BookingPaymentPanel({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showCountdown && booking.paymentDeadline && (
|
||||
{showCountdown && !draining && booking.paymentDeadline && (
|
||||
<Box mt={16}>
|
||||
<Countdown
|
||||
deadline={booking.paymentDeadline}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
|
||||
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
|
||||
import { PayNowButton } from "./payments/PayNowButton";
|
||||
import { payWindowState } from "./payments/payment-drain";
|
||||
import { BookingActionButton } from "./clearance/BookingActionButton";
|
||||
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
|
||||
import {
|
||||
@@ -211,7 +212,14 @@ function PrimaryAction({
|
||||
const payableStatus = isGeneralContract
|
||||
? "FULLY_EXECUTED"
|
||||
: "SELECTED_FOR_BATCH";
|
||||
if (status === payableStatus && booking.paymentStatus !== "PAID") {
|
||||
// A fully-closed pay window (deadline + drain both elapsed) falls through to
|
||||
// the default action. The drain itself still routes here — PayNowButton
|
||||
// renders the "payment processing" wait notice instead of a pay action.
|
||||
if (
|
||||
status === payableStatus &&
|
||||
booking.paymentStatus !== "PAID" &&
|
||||
payWindowState(booking).phase !== "closed"
|
||||
) {
|
||||
return <PayNowButton booking={booking} />;
|
||||
}
|
||||
// Contract ready for the customer's signature → full-page contract viewer.
|
||||
|
||||
@@ -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 (
|
||||
<PaymentProcessingNotice
|
||||
drainEndsAt={payWindow.drainEndsAt}
|
||||
variant="inline"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
|
||||
@@ -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 (
|
||||
<Group gap={6} align="center" wrap="nowrap">
|
||||
<Loader2 size={13} color="#B07D14" className="animate-spin" />
|
||||
<Text fz={12} fw={700} c="#B07D14" style={{ whiteSpace: "nowrap" }}>
|
||||
Processing · {remaining}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
mt={14}
|
||||
p={14}
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#FEF6E6",
|
||||
border: "1px solid #F3E2B8",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} align="center" wrap="nowrap">
|
||||
<Loader2 size={15} color="#9A5B00" className="animate-spin" />
|
||||
<Text fz="13px" fw={800} c="#9A5B00">
|
||||
Payment processing — {remaining} left
|
||||
</Text>
|
||||
</Group>
|
||||
<Text mt={4} fz="12.5px" c="#7A5A1E" lh={1.55}>
|
||||
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.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user