mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +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 { ContractCard } from "./components/ContractCard";
|
||||||
import { DocRow, IconSquare } from "./components/Documents";
|
import { DocRow, IconSquare } from "./components/Documents";
|
||||||
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
|
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 { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||||
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
||||||
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
||||||
@@ -48,6 +52,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
|
status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
|
||||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||||
const isExpired = status === "EXPIRED";
|
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 (
|
return (
|
||||||
<PageShell>
|
<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."
|
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
|
||||||
onRebook={() => navigate("/bookings/new")}
|
onRebook={() => navigate("/bookings/new")}
|
||||||
/>
|
/>
|
||||||
|
) : isPendingConsolidation ? (
|
||||||
|
<ConsolidationWaitingBanner
|
||||||
|
priceLabel={pricing ? priceTotal(pricing) : undefined}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<StatusHero booking={booking} />
|
<StatusHero booking={booking} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showPairedNotice && <ConsolidationPairedNotice />}
|
||||||
|
|
||||||
<ContractCard booking={booking} navigate={navigate} />
|
<ContractCard booking={booking} navigate={navigate} />
|
||||||
|
|
||||||
<BodyGrid
|
<BodyGrid
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
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";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export function NoticeBanner({
|
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({
|
export function MutationErrors({
|
||||||
mutations,
|
mutations,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ export function ScheduleCard({
|
|||||||
: "Rail only";
|
: "Rail only";
|
||||||
const equipmentReturn =
|
const equipmentReturn =
|
||||||
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
|
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
|
||||||
const consolidation = booking.allowConsolidation ? "Allowed" : "Not allowed";
|
|
||||||
const assignedTrain: Row = {
|
const assignedTrain: Row = {
|
||||||
label: "Assigned train",
|
label: "Assigned train",
|
||||||
value: booking.trainId ?? "Not yet assigned",
|
value: booking.trainId ?? "Not yet assigned",
|
||||||
@@ -80,7 +79,6 @@ export function ScheduleCard({
|
|||||||
{ label: "Equipment return", value: equipmentReturn },
|
{ label: "Equipment return", value: equipmentReturn },
|
||||||
assignedTrain,
|
assignedTrain,
|
||||||
{ label: "Scheduled", value: fmtDate(booking.scheduledDate) },
|
{ label: "Scheduled", value: fmtDate(booking.scheduledDate) },
|
||||||
{ label: "Consolidation", value: consolidation },
|
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
statusRow,
|
statusRow,
|
||||||
@@ -88,7 +86,6 @@ export function ScheduleCard({
|
|||||||
{ label: "Equipment return", value: equipmentReturn },
|
{ label: "Equipment return", value: equipmentReturn },
|
||||||
{ label: "Proposed date", value: fmtDate(booking.scheduledDate) },
|
{ label: "Proposed date", value: fmtDate(booking.scheduledDate) },
|
||||||
assignedTrain,
|
assignedTrain,
|
||||||
{ label: "Consolidation", value: consolidation },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -44,10 +44,7 @@ export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking })
|
|||||||
],
|
],
|
||||||
["Scheduled date", fmtDate(booking.scheduledDate)],
|
["Scheduled date", fmtDate(booking.scheduledDate)],
|
||||||
],
|
],
|
||||||
[
|
[["Assigned train", booking.trainId ?? "Not yet assigned"]],
|
||||||
["Consolidation", booking.allowConsolidation ? "Allowed" : "Not allowed"],
|
|
||||||
["Assigned train", booking.trainId ?? "Not yet assigned"],
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -359,10 +359,16 @@ export default function EditBookingPage() {
|
|||||||
|
|
||||||
const shippingLineOptions = useMemo(() => {
|
const shippingLineOptions = useMemo(() => {
|
||||||
if (!referenceData?.shipping_line) return [];
|
if (!referenceData?.shipping_line) return [];
|
||||||
return referenceData.shipping_line.map((sl) => ({
|
// Dedupe by name (the value the form keys on) so two lines sharing a name
|
||||||
value: sl.name,
|
// can't produce a duplicate Select option and crash Mantine.
|
||||||
label: sl.name,
|
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]);
|
}, [referenceData]);
|
||||||
|
|
||||||
const setDocument = (key: string, file: File | null) => {
|
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"),
|
.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({}),
|
documents: z.record(z.string(), z.any()).default({}),
|
||||||
notes: z.string(),
|
notes: z.string(),
|
||||||
})
|
})
|
||||||
@@ -243,7 +246,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
|||||||
isHazardous: false,
|
isHazardous: false,
|
||||||
isRefrigerated: false,
|
isRefrigerated: false,
|
||||||
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
|
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
|
||||||
consolidationEnabled: false,
|
consolidationEnabled: true,
|
||||||
documents: {},
|
documents: {},
|
||||||
notes: "",
|
notes: "",
|
||||||
};
|
};
|
||||||
@@ -265,13 +268,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
|||||||
"isRefrigerated",
|
"isRefrigerated",
|
||||||
"shippingLine",
|
"shippingLine",
|
||||||
],
|
],
|
||||||
4: [
|
4: ["cargoType", "cargoWeight", "cargoTypePath", "containers"],
|
||||||
"cargoType",
|
|
||||||
"cargoWeight",
|
|
||||||
"cargoTypePath",
|
|
||||||
"containers",
|
|
||||||
"consolidationEnabled",
|
|
||||||
],
|
|
||||||
5: ["scheduledDate"],
|
5: ["scheduledDate"],
|
||||||
6: ["documents"],
|
6: ["documents"],
|
||||||
7: ["notes"],
|
7: ["notes"],
|
||||||
|
|||||||
@@ -174,8 +174,8 @@ export function Step1ContractType({
|
|||||||
form.setValue("containers", mappedContainers);
|
form.setValue("containers", mappedContainers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Consolidation ───────────────────────────────────────────────────
|
// Consolidation is system-managed (always allowed) — not copied from the
|
||||||
form.setValue("consolidationEnabled", booking.allowConsolidation);
|
// previous booking and not customer-controllable.
|
||||||
|
|
||||||
// ── Scheduled date ──────────────────────────────────────────────────
|
// ── Scheduled date ──────────────────────────────────────────────────
|
||||||
if (booking.scheduledDate) {
|
if (booking.scheduledDate) {
|
||||||
|
|||||||
@@ -35,10 +35,17 @@ export function Step4Route({
|
|||||||
|
|
||||||
const shippingLineOptions = useMemo(() => {
|
const shippingLineOptions = useMemo(() => {
|
||||||
if (!referenceData?.shipping_line) return [];
|
if (!referenceData?.shipping_line) return [];
|
||||||
return referenceData.shipping_line.map((sl) => ({
|
// The form keys shipping line by name, so options are keyed by name too.
|
||||||
value: sl.name,
|
// Dedupe by name: if the reference data has two lines sharing a name, a
|
||||||
label: sl.name,
|
// 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]);
|
}, [referenceData]);
|
||||||
|
|
||||||
const originData = useMemo(() => {
|
const originData = useMemo(() => {
|
||||||
|
|||||||
@@ -338,10 +338,6 @@ export function Step8Review({
|
|||||||
label="Total VGM"
|
label="Total VGM"
|
||||||
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
|
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
|
||||||
/>
|
/>
|
||||||
<DetailRow
|
|
||||||
label="Consolidation"
|
|
||||||
value={values.consolidationEnabled ? "Allowed" : "Not allowed"}
|
|
||||||
/>
|
|
||||||
{values.cargoType === "container" && values.containers.length > 0 && (
|
{values.cargoType === "container" && values.containers.length > 0 && (
|
||||||
<Table mt="sm" withTableBorder withColumnBorders fz="sm">
|
<Table mt="sm" withTableBorder withColumnBorders fz="sm">
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
|
|||||||
Reference in New Issue
Block a user