feat: Implement consolidated booking functionality

- Added support for viewing and managing consolidated bookings in BookingRequestDetailPage.
- Enhanced BookingRequestsPage to display paired bookings in a single row.
- Introduced pairedDecision method in bookings service to handle decisions for both halves of a consolidated pair.
- Updated contracts service to include methods for manual consolidation of odd-20ft bookings.
- Created new components for selecting and editing consolidation partners.
- Added tests for paired decision logic and manual consolidation scenarios.
- Updated UI to reflect changes in booking handling and provide user feedback for odd container counts.
This commit is contained in:
Marshal
2026-08-18 12:50:35 +00:00
parent c723b660e2
commit 22a3fb98ee
25 changed files with 2121 additions and 34 deletions

View File

@@ -38,6 +38,7 @@ export function BookingActionsMenu({
reference: row.reference,
schedulingStatus: row.schedulingStatus,
customsClearingEnabled: row.customsClearingEnabled,
consolidationPartnerId: row.consolidationPartnerId,
};
const flow = useBookingActionDialog(row.id, context);
@@ -92,7 +93,13 @@ export function BookingActionsMenu({
);
})}
</Group>
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
<ActionDialog
flow={flow}
pendingAction={pendingAction}
onSuppressRowClick={onSuppressRowClick}
consolidationPartnerId={row.consolidationPartnerId}
consolidationPartnerReference={row.consolidationPartnerReference}
/>
</>
);
}
@@ -149,7 +156,13 @@ export function BookingActionsMenu({
</Menu.Dropdown>
</Menu>
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
<ActionDialog
flow={flow}
pendingAction={pendingAction}
onSuppressRowClick={onSuppressRowClick}
consolidationPartnerId={row.consolidationPartnerId}
consolidationPartnerReference={row.consolidationPartnerReference}
/>
</Group>
);
}
@@ -158,10 +171,14 @@ function ActionDialog({
flow,
pendingAction,
onSuppressRowClick,
consolidationPartnerId,
consolidationPartnerReference,
}: {
flow: ReturnType<typeof useBookingActionDialog>;
pendingAction: ReturnType<typeof useBookingActionDialog>["pendingAction"];
onSuppressRowClick?: () => void;
consolidationPartnerId?: string | null;
consolidationPartnerReference?: string | null;
}) {
return (
<BookingConfirmDialog
@@ -182,6 +199,17 @@ function ActionDialog({
}}
isPending={flow.mutations.isPending || flow.detailLoading}
confirmDisabled={flow.confirmDisabled}
// Only the four pairable decisions land on both halves; the rest stay
// per booking, so the warning must not appear for them.
pairedWithReference={
consolidationPartnerId &&
pendingAction &&
["accept", "cancel", "operationAccept", "requestChanges"].includes(
pendingAction.id,
)
? (consolidationPartnerReference ?? "its wagon partner")
: null
}
/>
);
}

View File

