mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 18:33:39 +00:00
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:
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user