fix issue and add consolidation

This commit is contained in:
Marshal
2026-08-21 23:16:06 +00:00
parent a339ea620e
commit 09deecd04c
21 changed files with 1464 additions and 264 deletions

View File

@@ -873,15 +873,10 @@ export default function GlCreateBookingForm() {
// 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.
//
// Switched OFF for now: consolidation is built end to end (toggle, parent
// picker, split entry, paired pricing, approval gate) but not in use, so an
// odd 20ft total is rejected outright instead of offering the shared wagon.
// Drop the `false &&` to bring the whole flow back.
const oddConsolidationAvailable =
false &&
Boolean(completeBookingId && isContainer && contract?.customsClearingEnabled);
// falls through to the server's automatic consolidation gate.
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.
@@ -970,11 +965,11 @@ export default function GlCreateBookingForm() {
!cargoDescriptionError
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
// Consolidation (sharing the wagon with another customer's odd booking) is
// built but switched off for now, so an odd 20ft total always blocks — the
// shared wagon no longer resolves the unpaired container. Flip this back to
// `hasOdd20ft && !consolidationActive` to re-enable the shared-wagon path.
const oddBlocksSubmit = hasOdd20ft;
// COMPLETION never blocks on an odd 20ft total: a customs instance can share
// the wagon via the manual pair (consolidationActive), and anything else is
// auto-paired or parked as PENDING_CONSOLIDATION by the server's
// consolidation gate. Creating a booking from scratch keeps the block.
const oddBlocksSubmit = hasOdd20ft && !completeBookingId;
// 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.

View File

@@ -250,7 +250,7 @@ function WagonCar({
<HoverCard.Target>
<Box
onClick={() => onSelectSlot(loaded[0] ?? wagon)}
style={{ width: 120, flexShrink: 0, cursor: "pointer" }}
style={{ width: 148, flexShrink: 0, cursor: "pointer" }}
>
<Box
onDragOver={(e) => {
@@ -270,7 +270,9 @@ function WagonCar({
}}
style={{
position: "relative",
height: 70,
// A leg-sharing wagon stacks its loads (bulk and container rows
// top/bottom) — give the stack real height so both stay legible.
height: shared ? 88 : 70,
borderRadius: 11,
background: isEmpty
? "var(--mantine-color-gray-0)"
@@ -393,7 +395,7 @@ function WagonCar({
);
const rowBlocks = wagonItems(slot).slice(0, 2);
const rowSelected = shared && slot.id === selectedWagonId;
const rowHeight = shared ? 13 : 26;
const rowHeight = shared ? 20 : 26;
return (
<Group
key={slot.id}

View File

@@ -359,13 +359,7 @@ export default function TrainScheduleV2ListPage() {
id: "metrics",
header: "Load",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<MetricChip value={row.original.bookingsCount} label="bkg" />
<WagonChips schedule={row.original} />
<MetricChip value={`${row.original.totalWeightTons}T`} label="" subtle />
</Group>
),
cell: ({ row }) => <MetricChip value={row.original.bookingsCount} label="bkg" />,
},
{
id: "actions",
@@ -884,14 +878,6 @@ export default function TrainScheduleV2ListPage() {
);
}
/**
* The row's wagon chips: used is slots carrying a booking allocation, the
* denominator is the schedule's capacity (API-computed: the larger of coupled
* consist and planned `maxWagons`, since wagons are coupled on demand). Both
* are consist-wide totals, so on a multi-leg schedule they do not describe any
* single leg — the bookable/planned counts were dropped for that reason; the
* detail page's wagon plan is the per-leg source of truth.
*/
/** Green tint for departures dedicated to a shipping line (overrides direction tint). */
const SHIPPING_LINE_ROW_STYLE = {
backgroundColor: "var(--mantine-color-edr-green-0)",
@@ -956,25 +942,6 @@ function ShippingLineBadge({ schedule }: { schedule: TrainScheduleListItem }) {
);
}
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
// renders rather than reading 0 used on every train.
const total = schedule.wagonsTotal ?? schedule.wagonCount;
const used = schedule.wagonsUsed;
const reserved = schedule.wagonsReserved ?? 0;
if (used == null || schedule.wagonCount === 0) {
return <MetricChip value={total} label="wgn" subtle />;
}
return (
<>
<MetricChip value={`${used}/${total}`} label="wgn used" />
{reserved > used ? <MetricChip value={reserved} label="reserved" subtle /> : null}
</>
);
}
function MetricChip({
value,
label,
@@ -1071,11 +1038,7 @@ function ScheduleCard({
) : null}
<ShippingLineBadge schedule={schedule} />
</Group>
<Group gap={6} wrap="nowrap">
<MetricChip value={schedule.bookingsCount} label="bkg" />
<WagonChips schedule={schedule} />
<MetricChip value={`${schedule.totalWeightTons}T`} label="" subtle />
</Group>
<MetricChip value={schedule.bookingsCount} label="bkg" />
</Group>
<Group gap="xs" wrap="nowrap">

View File

@@ -1,5 +1,5 @@
import { Button, Group, Modal, Stack, Tabs, Text } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { Button, Group, Modal, Skeleton, Stack, Tabs, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Clock,
CreditCard,
@@ -67,6 +67,8 @@ const CUSTOMER_CANCELLABLE_STATUSES = [
"CONTRACT_READY",
"OPERATION_REQUEST_PENDING",
"SELECTED_FOR_BATCH",
// Parked waiting for a consolidation partner — nothing reserved yet.
"PENDING_CONSOLIDATION",
];
const cancelErrorMessage = (error: unknown) => {
@@ -136,6 +138,32 @@ export function ReadonlyBookingView({
const canCancel =
booking.paymentStatus !== "PAID" &&
CUSTOMER_CANCELLABLE_STATUSES.includes(status);
// PAID booking (allocated or not): the same button cancels the WHOLE booking
// through wagon cancellation — a per-wagon fee is invoiced and the paid
// freight becomes a rebooking credit. Blocked once loading starts (server
// enforces; loading flips status past PAID/TRUCK_ASSIGNED).
const canCancelPaid =
booking.paymentStatus === "PAID" &&
["PAID", "TRUCK_ASSIGNED"].includes(status) &&
Boolean(booking.contractId);
const [paidCancelOpen, setPaidCancelOpen] = useState(false);
const paidPreview = useQuery({
queryKey: ["whole-cancel-preview", booking.id],
queryFn: () => bookingsService.previewWagonCancellation(booking.id, {}),
enabled: paidCancelOpen,
});
const paidCancelMutation = useMutation({
mutationFn: () => bookingsService.requestWagonCancellation(booking.id, {}),
onSuccess: () => {
setPaidCancelOpen(false);
toast.success(
"Cancellation requested — pay the cancellation fee to settle it. Your paid freight is kept as credit for rebooking.",
{ duration: 8000 },
);
onBookingUpdated?.();
},
onError: (e) => toast.error(cancelErrorMessage(e)),
});
const pricing = booking.pricingBreakdown;
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
@@ -206,7 +234,8 @@ export function ReadonlyBookingView({
actions={
(canApproveDelivery ||
(payables.items.length > 0 && tab !== "payments") ||
canCancel) && (
canCancel ||
canCancelPaid) && (
<Group gap={8} wrap="nowrap">
{canApproveDelivery && (
<ApproveDeliveryButton bookingId={booking.id} />
@@ -227,6 +256,14 @@ export function ReadonlyBookingView({
onClick={() => setCancelOpen(true)}
/>
)}
{canCancelPaid && (
<HeaderButton
red
icon={<XCircle size={16} />}
label="Cancel booking"
onClick={() => setPaidCancelOpen(true)}
/>
)}
</Group>
)
}
@@ -414,6 +451,7 @@ export function ReadonlyBookingView({
booking.paymentStatus === "PAID" &&
Boolean(booking.contractId)
}
consolidated={Boolean(booking.consolidationPartnerId)}
onCancellationRequested={onBookingUpdated}
/>
</Tabs.Panel>
@@ -509,6 +547,86 @@ export function ReadonlyBookingView({
</Group>
</Stack>
</Modal>
<Modal
opened={paidCancelOpen}
onClose={() => setPaidCancelOpen(false)}
title={
<Text fw={800} fz={18} c="#10202F">
Cancel this booking?
</Text>
}
centered
radius={16}
>
<Stack gap="md">
<Text size="sm" c="#475569">
You&apos;re about to cancel the whole booking{" "}
<Text span fw={700} c="#10202F">
{booking.reference}
</Text>
. A cancellation fee applies per wagon; your paid freight is kept as
a credit you can rebook with once the fee is settled.
{booking.consolidationPartnerId
? " This booking shares a wagon with another customer — both bookings will be cancelled, and the shared wagon's fee is charged to you, not to them."
: ""}
</Text>
{paidPreview.isLoading && <Skeleton height={64} radius={10} />}
{paidPreview.data && (
<Stack
gap={4}
p={12}
style={{
borderRadius: 10,
backgroundColor: "#FFFBEB",
border: "1px solid #FDE68A",
}}
>
<Text fz={13} c="#92400E">
Wagons cancelled: <b>{paidPreview.data.wagons}</b>
</Text>
<Text fz={13} c="#92400E">
Cancellation fee:{" "}
<b>
{Number(paidPreview.data.feeAmount).toLocaleString()}{" "}
{paidPreview.data.feeCurrency}
</b>{" "}
({Number(paidPreview.data.feePerWagon).toLocaleString()} per
wagon)
</Text>
<Text fz={13} c="#92400E">
Rebooking credit:{" "}
<b>
{Number(paidPreview.data.creditAmount).toLocaleString()}{" "}
{booking.paymentCurrency}
</b>
</Text>
</Stack>
)}
{paidPreview.isError && (
<Text fz={13} c="#B3362C">
{cancelErrorMessage(paidPreview.error)}
</Text>
)}
<Group justify="flex-end" gap={8}>
<Button
variant="default"
radius={10}
onClick={() => setPaidCancelOpen(false)}
>
Keep booking
</Button>
<Button
color="red"
radius={10}
disabled={!paidPreview.data}
loading={paidCancelMutation.isPending}
onClick={() => paidCancelMutation.mutate()}
>
Cancel booking &amp; issue fee
</Button>
</Group>
</Stack>
</Modal>
{viewer}
</PageShell>
);

View File

@@ -303,11 +303,14 @@ function WagonCard({
wagon,
selectable,
selected,
shared,
onToggle,
}: {
wagon: BookingWagonAllocation;
selectable?: boolean;
selected?: boolean;
/** Shared consolidation wagon — not selectable for cancellation. */
shared?: boolean;
onToggle?: () => void;
}) {
const allocated = Number(wagon.allocatedWeightTons || 0);
@@ -368,6 +371,14 @@ function WagonCard({
<StatusPill status={wagon.status} />
</Group>
{shared && (
<Text fz={11.5} c="#B45309" mb={6}>
Shared wagon the other half belongs to another customer&apos;s
booking, so it cannot be cancelled on its own. Cancel the whole
booking to release it.
</Text>
)}
<LoadBar allocated={allocated} capacity={capacity} />
<Group gap={16} mt="sm" mb={containers.length || wagon.loadType === "BULK" ? "sm" : 0}>
@@ -464,6 +475,7 @@ export function WagonsTab({
bookingId,
currency,
cancellable,
consolidated,
onCancellationRequested,
}: {
bookingId: string;
@@ -471,6 +483,8 @@ export function WagonsTab({
currency?: string;
/** PAID contract booking — specific wagons may be selected for cancellation. */
cancellable?: boolean;
/** Consolidated booking — its shared wagon (a lone 20ft) cannot be cancelled alone. */
consolidated?: boolean;
onCancellationRequested?: () => void;
}) {
const queryClient = useQueryClient();
@@ -711,19 +725,31 @@ export function WagonsTab({
<CancelledWagonsSection rows={ownCancellations} />
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={24}>
{wagons.map((w) => (
<WagonCard
key={w.allocationId ?? w.sequenceNo}
wagon={w}
selectable={
canSelect &&
!!w.allocationId &&
(w.status === "PLANNED" || w.status === "RESERVED")
}
selected={!!w.allocationId && selected.has(w.allocationId)}
onToggle={() => w.allocationId && toggle(w.allocationId)}
/>
))}
{wagons.map((w) => {
// The shared consolidation wagon carries this booking's lone 20ft —
// its other half belongs to the partner booking, so it can never be
// cancelled on its own (the server rejects it too).
const isSharedWagon =
!!consolidated &&
w.loadType === "CONTAINER" &&
(w.containers ?? []).length === 1 &&
Number(w.containers?.[0]?.sizeFt) === 20;
return (
<WagonCard
key={w.allocationId ?? w.sequenceNo}
wagon={w}
shared={isSharedWagon}
selectable={
canSelect &&
!isSharedWagon &&
!!w.allocationId &&
(w.status === "PLANNED" || w.status === "RESERVED")
}
selected={!!w.allocationId && selected.has(w.allocationId)}
onToggle={() => w.allocationId && toggle(w.allocationId)}
/>
);
})}
</SimpleGrid>
<Modal

View File

@@ -29,7 +29,6 @@ import {
Textarea,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertCircle,
@@ -413,10 +412,10 @@ function NewShipmentBookingForm({
mode: "onChange",
});
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
// the booking can never be planned. The server's shipment validation reports
// it too, but only once the price modal opens — block it inline instead, the
// same way the direct-booking wizard does (new-booking-form `calcWagons`).
// 20ft containers ride two per wagon. An odd total no longer blocks the
// booking — the server auto-pairs it with another customer's odd booking, or
// parks it as PENDING_CONSOLIDATION until one shows up (same consolidation
// gate the direct-booking flow already uses).
const watchedContainers = form.watch("containers");
const ft20Total =
contract.freightType === "CONTAINER"
@@ -581,8 +580,6 @@ function NewShipmentBookingForm({
// run it for every freight type; container contracts additionally get
// overweight warnings + 20ft pairing hard-blocks surfaced in the modal.
const handleReview = form.handleSubmit((values) => {
// An unpaired 20ft can never be planned onto a wagon — don't even price it.
if (hasOdd20ft) return;
setPendingValues(values);
validateMutation.reset();
validateMutation.mutate(buildDto(values));
@@ -747,27 +744,27 @@ function NewShipmentBookingForm({
Fix the highlighted fields before reviewing the price.
</Alert>
) : null}
<Group justify="flex-end">
<Tooltip
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
withArrow
disabled={!hasOdd20ft}
{hasOdd20ft ? (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
{/* Mantine tooltips get no pointer events from a disabled button,
so the wrapper carries the hover target. */}
<Box>
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
disabled={hasOdd20ft}
>
{isResubmit ? "Change booking" : "Review price & book"}
</Button>
</Box>
</Tooltip>
{`${ft20Total} is an odd number of 20ft containers — this booking will be paired with another customer's odd booking to share a wagon, or held until one is available.`}
</Alert>
) : null}
<Group justify="flex-end">
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
>
{isResubmit ? "Change booking" : "Review price & book"}
</Button>
</Group>
</Box>
</Box>
@@ -1687,18 +1684,16 @@ function CargoStep({
if (ft20 % 2 !== 1) return null;
return (
<Alert
color="red"
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20})`}
>
<Text fz={13}>
20ft containers travel two per wagon, so they must be booked
in even numbers. Please add one more 20ft container or remove
one (e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20})
the booking cannot be submitted with an unpaired 20ft
container.
20ft containers travel two per wagon. This booking will be
paired with another customer&apos;s odd booking to share a
wagon, or held until one is available.
</Text>
</Alert>
);

View File

@@ -106,15 +106,14 @@ export default function NewShipmentRequestPage() {
contract.cargoScope?.[0];
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
// the request cannot be planned. Consolidation (pairing the odd container with
// another customer's odd booking) is built but switched off for now, so an odd
// request is blocked here rather than dead-ending downstream.
// 20ft containers ride two per wagon. An odd total no longer blocks the
// request — the server auto-pairs it with another customer's odd booking, or
// parks it as PENDING_CONSOLIDATION until one shows up (same consolidation
// gate the direct-booking flow already uses).
const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0;
const hasOdd20ft = ft20Requested % 2 === 1;
const handleSubmit = () => {
if (hasOdd20ft) return;
const dto: Freight.CreateBookingRequestDto = {
contractRouteId: route?.id,
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
@@ -221,17 +220,16 @@ export default function NewShipmentRequestPage() {
{hasOdd20ft ? (
<Alert
color="red"
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Requested})`}
>
<Text fz={13}>
20ft containers travel two per wagon, so they must be requested
in even numbers. Please add one more 20ft container or remove
one (e.g. request {ft20Requested + 1} or {ft20Requested - 1}{" "}
instead of {ft20Requested}).
20ft containers travel two per wagon. This request will be
paired with another customer&apos;s odd booking to share a
wagon, or held until one is available.
</Text>
</Alert>
) : null}
@@ -282,7 +280,6 @@ export default function NewShipmentRequestPage() {
leftSection={<Send size={16} />}
loading={submit.isPending}
onClick={handleSubmit}
disabled={hasOdd20ft}
>
Submit shipment request
</Button>

View File

@@ -204,6 +204,8 @@ export interface BookingWagonContainer {
sealNumber: string | null;
positionOnWagon: number | null;
grossWeightTons: string | null;
/** Container size in feet (20/40) — identifies the shared consolidation wagon. */
sizeFt: number | null;
}
/** One allocated wagon of a booking, as returned by GET /bookings/:id/wagons. */