mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
feat(bookings): enhance booking details with consolidation notices and streamline form handling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 we’re 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,
|
||||
}: {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user