Files
edr-platform/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineCompletePage.tsx
2026-08-24 12:24:23 +00:00

1660 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Center,
Divider,
FileButton,
Group,
List,
Loader,
Modal,
Paper,
Select,
Stack,
Switch,
Text,
TextInput,
Textarea,
ThemeIcon,
Title,
} from "@mantine/core";
import {
AlertCircle,
AlertTriangle,
CalendarDays,
CheckCircle2,
ChevronLeft,
FileDown,
FileUp,
Flame,
MapPin,
Package,
PackageCheck,
Receipt,
Snowflake,
Train,
X,
} from "lucide-react";
import { OperationDatePicker } from "@edr/ui-common";
import {
StepCard,
StepHeader,
StepLabel,
fieldStyles,
} from "../contracts/new-contract-form/shared";
import { formatRateUnit } from "../contracts/new-contract-form/unit-rates";
import {
ShipmentFormInputValues,
ShipmentFormValues,
createShipmentFormSchema,
initialShipmentFormValues,
} from "../contracts/new-shipment-form/schema";
import {
downloadContainerImportTemplate,
parseContainerExcel,
} from "../contracts/new-shipment-form/container-excel";
import { formatAmount } from "../contracts/new-shipment-form/total";
import {
shippingLineBookingsService,
type CompleteBookingContainerLine,
type CompleteShippingLineBookingPayload,
type ShippingLineBooking,
type ShippingLinePriceQuote,
} from "@/services/shipping-line-bookings.service";
import { extractApiError } from "@/utils/result";
type ShipmentForm = ReturnType<
typeof useForm<ShipmentFormInputValues, any, ShipmentFormValues>
>;
const SIZES = ["20ft", "40ft"] as const;
/**
* Every quantity on this form is a non-negative magnitude. A native number
* input's `min` only constrains its stepper, so swallow the minus key before it
* can put a negative into the field at all.
*/
const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "-") event.preventDefault();
};
const COMPLETABLE_STATUSES = new Set([
"CLEARANCE_READY",
"OPERATION_CHANGES_REQUESTED",
]);
/**
* Shipping-line booking completion page — a faithful duplicate of the customer
* shipment-booking page (contracts/NewShipmentPage): the same Excel
* import/template flow, the same per-container rows with hazardous/refrigerated
* switches, the same day-picker calendar. Only the plumbing differs: no
* contract — cargo posts to the shipping-line completion endpoint, days come
* from the line's dedicated trains (or the shared pool when it has none), and
* the price lands on the credit ledger instead of an invoice, so there is no
* price-confirmation step.
*/
export default function ShippingLineCompletePage() {
const { id = "" } = useParams();
const navigate = useNavigate();
const bookingQuery = useQuery({
queryKey: ["shipping-line-bookings", id],
queryFn: () => shippingLineBookingsService.getById(id),
enabled: Boolean(id),
});
if (bookingQuery.isLoading) {
return (
<Center mih={400} p="xl">
<Loader color="edr-green" />
</Center>
);
}
const booking = bookingQuery.data;
if (!booking) {
return (
<Box p="xl">
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="xs">
Booking not found
</Text>
<Button
variant="default"
onClick={() => navigate("/shipping-line/bookings")}
>
Back to bookings
</Button>
</Paper>
</Box>
);
}
if (!COMPLETABLE_STATUSES.has(booking.status as string)) {
return (
<Box p="xl">
<Alert color="blue" icon={<AlertCircle size={18} />} radius="md">
<Text fw={700} mb="xs">
This booking cannot be completed yet
</Text>
<Text size="sm" mb="md">
Your documents must be approved by Operations before the cargo and
shipment day can be entered.
</Text>
<Button
color="edr-green"
radius="md"
onClick={() => navigate(`/shipping-line/bookings/${booking.id}`)}
>
Back to booking
</Button>
</Alert>
</Box>
);
}
return <CompleteBookingForm booking={booking} />;
}
function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const isContainer = (booking.freightType ?? "CONTAINER") === "CONTAINER";
const isResubmit = booking.status === "OPERATION_CHANGES_REQUESTED";
// Bulk cargo type lives outside the shared schema (customers get it from
// their contract) — validated at submit time instead.
const [cargoTypeId, setCargoTypeId] = useState<string | null>(null);
const form = useForm<ShipmentFormInputValues, any, ShipmentFormValues>({
defaultValues: {
...initialShipmentFormValues,
// Shipping lines are always invoiced in ETB — no currency choice.
paymentCurrency: "ETB",
},
resolver: zodResolver(
createShipmentFormSchema({
isContainer,
// Both handling services are always offered — there is no contract
// scope to restrict them; charges apply only to ticked containers.
isHazardous: true,
isReefer: true,
withReturnService: false,
unitOfMeasure: "PER_TON",
requiresDate: true,
requiresTrain: false,
}),
),
mode: "onChange",
});
const referenceQuery = useQuery({
queryKey: ["shipping-line-reference-data"],
queryFn: shippingLineBookingsService.referenceData,
});
// 20ft containers ride two per wagon — an odd total can never be planned.
const watchedContainers = form.watch("containers");
const ft20Total = isContainer
? (watchedContainers ?? [])
.filter((l) => l.containerSize === "20ft")
.reduce((sum, l) => sum + Number(l.quantity || 0), 0)
: 0;
const hasOdd20ft = ft20Total % 2 === 1;
// Two-step submit, same shape as the customer shipment form: the confirm
// modal opens at once with the payload pending, the server prices it (and
// runs the 20ft pairing check) while the modal shows a loader, the shipping
// line confirms the figure, and only then does the booking submit.
const [pendingPayload, setPendingPayload] =
useState<CompleteShippingLineBookingPayload | null>(null);
const previewMutation = useMutation({
mutationFn: (payload: CompleteShippingLineBookingPayload) =>
shippingLineBookingsService.pricePreview(booking.id, payload),
// A pricing failure (no rate configured) closes the confirm dialog — the
// error modal takes over with the server's message.
onError: () => setPendingPayload(null),
});
const quote = previewMutation.data ?? null;
const submitMutation = useMutation({
mutationFn: (payload: CompleteShippingLineBookingPayload) =>
shippingLineBookingsService.complete(booking.id, payload),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ["shipping-line-bookings"],
});
navigate(`/shipping-line/bookings/${booking.id}`);
},
// A submit failure (day filled up, window closed meanwhile) must not leave
// a stale confirm dialog on screen — the error modal takes over.
onError: () => {
previewMutation.reset();
setPendingPayload(null);
},
});
const closeConfirm = () => {
if (submitMutation.isPending) return;
previewMutation.reset();
setPendingPayload(null);
};
/**
* Map a container size to the configured container type: reefer type when
* any container on the line is refrigerated, standard type otherwise —
* mirroring the server's own size→type resolution for customers.
*/
function buildPayload(
values: ShipmentFormValues,
): CompleteShippingLineBookingPayload | { error: string } {
const base = {
scheduledDate: values.scheduledDate,
// Which of the line's trains the booking rides — required by the server
// when more than one departs on the chosen day.
...(values.trainScheduleId
? { trainScheduleId: values.trainScheduleId }
: {}),
...(values.paymentCurrency
? { paymentCurrency: values.paymentCurrency }
: {}),
...(values.cargoDescription?.trim()
? { cargoFreeText: values.cargoDescription.trim() }
: {}),
};
if (!isContainer) {
if (!cargoTypeId) return { error: "Select the cargo type." };
return {
...base,
cargoTypeId,
cargoWeightTons: Number(values.cargoWeightTons),
bulkHazardousQuantity: Number(values.bulkHazardousQuantity || 0),
bulkReeferQuantity: Number(values.bulkReeferQuantity || 0),
};
}
const containers: CompleteBookingContainerLine[] = [];
for (const l of values.containers.filter(
(l) => Number(l.quantity) >= 1,
)) {
const totalVgm = l.units.reduce((s, u) => s + Number(u.vgmTons || 0), 0);
containers.push({
// The size string alone — the server maps it to the configured
// container type, so a slow/failed catalog fetch can never block
// the submit with a phantom "type not configured" error.
containerSize: l.containerSize,
quantity: Number(l.quantity),
hazardousQuantity: l.units.filter((u) => u.isHazardous).length,
reeferQuantity: l.units.filter((u) => u.isReefer).length,
vgmPerUnitTons: l.units.length ? totalVgm / l.units.length : 0,
units: l.units.map((u) => ({
containerNumber: u.containerNumber.trim().toUpperCase(),
sealNumber: u.sealNumber || undefined,
vgmTons: Number(u.vgmTons),
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
})),
});
}
return { ...base, containers };
}
const [payloadError, setPayloadError] = useState<string | null>(null);
const handleSubmit = form.handleSubmit((values) => {
if (hasOdd20ft) return;
const payload = buildPayload(values);
if ("error" in payload) {
setPayloadError(payload.error);
return;
}
setPayloadError(null);
// Open the confirm dialog now and price into it; the booking submits only
// after the shipping line confirms the figure.
setPendingPayload(payload);
previewMutation.reset();
previewMutation.mutate(payload);
});
const handleConfirm = () => {
if (!pendingPayload || !quote) return;
// Guard: never let unresolved 20ft pairing errors submit — the server
// rejects them anyway; the disabled button just says so first.
if (quote.pairingErrors.length > 0) return;
submitMutation.mutate(pendingPayload);
};
const showValidationSummary =
form.formState.isSubmitted && !form.formState.isValid;
return (
<Box
style={{
padding: "28px 0 0",
minHeight: "calc(100dvh - var(--app-shell-header-height, 56px))",
display: "flex",
flexDirection: "column",
}}
>
<Group
justify="space-between"
px="24px"
align="flex-end"
wrap="wrap"
gap="md"
mb="lg"
>
<Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
{isResubmit ? "Change Your Booking" : "Complete Your Booking"}
</Title>
<Text size="sm" c="edr-muted" mt={4}>
{isResubmit
? `Update the details below and pick a new shipment day, then resubmit booking ${booking.reference}.`
: `Your documents are approved — enter the cargo details and shipment day for booking ${booking.reference}. The charge goes on your credit account.`}
</Text>
</Box>
<Button
variant="default"
radius="md"
leftSection={<ChevronLeft size={16} />}
onClick={() => navigate(`/shipping-line/bookings/${booking.id}`)}
>
Back to booking
</Button>
</Group>
<form
className="flex flex-col"
style={{ flex: 1 }}
onSubmit={(e) => e.preventDefault()}
>
{/* Errors surface in a modal, not an inline alert: the submit button
sits at the bottom of a long form, so a banner at the top scrolls
out of sight exactly when the user needs it. */}
<Modal
opened={Boolean(
submitMutation.isError || previewMutation.isError || payloadError,
)}
onClose={() => {
setPayloadError(null);
submitMutation.reset();
previewMutation.reset();
}}
centered
radius="md"
size="lg"
title={
<Group gap={8}>
<AlertCircle size={18} color="var(--mantine-color-red-6)" />
<Text fw={700} fz={16}>
Booking could not be completed
</Text>
</Group>
}
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
>
<Stack gap="md">
{(() => {
// The server's message, never axios's generic "Request failed
// with status code 400" — extractApiError digs it out of the
// response body.
const message =
payloadError ??
extractApiError(submitMutation.error ?? previewMutation.error)
.message;
// Missing-rate blocks are an EDR setup gap, not a mistake the
// shipping line made — lead with plain words and where to go,
// then the specifics of what is not priced.
const isPricingGap = message.includes("rate is configured");
// The server joins multiple pricing blocks with "; " — a list
// reads far better than one wall of text.
const parts = message
.split("; ")
.map((p) => p.trim())
.filter(Boolean);
return (
<>
{isPricingGap && (
<Text size="sm" fw={600}>
This booking cannot be completed yet the price for
your shipment has not been set up. Please contact EDR
support to configure it, or change the options below:
</Text>
)}
{parts.length > 1 ? (
<List spacing="sm" size="sm" c="red.8">
{parts.map((part, i) => (
<List.Item key={i}>{part}</List.Item>
))}
</List>
) : (
<Text size="sm" c="red.8">
{message}
</Text>
)}
</>
);
})()}
<Group justify="flex-end">
<Button
variant="default"
radius="md"
onClick={() => {
setPayloadError(null);
submitMutation.reset();
previewMutation.reset();
}}
>
Close
</Button>
</Group>
</Stack>
</Modal>
<PriceConfirmModal
opened={Boolean(pendingPayload)}
quote={quote}
quoteLoading={previewMutation.isPending}
loading={submitMutation.isPending}
onConfirm={handleConfirm}
onReject={closeConfirm}
/>
<Box flex={1} p="24px">
<Stack gap="lg" className="mx-auto max-w-4xl">
<RouteCard booking={booking} />
{isContainer ? (
<ContainerCargoStep form={form} />
) : (
<BulkCargoStep
form={form}
cargoTypeId={cargoTypeId}
onCargoTypeChange={setCargoTypeId}
cargoTypes={referenceQuery.data?.cargoTypes ?? []}
/>
)}
<ScheduleStep
form={form}
booking={booking}
cargoTypeId={isContainer ? null : cargoTypeId}
/>
</Stack>
</Box>
<Box
style={{
position: "sticky",
bottom: 0,
zIndex: 20,
borderTop: "1px solid var(--mantine-color-edr-border-0)",
backgroundColor: "rgba(255,255,255,0.94)",
backdropFilter: "blur(14px)",
padding: "16px 24px",
marginTop: "auto",
}}
>
<Box className="mx-auto max-w-4xl">
{showValidationSummary ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
mb="sm"
>
Fix the highlighted fields before completing the booking.
</Alert>
) : null}
<Group justify="flex-end">
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<PackageCheck size={16} />}
onClick={handleSubmit}
loading={previewMutation.isPending || submitMutation.isPending}
disabled={hasOdd20ft}
>
{isResubmit ? "Resubmit booking" : "Complete booking"}
</Button>
</Group>
</Box>
</Box>
</form>
</Box>
);
}
/**
* Price confirmation — the customer shipment form's modal, one-to-one: opens
* the moment the form submits, shows a loader while the server prices and
* checks 20ft pairing, then the authoritative breakdown. Pairing violations
* hard-block confirm (the server rejects them on /complete too); overweight
* containers only warn — the surcharge is already inside the total.
*/
function PriceConfirmModal({
opened,
quote,
quoteLoading,
loading,
onConfirm,
onReject,
}: {
opened: boolean;
quote: ShippingLinePriceQuote | null;
quoteLoading: boolean;
loading: boolean;
onConfirm: () => void;
onReject: () => void;
}) {
const pairingErrors = quote?.pairingErrors ?? [];
const hasPairingBlock = pairingErrors.length > 0;
const overweightLines = quote?.overweightLines ?? [];
const overweightSurchargeAmount =
quote?.lineItems.find((li) => li.code === "OVERWEIGHT_PER_TON")?.amount ??
0;
// Confirm waits for the authoritative price and a clean pairing check.
const confirmDisabled =
loading || quoteLoading || hasPairingBlock || !quote;
return (
<Modal
opened={opened}
onClose={onReject}
closeOnClickOutside={!loading}
closeOnEscape={!loading}
withCloseButton={!loading}
centered
radius="lg"
size="lg"
title={
<Group gap={10}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<Receipt size={18} />
</ThemeIcon>
<Box>
<Text fw={800} fz={16} c="#10202F">
Confirm shipment price
</Text>
<Text fz="xs" c="dimmed">
Review the total before booking this shipment.
</Text>
</Box>
</Group>
}
>
<Stack gap="md">
{quoteLoading && (
<Group gap={8} c="dimmed">
<Loader size="xs" color="edr-green" />
<Text fz="sm" c="dimmed">
Computing the final price breakdown and checking container
weights
</Text>
</Group>
)}
{hasPairingBlock && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Cannot complete booking — 20ft wagon pairing"
>
<Stack gap={6}>
{pairingErrors.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
Adjust the 20ft container weights or quantities so pairs differ
by no more than 10 tons.
</Text>
</Stack>
</Alert>
)}
{overweightLines.length > 0 && (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertTriangle size={16} />}
title="Overweight containers"
>
<Stack gap={6}>
{overweightLines.map((line, i) => (
<Text key={i} fz="sm" c="#9A5B00">
{line.containerLabel || line.containerTypeCode}:{" "}
{line.totalVgmTons}t exceeds limit {line.maxAllowedTons}t (+
{line.excessTons}t overweight)
</Text>
))}
<Text fz="xs" c="#9A5B00" mt={2}>
{overweightSurchargeAmount > 0
? `An overweight surcharge of ${formatAmount(overweightSurchargeAmount)} ${
quote?.currency ?? ""
} applies (included in the total below). You can still submit, or go back and adjust weights.`
: "An overweight surcharge applies. You can still submit, or go back and adjust weights."}
</Text>
</Stack>
</Alert>
)}
{quote && quote.warnings.length > 0 && (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertTriangle size={16} />}
>
{quote.warnings.join(" ")}
</Alert>
)}
{quote && (
<Paper
withBorder
radius={16}
p="lg"
style={{ borderColor: "#E6ECF2" }}
>
<Stack gap={10}>
{quote.lineItems.map((line, i) => (
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ minWidth: 0 }}>
<Text fz="sm" c="#10202F" fw={500}>
{line.description}
</Text>
<Text fz="xs" c="dimmed">
{(line.quantity ?? 1).toLocaleString()} ×{" "}
{formatAmount(line.unitAmount ?? line.amount)}{" "}
{quote.currency}
{line.unit
? ` · ${formatRateUnit(line.unit.toLowerCase())}`
: ""}
</Text>
</Box>
<Text
fz="sm"
fw={600}
c="#10202F"
style={{ whiteSpace: "nowrap" }}
>
{formatAmount(line.amount)} {quote.currency}
</Text>
</Group>
))}
{quote.lineItems.length === 0 && (
<Text fz="sm" c="dimmed">
No priced lines check the cargo details.
</Text>
)}
</Stack>
<Divider my="md" />
<Group justify="space-between" align="flex-end">
<Text
fz="xs"
fw={700}
tt="uppercase"
c="edr-green"
style={{ letterSpacing: "0.06em" }}
>
Total
</Text>
<Text fw={800} fz={28} c="#10202F">
{formatAmount(quote.totalAmount)}{" "}
<Text span fz={16} fw={700} c="edr-muted">
{quote.currency}
</Text>
</Text>
</Group>
<Text fz="xs" c="dimmed" mt="sm">
The amount is charged to your credit account no payment is due
now. EDR bills your accumulated charges periodically.
</Text>
</Paper>
)}
<Group justify="space-between" mt="xs">
<Button
variant="default"
radius="md"
leftSection={<X size={16} />}
onClick={onReject}
disabled={loading}
>
Reject &amp; edit
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
onClick={onConfirm}
loading={loading}
disabled={confirmDisabled}
>
Confirm &amp; book
</Button>
</Group>
</Stack>
</Modal>
);
}
/** The booking's lane — fixed at initiate time from the chosen route. */
function RouteCard({ booking }: { booking: ShippingLineBooking }) {
return (
<StepCard>
<StepHeader
icon={<MapPin size={22} />}
title="Route"
description="This booking ships on the lane picked when it was initiated."
/>
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
<Text fz={14} fw={600} c="#10202F">
{booking.originYard?.label ?? booking.originYard?.code ?? "—"} {" "}
{booking.destinationYard?.label ??
booking.destinationYard?.code ??
"—"}
</Text>
<Text fz={12} c="dimmed" mt={2}>
{booking.tradeDirection ?? "IMPORT"}
</Text>
</Paper>
</StepCard>
);
}
/** A departure's calendar day in East Africa Time — matches the server's eatDay. */
function eatDayOf(date: string | Date): string {
return new Date(date).toLocaleDateString("en-CA", {
timeZone: "Africa/Addis_Ababa",
});
}
function ScheduleStep({
form,
booking,
cargoTypeId,
}: {
form: ShipmentForm;
booking: ShippingLineBooking;
/** Bulk cargo pick (container bookings pass null) — refines availability. */
cargoTypeId: string | null;
}) {
// Days come from the line's dedicated trains (open until each train's
// close offset), or the shared pool when the lane has none.
const daysQuery = useQuery({
queryKey: ["shipping-line-bookings", booking.id, "available-days"],
queryFn: () => shippingLineBookingsService.availableDays(booking.id),
});
// Cargo context makes the per-train availability figures real: the wagon
// types offered are the ones THIS cargo can ride. Recomputed per render on
// purpose — react-hook-form mutates the watched array in place.
const isContainer = (booking.freightType ?? "CONTAINER") === "CONTAINER";
const containerLines = form.watch("containers") ?? [];
const cargo = (() => {
if (isContainer) {
const sizes = containerLines
.filter((l) => Number(l.quantity || 0) >= 1)
.map((l) => l.containerSize);
const ft20 = containerLines
.filter((l) => l.containerSize === "20ft")
.reduce((s, l) => s + Number(l.quantity || 0), 0);
const ft40 = containerLines
.filter((l) => l.containerSize === "40ft")
.reduce((s, l) => s + Number(l.quantity || 0), 0);
const wagons = Math.ceil(ft20 / 2) + ft40;
return {
containerSizes: sizes.length ? sizes : undefined,
wagons: wagons > 0 ? wagons : undefined,
};
}
return cargoTypeId ? { cargoTypeId } : {};
})();
// The line's OWN departures on this lane, each with per-wagon-type free
// space — the pick is "which train", not an abstract calendar day.
// Customers never see these trains; the endpoint is shipping-line only.
const trainsQuery = useQuery({
queryKey: [
"shipping-line-bookings",
booking.id,
"trains",
cargo.containerSizes?.join(",") ?? "",
(cargo as { cargoTypeId?: string }).cargoTypeId ?? "",
(cargo as { wagons?: number }).wagons ?? 0,
],
queryFn: () =>
shippingLineBookingsService.trainsForDay(booking.id, undefined, cargo),
});
const options = trainsQuery.data ?? [];
const openDays = new Set(daysQuery.data?.days ?? []);
const selectedTrainId = form.watch("trainScheduleId");
return (
<StepCard>
<StepHeader
icon={<CalendarDays size={22} />}
title="Schedule"
description={
options.length
? "Pick the train your shipment rides — these departures are dedicated to your company. A booking rides one train."
: "Pick the binding shipment day. Only days with a train departure on your route can be selected."
}
/>
{/* No currency choice: shipping-line charges always land on the ETB
credit ledger — the form defaults paymentCurrency to ETB and the
server enforces it. */}
<Controller
name="scheduledDate"
control={form.control}
render={({ field, fieldState }) => (
<Box>
<StepLabel>
{options.length ? "Your train *" : "Shipment day *"}
</StepLabel>
<Box mt={10} w="100%">
{trainsQuery.isLoading ? (
<Group gap={8} c="dimmed" py="sm">
<Loader size="xs" color="edr-green" />
<Text fz="sm" c="dimmed">
Checking wagon availability on your trains
</Text>
</Group>
) : options.length ? (
<Stack gap="sm">
{options.map((option) => {
const day = eatDayOf(option.departure);
const selected = selectedTrainId === option.scheduleId;
// Closed when its own cut-off passed OR the day fell out
// of the bookable set.
const closed = !option.isOpen || !openDays.has(day);
const showFit = (option.neededWagons ?? 0) > 0;
return (
<Paper
key={option.scheduleId}
withBorder
radius="md"
p="md"
onClick={() => {
if (closed) return;
field.onChange(day);
form.setValue("trainScheduleId", option.scheduleId, {
shouldValidate: true,
});
}}
style={{
cursor: closed ? "not-allowed" : "pointer",
opacity: closed ? 0.55 : 1,
borderColor: selected
? "var(--mantine-color-teal-6)"
: undefined,
borderWidth: selected ? 2 : 1,
background: selected
? "var(--mantine-color-teal-0)"
: undefined,
}}
>
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="sm" align="flex-start">
<Train
size={20}
color={
selected
? "var(--mantine-color-teal-7)"
: "var(--mantine-color-gray-6)"
}
/>
<Box>
<Group gap={8}>
<Text fw={700} fz={14}>
{option.trainNumber ??
option.trainName ??
"Train"}
</Text>
{option.trainName && option.trainNumber ? (
<Text fz={12} c="dimmed">
{option.trainName}
</Text>
) : null}
{closed && (
<Badge
size="xs"
radius="sm"
color="red"
variant="light"
>
Booking closed
</Badge>
)}
{!closed && showFit && (
<Badge
size="xs"
radius="sm"
color={option.fits ? "teal" : "orange"}
variant="light"
>
{option.fits
? "Fits your cargo"
: "Not enough space"}
</Badge>
)}
</Group>
{/* Free wagons by TYPE — what this train can
still take for the entered cargo. */}
<Group gap={6} mt={6} wrap="wrap">
{option.byWagonType.length ? (
option.byWagonType.map((t) => (
<Badge
key={t.wagonTypeId ?? "any"}
size="sm"
radius="sm"
variant="outline"
color={
t.freeWagons > 0 ? "teal" : "gray"
}
>
{t.code ?? t.name ?? "Wagons"} ·{" "}
{t.freeWagons} free
</Badge>
))
) : (
<Text fz={12} c="dimmed">
Enter your cargo to see wagon availability
by type.
</Text>
)}
</Group>
</Box>
</Group>
<Box ta="right">
<Text fz={13} fw={600}>
Departs{" "}
{new Date(option.departure).toLocaleString(
undefined,
{
weekday: "short",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
},
)}
</Text>
{option.bookingClosesAt && (
<Text fz={12} c="dimmed">
Book before{" "}
{new Date(
option.bookingClosesAt,
).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</Text>
)}
</Box>
</Group>
</Paper>
);
})}
</Stack>
) : (
<OperationDatePicker
fullWidth
availableDays={daysQuery.data?.days ?? []}
isLoading={daysQuery.isLoading}
emptyMessage="No departures are currently open on this route. Please check back or contact Operations."
value={field.value ?? ""}
onChange={(d) => field.onChange(d)}
/>
)}
</Box>
{fieldState.error?.message && (
<Text fz="xs" c="red" mt={6}>
{fieldState.error.message}
</Text>
)}
</Box>
)}
/>
</StepCard>
);
}
function ContainerCargoStep({ form }: { form: ShipmentForm }) {
// Seed one shipment line per size exactly once.
const seededRef = useRef(false);
useEffect(() => {
if (seededRef.current) return;
seededRef.current = true;
const existing = form.getValues("containers") ?? [];
if (existing.length > 0) return;
form.setValue(
"containers",
SIZES.map((size) => ({
containerSize: size,
quantity: "0",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [],
})),
{ shouldValidate: false },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const lines = form.watch("containers") ?? [];
// Excel import: one row per container. All-or-nothing — a file with any bad
// row is rejected with row-numbered errors so nothing is silently dropped.
const [importErrors, setImportErrors] = useState<string[]>([]);
const [importSummary, setImportSummary] = useState<string | null>(null);
const importResetRef = useRef<(() => void) | null>(null);
const excelOpts = {
allowedSizes: [...SIZES],
includeHazardous: true,
includeReefer: true,
includeReturn: false,
};
const handleImportFile = async (file: File | null) => {
// Reset the hidden input so re-picking the same (fixed) file re-fires.
importResetRef.current?.();
if (!file) return;
const { rows, errors } = await parseContainerExcel(file, excelOpts);
if (errors.length > 0) {
setImportSummary(null);
setImportErrors(errors);
return;
}
// Replace only the lines for sizes present in the file; a size the file
// omits keeps whatever was already entered for it.
const current = form.getValues("containers") ?? [];
const next = SIZES.map((size) => {
const imported = rows.filter((r) => r.containerSize === size);
if (imported.length === 0) {
return (
current.find((l) => l.containerSize === size) ?? {
containerSize: size,
quantity: "0",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [],
}
);
}
return {
containerSize: size,
quantity: String(imported.length),
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length),
returnQuantity: "0",
// The spreadsheet already marks handling per row — carry it onto the
// container it belongs to rather than collapsing it to a line count.
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
vgmTons: r.vgmTons,
isHazardous: Boolean(r.hazardous),
isReefer: Boolean(r.reefer),
isReturn: false,
})),
};
});
form.setValue("containers", next, {
shouldValidate: true,
shouldDirty: true,
});
setImportErrors([]);
setImportSummary(`Imported ${rows.length} container(s) from ${file.name}.`);
};
return (
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Enter the quantity and per-container details for each size."
/>
<Stack gap={18}>
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
<Group justify="space-between" wrap="wrap" gap="sm">
<Box>
<Text fz={13} fw={600} c="#10202F">
Import containers from Excel
</Text>
<Text fz={12} c="dimmed">
One row per container. Importing fills the lines below for the
sizes in your file.
</Text>
</Box>
<Group gap="sm">
<Button
variant="default"
size="xs"
radius="md"
leftSection={<FileDown size={14} />}
onClick={() => downloadContainerImportTemplate(excelOpts)}
>
Download template
</Button>
<FileButton
resetRef={importResetRef}
accept=".xlsx,.xls"
onChange={handleImportFile}
>
{(props) => (
<Button
{...props}
size="xs"
radius="md"
color="edr-green"
leftSection={<FileUp size={14} />}
>
Import Excel
</Button>
)}
</FileButton>
</Group>
</Group>
{importErrors.length > 0 && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Import failed — fix the file and try again"
mt="sm"
>
<Stack gap={4}>
{importErrors.slice(0, 8).map((msg, i) => (
<Text key={i} fz="xs">
{msg}
</Text>
))}
{importErrors.length > 8 && (
<Text fz="xs" c="dimmed">
and {importErrors.length - 8} more.
</Text>
)}
</Stack>
</Alert>
)}
{importSummary && (
<Alert
color="edr-green"
variant="light"
radius="md"
icon={<CheckCircle2 size={16} />}
mt="sm"
>
<Text fz="xs">{importSummary}</Text>
</Alert>
)}
</Paper>
<Controller
name="cargoDescription"
control={form.control}
render={({ field, fieldState }) => (
<Textarea
label="Cargo description *"
description="What do the containers carry on this shipment?"
placeholder="e.g. Electronics, garments, machinery spare parts…"
value={field.value ?? ""}
onChange={(e) => field.onChange(e.currentTarget.value)}
onBlur={field.onBlur}
error={fieldState.error?.message}
radius={10}
autosize
minRows={2}
maxRows={4}
styles={fieldStyles}
/>
)}
/>
{lines.map((line, index) => (
<ContainerLineEditor
key={line.containerSize}
form={form}
index={index}
size={line.containerSize}
/>
))}
{(() => {
const ft20 = lines
.filter((l) => l.containerSize === "20ft")
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
if (ft20 % 2 !== 1) return null;
return (
<Alert
color="red"
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.
</Text>
</Alert>
);
})()}
</Stack>
</StepCard>
);
}
function BulkCargoStep({
form,
cargoTypeId,
onCargoTypeChange,
cargoTypes,
}: {
form: ShipmentForm;
cargoTypeId: string | null;
onCargoTypeChange: (v: string | null) => void;
cargoTypes: {
id: string;
name: string;
parentGroupId: string | null;
}[];
}) {
// Grouping headers are rows other rows point at via parentGroupId — only
// leaves are bookable cargo.
const options = useMemo(() => {
const parents = new Set(
cargoTypes
.map((c) => c.parentGroupId)
.filter((id): id is string => Boolean(id)),
);
return cargoTypes
.filter((c) => !parents.has(c.id))
.map((c) => ({ value: c.id, label: c.name }));
}, [cargoTypes]);
return (
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Enter the amount you are shipping for this booking."
/>
<Stack gap={14}>
<Select
label="Cargo type *"
placeholder="Select cargo..."
searchable
data={options}
value={cargoTypeId}
onChange={onCargoTypeChange}
radius={10}
styles={fieldStyles}
comboboxProps={{ withinPortal: true }}
/>
<Controller
name="cargoWeightTons"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Total weight (tons) *"
placeholder="e.g. 1200"
min={0}
step={0.01}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
<Controller
name="bulkHazardousQuantity"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Hazardous quantity"
min={0}
step={1}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
<Controller
name="bulkReeferQuantity"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Refrigerated quantity"
min={0}
step={1}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
<Controller
name="cargoDescription"
control={form.control}
render={({ field }) => (
<Textarea
label="Cargo description (optional)"
placeholder="What the shipment carries"
value={field.value ?? ""}
onChange={(e) => field.onChange(e.currentTarget.value)}
radius={10}
autosize
minRows={1}
maxRows={3}
styles={fieldStyles}
/>
)}
/>
</Stack>
</StepCard>
);
}
function ContainerLineEditor({
form,
index,
size,
}: {
form: ShipmentForm;
index: number;
size: "20ft" | "40ft";
}) {
const line = form.watch(`containers.${index}`);
const quantity = Number(line?.quantity || 0);
const units = line?.units ?? [];
// Keep the units array length in sync with the entered quantity.
const syncUnits = (qty: number) => {
const current = form.getValues(`containers.${index}.units`) ?? [];
const next = [...current];
while (next.length < qty)
next.push({
containerNumber: "",
sealNumber: "",
vgmTons: "",
isHazardous: false,
isReefer: false,
isReturn: false,
});
next.length = Math.max(0, qty);
form.setValue(`containers.${index}.units`, next, { shouldValidate: false });
syncHandlingCounts(next);
};
// Drop one container row and shrink quantity to match — the inverse of
// syncUnits growing the array when quantity goes up.
const removeUnit = (unitIdx: number) => {
const current = form.getValues(`containers.${index}.units`) ?? [];
const next = current.filter((_, j) => j !== unitIdx);
form.setValue(`containers.${index}.units`, next, { shouldValidate: false });
form.setValue(`containers.${index}.quantity`, String(next.length), {
shouldValidate: true,
});
syncHandlingCounts(next);
};
/**
* Line totals are a roll-up of the per-container switches — the count is
* however many containers ticked each service. Kept in form state so the
* submitted payload stays in step with the switches.
*/
const syncHandlingCounts = (
units: Array<{ isHazardous?: boolean; isReefer?: boolean }>,
) => {
const set = (
key: "hazardousQuantity" | "reeferQuantity",
count: number,
) =>
form.setValue(`containers.${index}.${key}`, String(count), {
shouldDirty: true,
shouldValidate: true,
});
set("hazardousQuantity", units.filter((u) => u.isHazardous).length);
set("reeferQuantity", units.filter((u) => u.isReefer).length);
};
/** Flip one container's handling switch, then re-roll the line totals. */
const toggleUnitHandling = (
unitIndex: number,
key: "isHazardous" | "isReefer",
on: boolean,
) => {
form.setValue(`containers.${index}.units.${unitIndex}.${key}`, on, {
shouldDirty: true,
});
syncHandlingCounts(form.getValues(`containers.${index}.units`) ?? []);
};
const handlingColumns: Array<{
key: "isHazardous" | "isReefer";
label: string;
icon: ReactNode;
color: string;
}> = [
{
key: "isHazardous",
label: "Hazardous",
icon: <Flame size={14} />,
color: "#C0392B",
},
{
key: "isReefer",
label: "Refrigerated",
icon: <Snowflake size={14} />,
color: "#2E5B96",
},
];
return (
<Box
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 16 }}
>
<Text fz={14} fw={700} c="#10202F" mb={10}>
{size} containers
</Text>
<Box maw={220} mb={14}>
<Controller
name={`containers.${index}.quantity`}
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Quantity *"
min={0}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
onChange={(e) => {
field.onChange(e.currentTarget.value);
}}
onBlur={(e) => {
field.onBlur();
syncUnits(Number(e.currentTarget.value || 0));
}}
/>
)}
/>
</Box>
<StepLabel>Per-container details</StepLabel>
{quantity > 0 ? (
<Text fz={11} c="#5B6B7B" mt={4}>
Tick the services each individual container needs charges apply only
to the containers you tick.
</Text>
) : null}
<Stack gap={10} mt={8}>
{/* Header row — input labels + handling-service labels, one aligned
grid shared by every unit row below. */}
{Math.max(quantity, units.length) > 0 && (
<Group gap={10} wrap="nowrap" align="flex-end">
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Container number *
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Seal number
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
VGM (tons) *
</Text>
{handlingColumns.map((col) => (
<Group
key={col.key}
gap={4}
wrap="nowrap"
justify="center"
style={{ width: 96, flexShrink: 0 }}
>
<span style={{ color: col.color, display: "flex" }}>
{col.icon}
</span>
<Text fz={12} fw={600} c="#10202F">
{col.label}
</Text>
</Group>
))}
</Group>
)}
{Array.from({ length: Math.max(quantity, units.length) }).map(
(_, u) => (
<Group key={u} gap={10} wrap="nowrap" align="flex-start">
<Controller
name={`containers.${index}.units.${u}.containerNumber`}
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
onChange={(e) =>
field.onChange(e.currentTarget.value.toUpperCase())
}
placeholder="e.g. MSCU1234567"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
<Controller
name={`containers.${index}.units.${u}.sealNumber`}
control={form.control}
render={({ field }) => (
<TextInput
{...field}
placeholder="Optional"
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
<Controller
name={`containers.${index}.units.${u}.vgmTons`}
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
placeholder="e.g. 24.5"
min={0}
step={0.01}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
{handlingColumns.map((col) => (
<Controller
key={col.key}
name={`containers.${index}.units.${u}.${col.key}`}
control={form.control}
render={({ field }) => (
<Box
style={{
width: 96,
flexShrink: 0,
height: 42,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Switch
checked={Boolean(field.value)}
aria-label={`${col.label} — container ${u + 1}`}
onChange={(e) =>
toggleUnitHandling(u, col.key, e.currentTarget.checked)
}
size="sm"
/>
</Box>
)}
/>
))}
<ActionIcon
variant="subtle"
color="red"
aria-label={`Remove container ${u + 1}`}
onClick={() => removeUnit(u)}
>
<X size={16} />
</ActionIcon>
</Group>
),
)}
</Stack>
</Box>
);
}