feat(bookings): enhance booking details with consolidation notices and streamline form handling

This commit is contained in:
Marshal
2026-06-19 10:45:11 +00:00
parent ed114b1999
commit 3591abd27f
9 changed files with 141 additions and 32 deletions

View File

@@ -12,7 +12,11 @@ import { ActivityCard } from "./components/ActivityCard";
import { ContractCard } from "./components/ContractCard";
import { DocRow, IconSquare } from "./components/Documents";
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
import { CancelledBanner } from "./components/Notices";
import {
CancelledBanner,
ConsolidationPairedNotice,
ConsolidationWaitingBanner,
} from "./components/Notices";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
import { PaymentMethodModal } from "./components/PaymentMethodModal";
@@ -48,6 +52,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
const showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED";
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
// Paired: a consolidation partner was found and the booking resumed the normal
// flow. Surface the "partner found" reassurance only in the early stages,
// before approval, so it doesn't linger for the rest of the booking's life.
const showPairedNotice =
!!booking.consolidationPartnerId &&
["SUBMITTED", "PENDING_APPROVAL", "CHANGES_REQUESTED"].includes(status);
return (
<PageShell>
@@ -92,10 +103,16 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
onRebook={() => navigate("/bookings/new")}
/>
) : isPendingConsolidation ? (
<ConsolidationWaitingBanner
priceLabel={pricing ? priceTotal(pricing) : undefined}
/>
) : (
<StatusHero booking={booking} />
)}
{showPairedNotice && <ConsolidationPairedNotice />}
<ContractCard booking={booking} navigate={navigate} />
<BodyGrid

View File

