mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
payment ui for booking
This commit is contained in:
@@ -13,6 +13,8 @@ const statusColorMap: Record<string, string> = {
|
||||
FULLY_EXECUTED: "indigo",
|
||||
PNR_GENERATED: "violet",
|
||||
PAYMENT_VERIFICATION_IN_PROGRESS: "yellow",
|
||||
SELECTED_FOR_BATCH: "orange",
|
||||
EXPIRED: "red",
|
||||
PAID: "green",
|
||||
IN_TRANSIT: "cyan",
|
||||
COMPLETED: "indigo",
|
||||
|
||||
@@ -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 (
|
||||
<Stack gap={2} align="center" style={{ minWidth: 56 }}>
|
||||
<Text fw={700} size="2rem" style={{ lineHeight: 1, fontVariantNumeric: "tabular-nums" }}>
|
||||
{String(value).padStart(2, "0")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" lts="0.06em">
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<Remaining>(() => 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 (
|
||||
<SectionCard
|
||||
icon={Timer}
|
||||
title="Payment Deadline"
|
||||
subtitle={
|
||||
remaining.expired
|
||||
? "The pay window has closed"
|
||||
: "Time remaining to complete payment"
|
||||
}
|
||||
accent={accent}
|
||||
>
|
||||
{remaining.expired ? (
|
||||
<Text fw={600} c="red.7">
|
||||
Expired
|
||||
</Text>
|
||||
) : (
|
||||
<Group justify="center" gap="lg" wrap="nowrap">
|
||||
<Segment value={remaining.days} label="Days" />
|
||||
<Segment value={remaining.hours} label="Hours" />
|
||||
<Segment value={remaining.minutes} label="Mins" />
|
||||
<Segment value={remaining.seconds} label="Secs" />
|
||||
</Group>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -50,6 +50,14 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
|
||||
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",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Stack gap="lg">
|
||||
{booking.status === "SELECTED_FOR_BATCH" && booking.paymentDeadline && (
|
||||
<BookingPaymentCountdownCard paymentDeadline={booking.paymentDeadline} />
|
||||
)}
|
||||
<BookingPaymentCard
|
||||
totalAmount={booking.totalAmount}
|
||||
currency={booking.paymentCurrency}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DocRow, IconSquare } from "./components/Documents";
|
||||
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
|
||||
import { CancelledBanner } from "./components/Notices";
|
||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
||||
import { PaymentCard } from "./components/pricing";
|
||||
import { ScheduleCard } from "./components/ScheduleCard";
|
||||
import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
||||
@@ -32,14 +33,17 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
|
||||
const pricing = booking.pricingBreakdown;
|
||||
const canPay =
|
||||
status === "FULLY_EXECUTED" && booking.paymentStatus !== "PAID";
|
||||
status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
|
||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||
const isExpired = status === "EXPIRED";
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
booking={booking}
|
||||
actions={
|
||||
canPay && (
|
||||
canPay &&
|
||||
!showCountdown && (
|
||||
<HeaderButton
|
||||
green
|
||||
icon={<CreditCard size={16} />}
|
||||
@@ -70,6 +74,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
reason={booking.latestChangeRequestNote}
|
||||
onRebook={() => navigate("/bookings/new")}
|
||||
/>
|
||||
) : isExpired ? (
|
||||
<CancelledBanner
|
||||
pillLabel="Expired"
|
||||
title={`The payment window expired on ${fmtDate(booking.updatedAt)}.`}
|
||||
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
|
||||
onRebook={() => navigate("/bookings/new")}
|
||||
/>
|
||||
) : (
|
||||
<StatusHero booking={booking} />
|
||||
)}
|
||||
@@ -114,6 +125,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
}
|
||||
right={
|
||||
<>
|
||||
{showCountdown && (
|
||||
<PaymentDeadlineCard
|
||||
paymentDeadline={booking.paymentDeadline!}
|
||||
onPay={() => payMutation.mutate()}
|
||||
paying={payMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
<PaymentCard booking={booking} pricing={pricing} />
|
||||
<ScheduleCard
|
||||
booking={booking}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { CreditCard, Timer } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
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 (
|
||||
<Stack gap={2} align="center" style={{ minWidth: 52 }}>
|
||||
<Text
|
||||
fz="28px"
|
||||
fw={800}
|
||||
c="#10202F"
|
||||
lh={1}
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{String(value).padStart(2, "0")}
|
||||
</Text>
|
||||
<Text fz="10.5px" fw={700} c="#9AA8B5" tt="uppercase" className="tracking-[0.6px]">
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
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<Remaining>(() => 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 (
|
||||
<SectionCard p={22}>
|
||||
<Group justify="space-between" align="center">
|
||||
<CardTitle>Payment deadline</CardTitle>
|
||||
<Group
|
||||
component="span"
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
padding: "5px 11px",
|
||||
fontSize: 11.5,
|
||||
fontWeight: 700,
|
||||
backgroundColor: accentBg,
|
||||
color: accentFg,
|
||||
}}
|
||||
>
|
||||
<Timer size={13} />
|
||||
{remaining.expired ? "Expired" : "Pay window open"}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{remaining.expired ? (
|
||||
<Text mt={14} fz="13.5px" c="#6B7C8E">
|
||||
The payment window has closed. Move this booking to another schedule or
|
||||
contact support.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Group justify="space-between" mt={16} wrap="nowrap" px={4}>
|
||||
<Segment value={remaining.days} label="Days" />
|
||||
<Segment value={remaining.hours} label="Hrs" />
|
||||
<Segment value={remaining.minutes} label="Min" />
|
||||
<Segment value={remaining.seconds} label="Sec" />
|
||||
</Group>
|
||||
<Text mt={14} fz="12.5px" c="#9AA8B5" ta="center">
|
||||
Complete payment before the window closes to secure your slot.
|
||||
</Text>
|
||||
{onPay && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt={16}
|
||||
radius={10}
|
||||
color="edr-green"
|
||||
leftSection={<CreditCard size={17} />}
|
||||
onClick={onPay}
|
||||
loading={paying}
|
||||
styles={{ root: { height: 46 }, label: { fontSize: 13.5, fontWeight: 700 } }}
|
||||
>
|
||||
Pay now
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box mt={16} h={1} w="100%" bg="#EEF2F6" />
|
||||
<Text mt={12} fz="12px" c="#9AA8B5">
|
||||
Deadline:{" "}
|
||||
{new Date(paymentDeadline).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user