mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 07:51:02 +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:
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user