@@ -1,5 +1,13 @@
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { AlertCircle, AlertTriangle, PencilLine, StickyNote, XCircle } from "lucide-react";
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
Link2,
PencilLine,
StickyNote,
XCircle,
} from "lucide-react";
import type { ReactNode } from "react";
export function NoticeBanner({
@@ -186,6 +194,90 @@ export function CancelledBanner({
);
}
/**
* Shown to the customer while their booking is PENDING_CONSOLIDATION: it is
* waiting for another shipment to share the wagon. The price shown is this
* booking's own held amount — bookings and contracts are independent, so the
* partner's amount is never shown. Once a partner is found the backend moves
* the booking back to SUBMITTED and it continues the normal flow.
*/
export function ConsolidationWaitingBanner({
priceLabel,
}: {
priceLabel?: string;
}) {
return (
<Paper radius={16} p={20} bg="#FDF3E0" className="border border-[#F4D9A8]">
<Group justify="space-between" align="center" wrap="wrap" gap={20}>
<Group gap={16} align="center" wrap="nowrap" miw={0}>
<div
className="flex shrink-0 items-center justify-center rounded-[13px] border border-[#F4D9A8]"
style={{ width: 46, height: 46, backgroundColor: "#fff", color: "#C77F12" }}
>
<Link2 size={24} />
</div>
<Box miw={0}>
<span
className="inline-flex rounded-full px-[10px] py-1 text-[10.5px] font-extrabold uppercase tracking-[0.3px] text-white"
style={{ backgroundColor: "#C77F12" }}
>
Waiting for a partner
</span>
<Text mt={6} fz="15.5px" fw={700} c="#10202F">
Your shipment is waiting to share a wagon
</Text>
<Text mt={2} fz="13px" c="#7A6A4E" className="leading-[1.45]">
Your cargo only fills part of a wagon, so were pairing it with
another shipment on the same route to share the space. As soon as a
matching shipment is found, your booking continues automatically
acceptance, approval and contract stay independent and yours alone.
</Text>
</Box>
</Group>
{priceLabel && (
<div
className="flex shrink-0 flex-col items-end rounded-xl border border-[#F4D9A8] px-[16px] py-[12px]"
style={{ backgroundColor: "#fff" }}
>
<Text fz="10.5px" fw={700} c="#B07A2A" tt="uppercase" className="tracking-[0.5px]">
Your price (held)
</Text>
<Text mt={3} fz="18px" fw={800} c="#10202F">
{priceLabel}
</Text>
</div>
)}
</Group>
</Paper>
);
}
/**
* A brief positive notice shown once a consolidation partner has been found and
* the booking has resumed the normal flow (SUBMITTED with a partner linked).
* Reassures the customer the wait ended; the booking proceeds independently.
*/
export function ConsolidationPairedNotice() {
return (
<div
className="flex items-start gap-3 rounded-[14px] border p-4"
style={{ borderColor: "#BFE6C9", backgroundColor: "#EAF7EE", color: "#1B7A3D" }}
>
<CheckCircle2 size={18} className="mt-0.5 shrink-0" />
<div className="min-w-0">
<Text fz="13.5px" fw={800} c="#1B7A3D">
Consolidation partner found
</Text>
<Text fz="13px" c="#2E6B43" className="leading-[1.45]">
A matching shipment was found to share the wagon, so your booking is
back on track and now moving through review and approval as usual.
Nothing more is needed from you for now.
</Text>
</div>
</div>
);
}
export function MutationErrors({
mutations,
}: {

View File

@@ -61,7 +61,6 @@ export function ScheduleCard({
: "Rail only";
const equipmentReturn =
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
const consolidation = booking.allowConsolidation ? "Allowed" : "Not allowed";
const assignedTrain: Row = {
label: "Assigned train",
value: booking.trainId ?? "Not yet assigned",
@@ -80,7 +79,6 @@ export function ScheduleCard({
{ label: "Equipment return", value: equipmentReturn },
assignedTrain,
{ label: "Scheduled", value: fmtDate(booking.scheduledDate) },
{ label: "Consolidation", value: consolidation },
]
: [
statusRow,
@@ -88,7 +86,6 @@ export function ScheduleCard({
{ label: "Equipment return", value: equipmentReturn },
{ label: "Proposed date", value: fmtDate(booking.scheduledDate) },
assignedTrain,
{ label: "Consolidation", value: consolidation },
];
return (

View File

@@ -44,10 +44,7 @@ export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking })
],
["Scheduled date", fmtDate(booking.scheduledDate)],
],
[
["Consolidation", booking.allowConsolidation ? "Allowed" : "Not allowed"],
["Assigned train", booking.trainId ?? "Not yet assigned"],
],
[["Assigned train", booking.trainId ?? "Not yet assigned"]],
];
return (

View File

@@ -359,10 +359,16 @@ export default function EditBookingPage() {
const shippingLineOptions = useMemo(() => {
if (!referenceData?.shipping_line) return [];
return referenceData.shipping_line.map((sl) => ({
value: sl.name,
label: sl.name,
}));
// Dedupe by name (the value the form keys on) so two lines sharing a name
// can't produce a duplicate Select option and crash Mantine.
const seen = new Set<string>();
const options: { value: string; label: string }[] = [];
for (const sl of referenceData.shipping_line) {
if (!sl.name || seen.has(sl.name)) continue;
seen.add(sl.name);
options.push({ value: sl.name, label: sl.name });
}
return options;
}, [referenceData]);
const setDocument = (key: string, file: File | null) => {

View File

@@ -138,7 +138,10 @@ export const bookingFormSchema = z
.refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"),
}),
),
consolidationEnabled: z.boolean(),
// Consolidation is system-managed, not a customer choice. The backend only
// consolidates partial-wagon bookings, so this is always allowed; the
// customer neither sees nor toggles it.
consolidationEnabled: z.boolean().default(true),
documents: z.record(z.string(), z.any()).default({}),
notes: z.string(),
})
@@ -243,7 +246,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
isHazardous: false,
isRefrigerated: false,
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
consolidationEnabled: false,
consolidationEnabled: true,
documents: {},
notes: "",
};
@@ -265,13 +268,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"isRefrigerated",
"shippingLine",
],
4: [
"cargoType",
"cargoWeight",
"cargoTypePath",
"containers",
"consolidationEnabled",
],
4: ["cargoType", "cargoWeight", "cargoTypePath", "containers"],
5: ["scheduledDate"],
6: ["documents"],
7: ["notes"],

View File

@@ -174,8 +174,8 @@ export function Step1ContractType({
form.setValue("containers", mappedContainers);
}
// ── Consolidation ───────────────────────────────────────────────────
form.setValue("consolidationEnabled", booking.allowConsolidation);
// Consolidation is system-managed (always allowed) — not copied from the
// previous booking and not customer-controllable.
// ── Scheduled date ──────────────────────────────────────────────────
if (booking.scheduledDate) {

View File

@@ -35,10 +35,17 @@ export function Step4Route({
const shippingLineOptions = useMemo(() => {
if (!referenceData?.shipping_line) return [];
return referenceData.shipping_line.map((sl) => ({
value: sl.name,
label: sl.name,
}));
// The form keys shipping line by name, so options are keyed by name too.
// Dedupe by name: if the reference data has two lines sharing a name, a
// duplicate option would crash Mantine's Select ("Duplicate options...").
const seen = new Set<string>();
const options: { value: string; label: string }[] = [];
for (const sl of referenceData.shipping_line) {
if (!sl.name || seen.has(sl.name)) continue;
seen.add(sl.name);
options.push({ value: sl.name, label: sl.name });
}
return options;
}, [referenceData]);
const originData = useMemo(() => {

View File

@@ -338,10 +338,6 @@ export function Step8Review({
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
/>
<DetailRow
label="Consolidation"
value={values.consolidationEnabled ? "Allowed" : "Not allowed"}
/>
{values.cargoType === "container" && values.containers.length > 0 && (
<Table mt="sm" withTableBorder withColumnBorders fz="sm">
<Table.Thead>