This commit is contained in:
Marshal
2026-06-16 14:17:40 +00:00
parent 524d3b6418
commit 2d0f5ce6ee
14 changed files with 235 additions and 19 deletions

View File

@@ -1,4 +1,5 @@
import { Badge } from "@mantine/core";
import { Badge, Group } from "@mantine/core";
import { Link2 } from "lucide-react";
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
const statusColorMap: Record<string, string> = {
@@ -24,14 +25,26 @@ const statusColorMap: Record<string, string> = {
CONSOLIDATED: "indigo",
};
export function BookingStatusBadge({ status }: { status: string }) {
interface BookingStatusBadgeProps {
status: string;
/** When the booking is part of a consolidation, show a sibling badge. */
consolidated?: boolean;
/** Partner booking reference for the consolidated badge tooltip. */
partnerReference?: string | null;
}
export function BookingStatusBadge({
status,
consolidated,
partnerReference,
}: BookingStatusBadgeProps) {
const style = BOOKING_STATUS_STYLES[status] ?? {
label: status,
color: "gray",
};
const color = statusColorMap[status] ?? "gray";
return (
const statusBadge = (
<Badge
color={color}
variant="light"
@@ -51,4 +64,34 @@ export function BookingStatusBadge({ status }: { status: string }) {
{style.label}
</Badge>
);
if (!consolidated) return statusBadge;
return (
<Group gap={4} wrap="nowrap">
{statusBadge}
<Badge
color="indigo"
variant="light"
size="sm"
radius="md"
tt="uppercase"
fw={600}
leftSection={<Link2 size={12} />}
title={
partnerReference
? `Consolidated with ${partnerReference}`
: "Part of a consolidation"
}
style={{
fontSize: "0.7rem",
letterSpacing: "0.05em",
display: "inline-flex",
whiteSpace: "nowrap",
}}
>
Consolidated
</Badge>
</Group>
);
}

View File

@@ -36,7 +36,11 @@ export function BookingDetailHeader({
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference}
</Title>
<BookingStatusBadge status={booking.status} />
<BookingStatusBadge
status={booking.status}
consolidated={Boolean(booking.consolidationPartnerId)}
partnerReference={booking.consolidationPartner?.reference}
/>
</Group>
<Group gap="xs">
<Building2 size={14} color="var(--mantine-color-gray-5)" />

View File

@@ -0,0 +1,44 @@
import { useQuery } from "@tanstack/react-query";
import { Alert, List, Text } from "@mantine/core";
import { Link2 } from "lucide-react";
import { bookingsService } from "@/services/bookings.service";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
/**
* Shown on a booking parked in PENDING_CONSOLIDATION. Explains that the booking
* cannot be approved until a complementary booking fills the wagon, and lists
* the partial-wagon container lines that are waiting for a partner.
*/
export function ConsolidationWaitingBanner({ bookingId }: { bookingId: string }) {
const { data } = useQuery({
queryKey: [...QUERY_KEYS.BOOKINGS.byId(bookingId), "consolidation"],
queryFn: () => bookingsService.getConsolidationDetails(bookingId),
enabled: Boolean(bookingId),
});
return (
<Alert
color="yellow"
variant="light"
icon={<Link2 size={18} />}
title="Waiting for a consolidation partner"
>
<Text size="sm">
This booking cannot be approved until a matching booking fills the
wagon. It will return to the approval queue automatically once a partner
is found.
</Text>
{data?.wagonSlots?.length ? (
<List size="sm" mt="xs" spacing={2}>
{data.wagonSlots.map((slot) => (
<List.Item key={slot.containerTypeCode}>
{slot.slotsNeeded} more × {slot.containerTypeCode} (
{slot.containersPerWagon} per wagon; you have {slot.quantity})
</List.Item>
))}
</List>
) : null}
</Alert>
);
}

View File

@@ -347,6 +347,10 @@ export function getBookingActions(
case "CHANGES_REQUESTED":
actions = [CANCEL_ACTION];
break;
case "PENDING_CONSOLIDATION":
// View-only while waiting for a consolidation partner; cancel still allowed.
actions = [CANCEL_ACTION];
break;
default:
actions = [];
}

View File

@@ -212,21 +212,25 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
},
PENDING_CONSOLIDATION: {
title: "Pending Consolidation",
description: "Waiting for consolidation partner.",
description: "Blocked — waiting for a consolidation partner before approval.",
color: "text-amber-600",
stage: 4,
stage: 0,
},
CONSOLIDATED: {
title: "Consolidated",
description: "Paired with another booking.",
color: "text-indigo-600",
stage: 4,
stage: 0,
},
};
export const BOOKING_LIST_TABS = [
{ key: "all", label: "All bookings", statuses: null as string[] | null },
{ key: "intake", label: "Submitted", statuses: ["SUBMITTED"] },
{
key: "intake",
label: "Submitted",
statuses: ["SUBMITTED", "PENDING_CONSOLIDATION"],
},
{
key: "in_approval",
label: "In approval",
@@ -256,7 +260,7 @@ export const BOOKING_LIST_TABS = [
{
key: "operations",
label: "Operations",
statuses: ["PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
statuses: ["PAID", "IN_TRANSIT"],
},
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
@@ -265,7 +269,15 @@ export const BOOKING_LIST_TABS = [
export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"];
export const WORKFLOW_STAGES = [
{ label: "Submission", statuses: ["DRAFT", "SUBMITTED", "CHANGES_REQUESTED"] },
{
label: "Submission",
statuses: [
"DRAFT",
"SUBMITTED",
"CHANGES_REQUESTED",
"PENDING_CONSOLIDATION",
],
},
{
label: "Approval",
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE"],
@@ -286,7 +298,7 @@ export const WORKFLOW_STAGES = [
},
{
label: "Operations",
statuses: ["PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
statuses: ["PAID", "IN_TRANSIT"],
},
{ label: "Done", statuses: ["COMPLETED"] },
] as const;

View File

@@ -42,6 +42,8 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
trainScheduleId: booking.trainScheduleId ?? null,
isGovernment: booking.isGovernment ?? false,
governmentInstitution: booking.governmentInstitution ?? null,
consolidationPartnerId: booking.consolidationPartnerId ?? null,
consolidationPartnerReference: booking.consolidationPartner?.reference ?? null,
createdAt: booking.createdAt,
};
}

View File

@@ -17,6 +17,7 @@ import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
import {
detailStyles,
BookingRequestHero,
@@ -128,6 +129,10 @@ export default function BookingRequestDetailPage() {
titleColor={statusMeta.color}
/>
{booking.status === "PENDING_CONSOLIDATION" && (
<ConsolidationWaitingBanner bookingId={booking.id} />
)}
<Grid gutter="lg">
{/* LEFT — primary content */}
<Grid.Col span={{ base: 12, lg: 8 }}>

View File

@@ -227,7 +227,11 @@ export default function BookingRequestsPage() {
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<div className="py-1">
<BookingStatusBadge status={row.original.status} />
<BookingStatusBadge
status={row.original.status}
consolidated={Boolean(row.original.consolidationPartnerId)}
partnerReference={row.original.consolidationPartnerReference}
/>
</div>
),
meta: {

View File

@@ -87,6 +87,21 @@ export interface ContractView {
} | null;
}
export interface ConsolidationWagonSlot {
containerTypeCode: string;
quantity: number;
containersPerWagon: number;
remainder: number;
slotsNeeded: number;
}
export interface ConsolidationDetails {
statusMessage: string;
wagonSlots: ConsolidationWagonSlot[];
partner: { id: string; reference: string } | null;
splitBilling: { bookingShare: number; partnerShare: number } | null;
}
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
@@ -198,6 +213,13 @@ export const bookingsService = {
return unwrap(response.data);
},
getConsolidationDetails: async (
id: string,
): Promise<ConsolidationDetails> => {
const response = await client.get<ConsolidationDetails>(B.CONSOLIDATION(id));
return unwrap(response.data) as ConsolidationDetails;
},
customerSign: (id: string, payload: SignContractPayload) =>
postBooking<BookingDetail>(B.CUSTOMER_SIGN(id), {
...payload,

View File

@@ -101,6 +101,8 @@ export interface BookingDetail {
cargoTotalWeightVgm: number;
isHazardous: boolean;
allowConsolidation: boolean;
consolidationPartnerId?: string | null;
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
priorityScore: number;
schedulingStatus?: string;
holdExpiresAt?: string | null;
@@ -157,5 +159,7 @@ export interface BookingListRow {
trainScheduleId?: string | null;
isGovernment?: boolean;
governmentInstitution?: string | null;
consolidationPartnerId?: string | null;
consolidationPartnerReference?: string | null;
createdAt: string;
}