mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix issue and add consolidation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'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 & issue fee
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
{viewer}
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -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'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
|
||||
|
||||
@@ -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's odd booking to share a
|
||||
wagon, or held until one is available.
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user