@@ -1,5 +1,7 @@
import type { ReactNode } from "react";
import { Link2 } from "lucide-react";
import {
Alert,
Modal,
Group,
Stack,
@@ -37,6 +39,12 @@ interface BookingConfirmDialogProps {
isPending: boolean;
confirmDisabled?: boolean;
extra?: ReactNode;
/**
* Reference of the booking sharing this one's wagon. When set, the dialog
* warns that the decision lands on BOTH bookings — staff must not think they
* are acting on one.
*/
pairedWithReference?: string | null;
}
export function BookingConfirmDialog({
@@ -52,6 +60,7 @@ export function BookingConfirmDialog({
isPending,
confirmDisabled = false,
extra,
pairedWithReference = null,
}: BookingConfirmDialogProps) {
if (!action || !action.confirmTitle) return null;
@@ -125,6 +134,21 @@ export function BookingConfirmDialog({
{action.confirmDescription}
</Text>
)}
{pairedWithReference && (
<Alert
color="blue"
variant="light"
radius="md"
mt="sm"
icon={<Link2 size={16} />}
>
<Text size="sm">
This applies to <strong>{pairedWithReference}</strong> as well
the two bookings share a wagon and are decided together. If either
fails, neither changes.
</Text>
</Alert>
)}
</Box>
{/* Body */}

View File

@@ -14,6 +14,19 @@ function isValidValidityDays(value: string): boolean {
return Number.isInteger(days) && days >= 1 && days <= 365;
}
/**
* Decisions that must be applied to BOTH halves of a consolidated pair. The two
* bookings share one wagon: accepting one alone would put half a wagon into the
* approval chain, and cancelling one alone would strand the other on a wagon it
* can no longer fill.
*/
const PAIRED_DECISIONS = {
accept: "accept",
cancel: "cancel",
operationAccept: "operationAccept",
requestChanges: "requestChanges",
} as const;
export function useBookingActionDialog(
bookingId: string,
context: BookingActionContext,
@@ -52,6 +65,30 @@ export function useBookingActionDialog(
const onSuccess = () => closeDialog();
// A booking on a shared wagon routes the four pairable decisions through the
// paired endpoint, which applies them to both halves all-or-nothing. Every
// other action stays per booking.
const pairedDecision =
PAIRED_DECISIONS[pendingAction.id as keyof typeof PAIRED_DECISIONS];
if (context.consolidationPartnerId && pairedDecision) {
if (pairedDecision === "accept") {
const days = Number(inputValue.trim());
if (!Number.isInteger(days) || days < 1 || days > 365) return;
mutations.pairedDecision.mutate(
{ decision: "accept", validityDays: days },
{ onSuccess },
);
return;
}
mutations.pairedDecision.mutate(
pairedDecision === "cancel"
? { decision: "cancel", reason: inputValue.trim() }
: { decision: pairedDecision, note: inputValue.trim() },
{ onSuccess },
);
return;
}
switch (pendingAction.id) {
case "accept": {
const days = Number(inputValue.trim());

View File

@@ -11,6 +11,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Center,
@@ -40,6 +41,7 @@ import {
FileText,
FileUp,
Flame,
Link2,
MapPin,
Package,
Receipt,
@@ -57,7 +59,10 @@ import {
import { api } from "@/services/api";
import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader";
import { contractsService } from "@/services/contracts.service";
import {
contractsService,
type ConsolidationCandidate,
} from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
import {
useContractCapacity,
@@ -80,6 +85,18 @@ import {
StepHeader,
StepLabel,
} from "./gl-booking-form/form-ui";
import {
ConsolidationPartnerPanel,
emptyPartnerLine,
} from "./gl-booking-form/ConsolidationPartnerPanel";
import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker";
/**
* Container sizes offered on the parent-booking panel. Fixed rather than taken
* from this contract's scope: the parent booking is a different customer on a
* different contract, so its sizes are its own.
*/
const PARTNER_SIZES = ["20ft", "40ft"];
/** All booking-window times are communicated in East Africa Time. */
const EAT_TZ = "Africa/Addis_Ababa";
@@ -240,6 +257,14 @@ export default function GlCreateBookingForm() {
enabled: Boolean(copyFromParam),
});
// The booking being completed — used to name the customer on the price
// confirmation when a second booking's price is shown beside it.
const { data: completeBooking } = useQuery({
queryKey: ["gl-complete-booking", completeBookingId],
queryFn: () => bookingsService.getById(completeBookingId!),
enabled: Boolean(completeBookingId),
});
// Same window-gating the customer sees: booking is only allowed while a
// window is OPEN for one of the contract's routes. Intercity contracts are
// never window-gated — the shipment rides a passing train staff pick later.
@@ -290,6 +315,18 @@ export default function GlCreateBookingForm() {
const [withReturn, setWithReturn] = useState(false);
const [prefilled, setPrefilled] = useState(false);
const [priceOpen, setPriceOpen] = useState(false);
// ── Odd-20ft shared wagon (customs / Path B) ──────────────────────────────
// An odd 20ft total leaves one container unpaired. On a customs contract GL
// resolves that here by linking a second booking that is also odd — two odd
// counts always sum to even — completing both together onto the shared wagon.
const [consolidateOdd, setConsolidateOdd] = useState(false);
// Set once GL flips the toggle by hand, so the auto-on effect below never
// re-opens a panel GL deliberately closed.
const consolidateTouchedRef = useRef(false);
const [partnerPickerOpen, setPartnerPickerOpen] = useState(false);
const [partner, setPartner] = useState<ConsolidationCandidate | null>(null);
const [partnerLines, setPartnerLines] = useState<ContainerLineDraft[]>([]);
const [partnerCargoDescription, setPartnerCargoDescription] = useState("");
const seededRef = useRef(false);
const returnSeededRef = useRef(false);
@@ -834,6 +871,48 @@ export default function GlCreateBookingForm() {
}, [isContainer, containerLines]);
const hasOdd20ft = ft20Total % 2 === 1;
// Only a customs (Path B) instance being COMPLETED by GL can use the shared
// wagon: it is GL, not the customer, who links the two bookings. Anything else
// keeps the historical hard block on odd 20ft.
const oddConsolidationAvailable = Boolean(
completeBookingId && isContainer && contract?.customsClearingEnabled,
);
// Auto-on: entering an odd 20ft total opens the consolidation panel by itself,
// once. GL can still switch it off — then odd is blocked exactly as before.
useEffect(() => {
if (!oddConsolidationAvailable) return;
if (consolidateTouchedRef.current) return;
if (hasOdd20ft) setConsolidateOdd(true);
}, [oddConsolidationAvailable, hasOdd20ft]);
// Clear the partner as soon as the panel closes or stops applying, so a
// leftover selection can never ride along into a plain single-booking submit.
useEffect(() => {
if (consolidateOdd && oddConsolidationAvailable) return;
setPartner(null);
setPartnerLines([]);
setPartnerCargoDescription("");
}, [consolidateOdd, oddConsolidationAvailable]);
const consolidationActive =
oddConsolidationAvailable && consolidateOdd && hasOdd20ft;
// Once a parent booking is linked, each booking's cargo is entered under its
// own labelled heading so it is clear which containers belong to whom.
const splitView = Boolean(consolidationActive && partner);
const candidatesQuery = useQuery({
queryKey: ["consolidation-candidates", id, completeBookingId],
queryFn: () =>
contractsService.listConsolidationCandidates(
id ?? "",
completeBookingId ?? "",
),
enabled:
partnerPickerOpen && Boolean(id) && Boolean(completeBookingId),
});
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
const bulkErrors = useMemo<BulkErrors>(() => {
@@ -886,7 +965,64 @@ export default function GlCreateBookingForm() {
!cargoDescriptionError
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError;
// The unpaired 20ft container is resolved by the shared wagon, so with an
// active consolidation an odd total stops being a blocker; without one it
// blocks exactly as before.
const oddBlocksSubmit = hasOdd20ft && !consolidationActive;
// Partner side: a linked partner must be picked, carry an odd 20ft count of
// its own (odd + odd = even fills the wagon) and have complete unit details.
const partnerFt20Total = useMemo(() => {
if (!consolidationActive) return 0;
return partnerLines
.filter((l) => parseInt(l.containerSize, 10) === 20)
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
}, [consolidationActive, partnerLines]);
const partnerError = useMemo<string | undefined>(() => {
if (!consolidationActive) return undefined;
if (!partner) return "Select the booking that shares this wagon.";
const totalQty = partnerLines.reduce(
(sum, l) => sum + Math.max(0, Number(l.quantity) || 0),
0,
);
if (totalQty < 1) {
return `Enter the containers for ${partner.reference}.`;
}
if (partnerFt20Total % 2 === 0) {
return `${partner.reference} must also carry an odd number of 20ft containers so the two bookings fill whole wagons together (it has ${partnerFt20Total}).`;
}
const incomplete = partnerLines.some((line) => {
const qty = Number(line.quantity || 0);
return qty >= 1 && line.units.length < qty;
});
if (incomplete) {
return `Enter the container details for all of ${partner.reference}'s containers.`;
}
const badUnit = partnerLines.some((line) =>
line.units.some(
(u) =>
!ISO_CONTAINER_NUMBER_REGEX.test(u.containerNumber.trim().toUpperCase()) ||
!(Number(u.vgmTons) > 0),
),
);
if (badUnit) {
return `Every ${partner.reference} container needs a valid container number and a VGM above 0.`;
}
if (!partnerCargoDescription.trim()) {
return `Describe the cargo carried in ${partner.reference}'s containers.`;
}
return undefined;
}, [
consolidationActive,
partner,
partnerLines,
partnerFt20Total,
partnerCargoDescription,
]);
const formValid =
cargoValid && !oddBlocksSubmit && !dateError && !routeError && !partnerError;
/** The create-booking DTO from the current form state — shared by the
* authoritative price preview and the actual submit so what GL confirms is
@@ -953,6 +1089,44 @@ export default function GlCreateBookingForm() {
return payload;
};
/**
* Completion DTO for the partner half of a shared wagon. Route, day and train
* are deliberately copied from THIS booking: the two bookings ride the same
* wagon, so they must ride the same train on the same day. Only the cargo and
* the billing currency belong to the partner.
*/
const buildPartnerPayload = (): Freight.CreateBookingUnderContractDto | null => {
if (!partner || !consolidationActive) return null;
const payload: Freight.CreateBookingUnderContractDto = {
paymentCurrency,
...(scheduledDate
? { scheduledDate: new Date(scheduledDate).toISOString() }
: {}),
...(trainScheduleId ? { trainScheduleId } : {}),
...(partnerCargoDescription.trim()
? { cargoFreeText: partnerCargoDescription.trim() }
: {}),
containers: partnerLines
.filter((l) => Number(l.quantity) >= 1)
.map((l) => ({
containerSize: l.containerSize,
quantity: Number(l.quantity),
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
units: l.units.map((u) => ({
containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
vgmTons: Number(u.vgmTons) || 0,
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
})),
})),
};
return payload;
};
// Authoritative price preview (same pricing pass the booking persists at
// create): rail freight + first/last mile + overweight + every surcharge,
// plus the hard-block checks (20ft pairing, max capacity, container numbers
@@ -964,6 +1138,22 @@ export default function GlCreateBookingForm() {
});
const validation = validateShipmentMutation.data ?? null;
// The partner is priced against ITS OWN contract, so the two totals shown in
// the confirm modal are each customer's real bill — nobody pays for the other.
const validatePartnerMutation = useMutation({
mutationFn: (input: {
contractId: string;
bookingId: string;
dto: Freight.CreateBookingUnderContractDto;
}) =>
contractsService.validateShipment(
input.contractId,
input.dto,
input.bookingId,
),
});
const partnerValidation = validatePartnerMutation.data ?? null;
const serverTotal = useMemo(() => {
const items = validation?.lineItems;
if (!items?.length) return null;
@@ -1010,8 +1200,52 @@ export default function GlCreateBookingForm() {
};
}, [serverTotal, priceTotal, overweightSurchargeAmount]);
const partnerTotal = useMemo(() => {
const items = partnerValidation?.lineItems;
if (!items?.length) return null;
return {
currency: partnerValidation?.currency ?? "ETB",
lines: items.map((li) => ({
label: li.description,
unitPrice: li.unitAmount,
unit: li.unit.toLowerCase(),
quantity: li.quantity,
amount: li.amount,
})),
total:
partnerValidation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0),
};
}, [partnerValidation]);
// The partner half must clear the same hard blocks as this one — the pair is
// booked all-or-nothing, so a block on either side blocks both.
const partnerBlockers = useMemo(() => {
if (!consolidationActive || !partnerValidation) return [];
return [
...(partnerValidation.pairingErrors ?? []),
...(partnerValidation.capacityErrors ?? []),
...(partnerValidation.containerClashErrors ?? []),
...(partnerValidation.spaceErrors ?? []),
];
}, [consolidationActive, partnerValidation]);
const completePairMutation = useMutation({
mutationFn: (input: {
payload: Freight.CreateBookingUnderContractDto;
partnerPayload: Freight.CreateBookingUnderContractDto;
partnerBookingId: string;
}) =>
contractsService.completeConsolidatedPair(id ?? "", completeBookingId ?? "", {
partnerBookingId: input.partnerBookingId,
booking: input.payload,
partner: input.partnerPayload,
}),
});
const submitPending =
mutations.createBooking.isPending || mutations.completeBooking.isPending;
mutations.createBooking.isPending ||
mutations.completeBooking.isPending ||
completePairMutation.isPending;
// Block confirm until the authoritative server price is in hand — the client
// estimate is display-only; booking on it would confirm an un-validated,
@@ -1023,7 +1257,13 @@ export default function GlCreateBookingForm() {
capacityErrors.length > 0 ||
containerClashErrors.length > 0 ||
spaceErrors.length > 0 ||
!serverTotal;
!serverTotal ||
// Same bar for the shared-wagon partner: its authoritative price must be in
// hand and its own hard blocks clear before either booking is confirmed.
(consolidationActive &&
(validatePartnerMutation.isPending ||
!partnerTotal ||
partnerBlockers.length > 0));
const openPriceModal = () => {
// Surface the per-field errors (portal-parity validation) instead of
@@ -1039,6 +1279,15 @@ export default function GlCreateBookingForm() {
validateShipmentMutation.reset();
validateShipmentMutation.mutate(payload);
}
validatePartnerMutation.reset();
const partnerPayload = buildPartnerPayload();
if (partnerPayload && partner?.contractId) {
validatePartnerMutation.mutate({
contractId: partner.contractId,
bookingId: partner.id,
dto: partnerPayload,
});
}
};
const handleSubmit = () => {
@@ -1054,6 +1303,25 @@ export default function GlCreateBookingForm() {
const payload = buildPayload();
if (!payload) return;
// Shared wagon: both halves complete together, all-or-nothing on the server.
if (consolidationActive && partner && completeBookingId) {
// A hard block on the partner's own price preview blocks the pair.
if (partnerBlockers.length > 0) return;
const partnerPayload = buildPartnerPayload();
if (!partnerPayload) return;
completePairMutation.mutate(
{
payload,
partnerPayload,
partnerBookingId: partner.id,
},
{
onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`),
},
);
return;
}
if (completeBookingId) {
// Completion mode: cargo + day land on the already-cleared instance —
// the request was linked and accepted at submission time.
@@ -1347,6 +1615,18 @@ export default function GlCreateBookingForm() {
maxRows={4}
styles={fieldStyles}
/>
{/* With a parent booking linked, each booking's containers are
entered in its own labelled section, one after the other. */}
{splitView ? (
<Group gap={8} align="center">
<Badge color="edr-green" variant="light" radius="sm">
{completeBooking?.reference ?? "This booking"}
</Badge>
<Text fz={13} fw={600} c="#10202F">
{completeBooking?.company?.name ?? "—"}
</Text>
</Group>
) : null}
{containerLines.length === 0 ? (
<Text fz="sm" c="dimmed">
This contract has no container sizes in scope.
@@ -1526,7 +1806,71 @@ export default function GlCreateBookingForm() {
))
)}
{hasOdd20ft ? (
{hasOdd20ft && oddConsolidationAvailable ? (
<Alert
color={consolidateOdd ? "edr-green" : "red"}
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Total})`}
>
<Stack gap={10}>
<Text fz={13}>
20ft containers travel two per wagon, so one container here
is unpaired. On a customs booking you can pair it with
another customer's odd booking and complete both onto the
shared wagon — each booking is still priced and invoiced
separately.
</Text>
<Switch
checked={consolidateOdd}
color="edr-green"
label="Share a wagon with another booking"
onChange={(e) => {
consolidateTouchedRef.current = true;
setConsolidateOdd(e.currentTarget.checked);
}}
/>
{consolidateOdd ? (
<Group gap={10} align="center" wrap="wrap">
<Button
size="xs"
radius="md"
variant="light"
color="edr-green"
leftSection={<Link2 size={14} />}
onClick={() => setPartnerPickerOpen(true)}
>
{partner
? `Parent booking: ${partner.reference} — change`
: "Parent booking"}
</Button>
{partner ? (
<Button
size="xs"
radius="md"
variant="subtle"
color="gray"
onClick={() => {
setPartner(null);
setPartnerLines([]);
setPartnerCargoDescription("");
}}
>
Remove
</Button>
) : null}
</Group>
) : (
<Text fz={12.5} c="red.7">
With sharing off, book an even number of 20ft containers
— add one more or remove one (e.g. {ft20Total + 1} or{" "}
{ft20Total - 1} instead of {ft20Total}).
</Text>
)}
</Stack>
</Alert>
) : hasOdd20ft ? (
<Alert
color="red"
variant="light"
@@ -1540,6 +1884,34 @@ export default function GlCreateBookingForm() {
— the booking cannot be created with an unpaired 20ft container.
</Alert>
) : null}
{splitView && partner ? (
<>
<Divider my={4} />
<Group gap={8} align="center">
<Badge color="blue" variant="light" radius="sm">
{partner.reference}
</Badge>
<Text fz={13} fw={600} c="#10202F">
{partner.companyName ?? "—"}
</Text>
</Group>
<Text fz={12.5} c="dimmed">
Parent booking — ships on the same day and train, billed to
its own customer.
</Text>
<ConsolidationPartnerPanel
lines={partnerLines}
onLinesChange={setPartnerLines}
cargoDescription={partnerCargoDescription}
onCargoDescriptionChange={setPartnerCargoDescription}
showHazardous={Boolean(contract.isHazardous)}
showReefer={Boolean(contract.isReefer)}
showErrors={showErrors}
error={partnerError}
/>
</>
) : null}
</Stack>
</StepCard>
) : (
@@ -1806,12 +2178,31 @@ export default function GlCreateBookingForm() {
>
Fix the highlighted fields before reviewing the price.
</Alert>
) : partnerError ? (
// The review button is disabled while the parent booking is
// incomplete, so the click that would reveal the errors never
// lands — say what is outstanding without waiting for it.
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
{partnerError}
</Alert>
) : null}
<Group justify="flex-end">
<Tooltip
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
label={
oddBlocksSubmit
? `Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`
: (partnerError ?? "")
}
withArrow
disabled={!hasOdd20ft}
// Only explain a block that is actually in force: an odd count
// linked to a parent booking is resolved by the shared wagon.
disabled={!oddBlocksSubmit && !partnerError}
>
{/* Mantine tooltips get no pointer events from a disabled button,
so the wrapper carries the hover target. */}
@@ -1821,9 +2212,11 @@ export default function GlCreateBookingForm() {
radius="md"
leftSection={<Receipt size={16} />}
onClick={openPriceModal}
// Same hard block the customer portal applies at review time —
// an unpaired 20ft can never be planned onto a wagon.
disabled={hasOdd20ft}
// An unpaired 20ft can never be planned onto a wagon — unless
// a parent booking is linked to share it, which is what
// oddBlocksSubmit accounts for. The parent's own cargo must be
// complete too, or there is nothing to price.
disabled={oddBlocksSubmit || Boolean(partnerError)}
>
Review price &amp; book
</Button>
@@ -1833,6 +2226,24 @@ export default function GlCreateBookingForm() {
</Box>
</Box>
<ConsolidationPartnerPicker
opened={partnerPickerOpen}
onClose={() => setPartnerPickerOpen(false)}
candidates={candidatesQuery.data ?? []}
isLoading={candidatesQuery.isLoading}
isError={candidatesQuery.isError}
onSelect={(candidate) => {
setPartner(candidate);
// Seed a 20ft and a 40ft line. The parent booking sits on its OWN
// contract, whose size scope need not match this one's, so the panel
// offers both sizes rather than mirroring this contract's scope; a
// size the parent does not ship is simply left at 0.
setPartnerLines(PARTNER_SIZES.map(emptyPartnerLine));
setPartnerCargoDescription("");
setPartnerPickerOpen(false);
}}
/>
<Modal
opened={priceOpen}
onClose={() => {
@@ -1984,6 +2395,18 @@ export default function GlCreateBookingForm() {
)}
<Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}>
{/* Whose bill this is. Only worth naming when a second booking is
on screen — on a lone booking there is nothing to confuse it with. */}
{consolidationActive && partner ? (
<Group gap={8} align="center" mb={12} wrap="wrap">
<Badge color="edr-green" variant="light" radius="sm">
{completeBooking?.reference ?? "This booking"}
</Badge>
<Text fz={13} fw={600} c="#10202F">
{completeBooking?.company?.name ?? contract.company?.name ?? "—"}
</Text>
</Group>
) : null}
<Stack gap={10}>
{displayTotal.lines.map((line, i) => (
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
@@ -2028,6 +2451,123 @@ export default function GlCreateBookingForm() {
</Group>
</Paper>
{consolidationActive && partner ? (
<Paper
withBorder
radius={16}
p="lg"
style={{ borderColor: "#E6ECF2" }}
>
<Group gap={8} align="center" mb={12} wrap="wrap">
<Badge color="blue" variant="light" radius="sm">
{partner.reference}
</Badge>
<Text fz={13} fw={600} c="#10202F">
{partner.companyName ?? "—"}
</Text>
</Group>
{validatePartnerMutation.isPending ? (
<Group gap={8} c="dimmed">
<Loader size="xs" color="edr-green" />
<Text fz="sm" c="dimmed">
Pricing the partner booking
</Text>
</Group>
) : partnerBlockers.length > 0 ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Cannot book ${partner.reference}`}
>
<Stack gap={6}>
{partnerBlockers.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
Both bookings are confirmed together, so this must be
fixed before either can be booked.
</Text>
</Stack>
</Alert>
) : partnerTotal ? (
<>
<Stack gap={10}>
{partnerTotal.lines.map((line, i) => (
<Group
key={i}
justify="space-between"
wrap="nowrap"
gap="sm"
>
<Box style={{ minWidth: 0 }}>
<Text fz="sm" fw={500}>
{line.label}
</Text>
<Text fz="xs" c="dimmed">
{line.quantity.toLocaleString()} ×{" "}
{line.unitPrice.toLocaleString()}{" "}
{partnerTotal.currency} ·{" "}
{formatRateUnit(line.unit)}
</Text>
</Box>
<Text
fz="sm"
fw={600}
style={{ whiteSpace: "nowrap" }}
>
{line.amount.toLocaleString()}{" "}
{partnerTotal.currency}
</Text>
</Group>
))}
</Stack>
<Divider my="md" />
<Group justify="space-between" align="flex-end">
<Text
fz="xs"
fw={700}
tt="uppercase"
c="blue"
style={{ letterSpacing: "0.06em" }}
>
Total
</Text>
<Text fw={800} fz={28}>
{partnerTotal.total.toLocaleString()}{" "}
<Text span fz={16} fw={700} c="dimmed">
{partnerTotal.currency}
</Text>
</Text>
</Group>
</>
) : (
<Text fz="sm" c="dimmed">
No price yet for the partner booking.
</Text>
)}
</Paper>
) : null}
{consolidationActive && partner ? (
<Alert
color="blue"
variant="light"
radius="md"
icon={<Link2 size={16} />}
>
<Text fz="sm">
These two bookings share one wagon but stay separate: each is
invoiced to its own customer and paid separately. Confirming
books both together if either fails, neither is booked.
</Text>
</Alert>
) : null}
<Group justify="space-between" mt="xs">
<Button
variant="default"
@@ -2046,7 +2586,11 @@ export default function GlCreateBookingForm() {
disabled={confirmDisabled}
onClick={handleSubmit}
>
{completeBookingId ? "Confirm & complete" : "Confirm & book"}
{consolidationActive && partner
? "Confirm & book both"
: completeBookingId
? "Confirm & complete"
: "Confirm & book"}
</Button>
</Group>
</Stack>

View File

@@ -0,0 +1,255 @@
import { type KeyboardEvent } from "react";
import {
Box,
Checkbox,
Group,
Stack,
Text,
TextInput,
} from "@mantine/core";
/**
* Container editor for the PARTNER half of a shared wagon. Deliberately a
* reduced version of the main form's editor: the partner contributes only cargo
* — route, shipment day and train are inherited from the booking it shares the
* wagon with, and hazardous/reefer/return counts are derived from the per-unit
* ticks rather than typed line totals.
*/
export interface PartnerUnitDraft {
containerNumber: string;
sealNumber: string;
vgmTons: string;
isHazardous: boolean;
isReefer: boolean;
isReturn: boolean;
}
export interface PartnerLineDraft {
containerSize: string;
quantity: string;
hazardousQuantity: string;
reeferQuantity: string;
returnQuantity: string;
units: PartnerUnitDraft[];
}
export function emptyPartnerUnit(): PartnerUnitDraft {
return {
containerNumber: "",
sealNumber: "",
vgmTons: "",
isHazardous: false,
isReefer: false,
isReturn: false,
};
}
export function emptyPartnerLine(size: string): PartnerLineDraft {
return {
containerSize: size,
quantity: "0",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [],
};
}
/** Quantities are magnitudes — swallow the minus key before it reaches the field. */
const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "-") event.preventDefault();
};
/** Grow or shrink a line's unit rows to match its quantity. */
function syncUnits(line: PartnerLineDraft, quantity: number): PartnerLineDraft {
const target = Math.max(0, Math.floor(quantity) || 0);
const units = [...line.units];
while (units.length < target) units.push(emptyPartnerUnit());
units.length = target;
return {
...line,
units,
hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
reeferQuantity: String(units.filter((u) => u.isReefer).length),
};
}
interface Props {
lines: PartnerLineDraft[];
onLinesChange: (lines: PartnerLineDraft[]) => void;
cargoDescription: string;
onCargoDescriptionChange: (value: string) => void;
/** Whether per-container hazardous / refrigerated ticks apply. */
showHazardous: boolean;
showReefer: boolean;
/** Surface field errors only after the operator tried to continue. */
showErrors: boolean;
error?: string;
}
export function ConsolidationPartnerPanel({
lines,
onLinesChange,
cargoDescription,
onCargoDescriptionChange,
showHazardous,
showReefer,
showErrors,
error,
}: Props) {
const patchLine = (index: number, patch: Partial<PartnerLineDraft>) => {
onLinesChange(
lines.map((line, i) => (i === index ? { ...line, ...patch } : line)),
);
};
const patchUnit = (
lineIndex: number,
unitIndex: number,
patch: Partial<PartnerUnitDraft>,
) => {
onLinesChange(
lines.map((line, i) => {
if (i !== lineIndex) return line;
const units = line.units.map((unit, u) =>
u === unitIndex ? { ...unit, ...patch } : unit,
);
return {
...line,
units,
hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
reeferQuantity: String(units.filter((u) => u.isReefer).length),
};
}),
);
};
return (
<Stack gap={14}>
{error && showErrors ? (
<Text fz={12.5} c="red.7">
{error}
</Text>
) : null}
{lines.map((line, lineIdx) => (
<Box
key={`${line.containerSize}-${lineIdx}`}
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 16 }}
>
<Text fz={14} fw={700} mb={10}>
{line.containerSize} containers
</Text>
<TextInput
type="number"
onKeyDown={blockNegative}
label="Quantity *"
min={0}
value={line.quantity}
onChange={(e) => patchLine(lineIdx, { quantity: e.currentTarget.value })}
// Sync off the typed value, not the captured `line` — that snapshot
// still holds the pre-edit quantity and would write it back.
onBlur={(e) => {
const typed = e.currentTarget.value;
patchLine(lineIdx, {
...syncUnits({ ...line, quantity: typed }, Number(typed || 0)),
quantity: typed,
});
}}
mb={12}
/>
{line.units.map((unit, unitIdx) => (
<Box key={unitIdx} mb={10}>
<Text fz={12} fw={600} c="#5B6B7B" mb={6}>
Container {unitIdx + 1}
</Text>
<Group gap={12} grow align="flex-start">
<TextInput
label="Container number *"
placeholder="e.g. MSCU1234567"
value={unit.containerNumber}
error={
showErrors && !unit.containerNumber.trim()
? "Required."
: undefined
}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
containerNumber: e.currentTarget.value.toUpperCase(),
})
}
/>
<TextInput
label="Seal number"
value={unit.sealNumber}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
sealNumber: e.currentTarget.value,
})
}
/>
<TextInput
type="number"
onKeyDown={blockNegative}
label="VGM (tons) *"
min={0}
value={unit.vgmTons}
error={
showErrors && !(Number(unit.vgmTons) > 0)
? "Required."
: undefined
}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, { vgmTons: e.currentTarget.value })
}
/>
</Group>
{showHazardous || showReefer ? (
<Group gap={16} mt={8}>
{showHazardous ? (
<Checkbox
size="xs"
label="Hazardous"
checked={unit.isHazardous}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
isHazardous: e.currentTarget.checked,
})
}
/>
) : null}
{showReefer ? (
<Checkbox
size="xs"
label="Refrigerated"
checked={unit.isReefer}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
isReefer: e.currentTarget.checked,
})
}
/>
) : null}
</Group>
) : null}
</Box>
))}
</Box>
))}
<TextInput
label="Cargo description *"
placeholder="What these containers carry"
value={cargoDescription}
error={
showErrors && !cargoDescription.trim() ? "Required." : undefined
}
onChange={(e) => onCargoDescriptionChange(e.currentTarget.value)}
/>
</Stack>
);
}

View File

@@ -0,0 +1,137 @@
import {
Alert,
Badge,
Box,
Button,
Center,
Group,
Loader,
Modal,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { AlertCircle, Link2 } from "lucide-react";
import type { ConsolidationCandidate } from "@/services/contracts.service";
/**
* Picker for the booking that shares this booking's wagon. The server has
* already narrowed the list to bookings that can legally pair — same route and
* direction, customs clearing, an odd 20ft count of their own and not already
* linked to someone else — so every row here is a valid choice.
*/
interface Props {
opened: boolean;
onClose: () => void;
candidates: ConsolidationCandidate[];
isLoading: boolean;
isError: boolean;
onSelect: (candidate: ConsolidationCandidate) => void;
}
export function ConsolidationPartnerPicker({
opened,
onClose,
candidates,
isLoading,
isError,
onSelect,
}: Props) {
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size="lg"
title={
<Group gap={10}>
<ThemeIcon variant="light" color="blue" radius="md" size={34}>
<Link2 size={18} />
</ThemeIcon>
<Box>
<Text fw={800} fz={16}>
Pick the parent booking
</Text>
<Text fz="xs" c="dimmed">
Customs bookings on the same route that also carry an odd number of
20ft containers.
</Text>
</Box>
</Group>
}
>
{isLoading ? (
<Center py="xl">
<Loader size="sm" color="edr-green" />
</Center>
) : isError ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
>
Could not load the candidate bookings. Close this and try again.
</Alert>
) : candidates.length === 0 ? (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="No booking available to share this wagon"
>
<Text fz="sm">
No other customs booking on this route currently carries an odd
number of 20ft containers. Either wait for one, or switch the
shared-wagon option off and book an even number of 20ft containers.
</Text>
</Alert>
) : (
<Stack gap={10}>
{candidates.map((candidate) => (
<Box
key={candidate.id}
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 14 }}
>
<Group justify="space-between" align="center" wrap="wrap" gap={10}>
<Box style={{ minWidth: 0 }}>
<Group gap={8} align="center" wrap="wrap">
<Text fz={14} fw={700} c="#10202F">
{candidate.reference}
</Text>
<Badge size="sm" variant="light" color="gray" radius="sm">
{candidate.status.replaceAll("_", " ")}
</Badge>
</Group>
<Text fz={12.5} c="dimmed" mt={2}>
{candidate.companyName ?? "—"}
{candidate.tradeDirection
? ` · ${candidate.tradeDirection}`
: ""}
{" · "}
{candidate.hasCargo
? `${candidate.ft20Quantity} × 20ft`
: "cargo not entered yet"}
</Text>
</Box>
<Button
size="xs"
radius="md"
variant="light"
color="edr-green"
onClick={() => onSelect(candidate)}
>
Select
</Button>
</Group>
</Box>
))}
</Stack>
)}
</Modal>
);
}

View File

@@ -191,6 +191,8 @@ export const URL_CONSTANTS = {
BY_ID: (id: string) => `/bookings/${id}`,
QUEUE: (queue: string) => `/bookings/queues/${queue}`,
STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`,
// Consolidated pair: one staff decision applied to both halves at once.
PAIRED_DECISION: (id: string) => `/bookings/${id}/paired-decision`,
STAFF_REQUEST_CHANGES: (id: string) =>
`/bookings/${id}/staff/request-changes`,
STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`,
@@ -317,6 +319,12 @@ export const URL_CONSTANTS = {
AWAITING_SHIPMENT: "/contracts/awaiting-shipment",
BOOKINGS_COMPLETE: (id: string, bookingId: string) =>
`/contracts/${id}/bookings/${bookingId}/complete`,
// Odd-20ft shared-wagon consolidation (customs/Path B): candidates GL may
// link, and the all-or-nothing completion of both halves together.
CONSOLIDATION_CANDIDATES: (id: string, bookingId: string) =>
`/contracts/${id}/bookings/${bookingId}/consolidation-candidates`,
BOOKINGS_COMPLETE_CONSOLIDATED: (id: string, bookingId: string) =>
`/contracts/${id}/bookings/${bookingId}/complete-consolidated`,
VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`,
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.

View File

@@ -59,6 +59,9 @@ export type BookingActionContext = Pick<
| "reference"
| "schedulingStatus"
| "customsClearingEnabled"
// Set when this booking shares a wagon: the pairable staff decisions then
// apply to both halves at once rather than to this booking alone.
| "consolidationPartnerId"
>;
const ALLOCATABLE_SCHEDULING_STATUSES = new Set([

View File

@@ -121,7 +121,38 @@ export function useBookingMutations(bookingId: string) {
onError: (error) => toast.error(parseApiError(error, "Failed to cancel booking")),
});
/**
* One staff decision applied to both halves of a consolidated pair. Both
* bookings are invalidated on success so whichever tab is open reflects the
* new state immediately.
*/
const pairedDecision = useMutation({
mutationFn: (payload: {
decision: "accept" | "cancel" | "operationAccept" | "requestChanges";
reason?: string;
note?: string;
validityDays?: number;
}) => {
const { decision, ...options } = payload;
return bookingsService.pairedDecision(bookingId, decision, options);
},
onSuccess: (data) => {
toast.success("Applied to both bookings on the shared wagon");
void invalidateBookingDetail(qc, data.booking.id);
void invalidateBookingDetail(qc, data.partner.id);
},
onError: (error) => {
toast.error(
parseApiError(error, "Failed to apply the decision to both bookings"),
);
// Nothing should have committed (the server runs both halves in one
// transaction), but refetch so the UI never shows a stale guess.
void invalidateBookingDetail(qc, bookingId);
},
});
const isPending =
pairedDecision.isPending ||
staffAccept.isPending ||
requestChanges.isPending ||
staffReject.isPending ||
@@ -134,6 +165,7 @@ export function useBookingMutations(bookingId: string) {
cancel.isPending;
return {
pairedDecision,
staffAccept,
requestChanges,
staffReject,

View File

@@ -9,6 +9,7 @@ import {
FolderOpen,
Layers,
LayoutGrid,
Link2,
Milestone,
MoreHorizontal,
Package,
@@ -79,14 +80,50 @@ export default function BookingRequestDetailPage() {
const [searchParams, setSearchParams] = useSearchParams();
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
useScrollToHash();
// Consolidated pair: `?booking=<partnerId>` swaps the WHOLE page over to the
// other half of the shared wagon. Everything below — KPIs, stepper, the
// overview/orders/documents/trucks sub-tabs, the action toolbar — then reads
// from the selected booking, so each half gets its own complete detail page
// under a top-level tab. The URL id stays put so Back still works.
const selectedId = searchParams.get("booking") || id;
const {
data: booking,
isLoading,
isError,
refetch,
isFetching,
} = useBookingDetail(id);
const mutations = useBookingMutations(id ?? "");
} = useBookingDetail(selectedId);
const mutations = useBookingMutations(selectedId ?? "");
// The pair is discovered from whichever half is on screen: each booking
// carries a reference to the other.
const routeBookingId = id ?? "";
const partnerId = booking?.consolidationPartnerId ?? null;
const isPaired = Boolean(partnerId);
const viewingPartner = selectedId !== routeBookingId;
// Tab identities: the booking named by the URL is always the first tab, the
// other half the second — regardless of which one is currently displayed.
const firstTabId = routeBookingId;
const secondTabId = viewingPartner ? selectedId : partnerId;
// Only for the tab label (reference + customer) — the displayed half is
// loaded above. Skipped entirely when the booking is not part of a pair.
const { data: otherBooking } = useBookingDetail(
secondTabId && secondTabId !== selectedId ? secondTabId : undefined,
);
const firstTabBooking = viewingPartner ? otherBooking : booking;
const secondTabBooking = viewingPartner ? booking : otherBooking;
const selectBooking = (bookingId: string) => {
const next = new URLSearchParams(searchParams);
if (bookingId === routeBookingId) next.delete("booking");
else next.set("booking", bookingId);
// Switching booking resets the sub-tab: the other half has its own content
// and may not even have the tab that was open (e.g. Orders).
next.delete("tab");
setSearchParams(next, { replace: true });
};
if (isLoading) {
return (
@@ -349,6 +386,48 @@ export default function BookingRequestDetailPage() {
/>
<Stack gap="lg">
{/* Consolidated pair: one tab per booking, switching the ENTIRE page
below. The overview/orders/documents/trucks tabs further down are
sub-tabs of whichever booking is selected here. */}
{isPaired && secondTabId ? (
<Tabs
value={selectedId ?? undefined}
onChange={(value) => value && selectBooking(value)}
variant="pills"
radius="md"
>
<Tabs.List>
<Tabs.Tab value={firstTabId} leftSection={<Link2 size={15} />}>
<Stack gap={0} align="flex-start">
<Text fz={13} fw={700}>
{firstTabBooking?.reference ?? "Booking"}
</Text>
<Text fz={11} c="dimmed">
{firstTabBooking?.company?.name ?? "—"}
</Text>
</Stack>
</Tabs.Tab>
<Tabs.Tab value={secondTabId} leftSection={<Link2 size={15} />}>
<Stack gap={0} align="flex-start">
<Text fz={13} fw={700}>
{secondTabBooking?.reference ?? "Partner booking"}
</Text>
<Text fz={11} c="dimmed">
{secondTabBooking?.company?.name ?? "—"}
</Text>
</Stack>
</Tabs.Tab>
</Tabs.List>
</Tabs>
) : null}
{isPaired ? (
<Text size="xs" c="dimmed">
These two bookings share one wagon. Accepting or cancelling applies
to both; each is invoiced and paid separately.
</Text>
) : null}
<KpiStrip items={kpis} />
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (

View File

@@ -15,6 +15,7 @@ import {
CheckCircle2,
Clock,
LayoutList,
Link2,
Package,
Plus,
RefreshCw,
@@ -200,10 +201,28 @@ export default function BookingRequestsPage() {
// Search is applied server-side (via the `search` filter param) — no
// client-side filtering here.
const rows = useMemo(
() => (data?.items ?? []).map(toBookingListRow),
[data?.items],
);
const rows = useMemo(() => {
const mapped = (data?.items ?? []).map(toBookingListRow);
// Consolidated pairs share one wagon and are decided together, so they show
// as ONE row. Keep the half that appears first in the current sort and hang
// the other on it as `pairedWith`; the row renders both bookings' details
// and opens the detail page, where each half gets its own tab.
const byId = new Map(mapped.map((row) => [row.id, row]));
const absorbed = new Set<string>();
const merged: BookingListRow[] = [];
for (const row of mapped) {
if (absorbed.has(row.id)) continue;
const partnerId = row.consolidationPartnerId;
const partner = partnerId ? byId.get(partnerId) : undefined;
if (partner && !absorbed.has(partner.id)) {
absorbed.add(partner.id);
merged.push({ ...row, pairedWith: partner });
continue;
}
merged.push(row);
}
return merged;
}, [data?.items]);
const total = data?.total ?? 0;
const hasSearch = controls.searchText.trim().length > 0;
@@ -321,6 +340,22 @@ export default function BookingRequestsPage() {
</Badge>
) : null}
</p>
{/* Shared wagon: the second booking rides in the same row, so the
operator sees both customers before opening the pair. */}
{b.pairedWith ? (
<div className="mt-1.5 border-l-2 border-muted pl-2">
<div className="flex items-center gap-1.5">
<Link2 className="size-3 shrink-0 opacity-70" />
<p className="truncate text-xs font-medium text-foreground">
{b.pairedWith.reference}
</p>
</div>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{b.pairedWith.customerLabel}
</p>
</div>
) : null}
</div>
</div>
);

View File

@@ -323,6 +323,26 @@ export const bookingsService = {
cancel: (id: string, reason: string) =>
postBooking<BookingDetail>(B.CANCEL(id), { reason }),
/**
* Apply one staff decision to BOTH halves of a consolidated pair. The two
* bookings share a wagon, so they advance or cancel together — all-or-nothing
* on the server. Each half keeps its own invoice and payment.
*/
pairedDecision: async (
id: string,
decision: "accept" | "cancel" | "operationAccept" | "requestChanges",
options: { reason?: string; note?: string; validityDays?: number } = {},
): Promise<{ booking: BookingDetail; partner: BookingDetail }> => {
const response = await client.post(B.PAIRED_DECISION(id), {
decision,
...options,
});
return unwrap(response.data) as {
booking: BookingDetail;
partner: BookingDetail;
};
},
create: async (payload: Record<string, unknown>): Promise<BookingDetail> => {
const response = await client.post<{ booking: BookingDetail } | BookingDetail>(
B.BASE,

View File

@@ -71,6 +71,32 @@ export interface ShipmentValidation {
totalAmount?: number;
}
/**
* A booking GL may pick as the shared-wagon partner of an odd-20ft customs
* booking. `hasCargo` is false for a bare instance whose containers GL still
* enters on the split completion form.
*/
export interface ConsolidationCandidate {
id: string;
reference: string;
contractId: string | null;
companyName: string | null;
status: string;
tradeDirection: string | null;
originYardId: string | null;
destinationYardId: string | null;
scheduledDate: string | null;
ft20Quantity: number;
hasCargo: boolean;
}
/** Both halves of a shared-wagon completion, each with its own full payload. */
export interface CompleteConsolidatedPairPayload {
partnerBookingId: string;
booking: Freight.CreateBookingUnderContractDto;
partner: Freight.CreateBookingUnderContractDto;
}
export interface ContractListSummaryMetrics {
inQueue: number;
needsAction: number;
@@ -674,6 +700,46 @@ export const contractsService = {
};
},
/**
* Bookings GL may link to an odd-20ft customs booking as its shared-wagon
* partner (same route and direction, customs, odd 20ft, not already paired).
*/
listConsolidationCandidates: async (
id: string,
bookingId: string,
): Promise<ConsolidationCandidate[]> => {
const response = await client.get(
C.CONSOLIDATION_CANDIDATES(id, bookingId),
);
return (unwrap(response.data) ?? []) as ConsolidationCandidate[];
},
/**
* Complete an odd-20ft booking together with the partner booking sharing its
* wagon. All-or-nothing on the server: either both bookings complete and are
* linked, or neither does. Each booking keeps its own price and its own
* invoice — only the wagon is shared.
*/
completeConsolidatedPair: async (
id: string,
bookingId: string,
payload: CompleteConsolidatedPairPayload,
): Promise<{
booking: { id: string; reference: string };
partner: { id: string; reference: string };
warnings?: string[];
}> => {
const response = await client.post(
C.BOOKINGS_COMPLETE_CONSOLIDATED(id, bookingId),
payload,
);
return unwrap(response.data) as {
booking: { id: string; reference: string };
partner: { id: string; reference: string };
warnings?: string[];
};
},
/**
* Pre-create validation + authoritative price preview: the same
* BookingPricingService pass that prices the booking on create (rail +

View File

@@ -296,6 +296,12 @@ export interface BookingListRow {
governmentInstitution?: string | null;
consolidationPartnerId?: string | null;
consolidationPartnerReference?: string | null;
/**
* The other half of a consolidated pair, folded into this row for display.
* Set client-side when both halves are present in the same page of results —
* the list shows one row per shared wagon, not one per booking.
*/
pairedWith?: BookingListRow | null;
customsClearingEnabled?: boolean;
/**
* Derived booking kind for the list "Type" column. Mirrors the server's