mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1660 lines
55 KiB
TypeScript
1660 lines
55 KiB
TypeScript
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 & edit
|
||
</Button>
|
||
<Button
|
||
color="edr-green"
|
||
radius="md"
|
||
leftSection={<CheckCircle2 size={16} />}
|
||
onClick={onConfirm}
|
||
loading={loading}
|
||
disabled={confirmDisabled}
|
||
>
|
||
Confirm & 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>
|
||
);
|
||
}
|