Files
edr-platform/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx
Marshal cd8fb2b321 enhance gate pass and freight payment handling in train scheduling
- Updated the logic in  to ensure that a booking only earns its gate pass once the freight charges are settled.
- Added logging for bookings that have not settled freight payment when securing gate passes.
- Modified seeders to ensure that bookings have associated company profiles to prevent data inconsistencies.
- Updated freight permissions to include new clearance actions for bookings.
- Enhanced the UI to reflect changes in the clearance process, including new shipment request pages and improved status handling in the clearance action panel.
- Adjusted the contract clearance list to accommodate both customs contracts and shipment bookings.
- Improved the handling of GENERAL contracts in various components to ensure proper booking flow and visibility.
2026-07-09 07:19:36 +00:00

1224 lines
38 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 } 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 {
Alert,
Box,
Button,
Center,
Divider,
Group,
Loader,
Modal,
Paper,
Stack,
Text,
TextInput,
Textarea,
ThemeIcon,
Title,
} from "@mantine/core";
import {
AlertCircle,
AlertTriangle,
CalendarDays,
CheckCircle2,
ChevronLeft,
MapPin,
Package,
Receipt,
X,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { OperationDatePicker } from "@edr/ui-common";
import { api } from "@/services/api";
import type { ShipmentValidation } from "@/services/contracts.service";
import {
SelectField,
StepCard,
StepHeader,
StepLabel,
fieldStyles,
} from "./new-contract-form/shared";
import { formatRateUnit } from "./new-contract-form/unit-rates";
import {
ShipmentFormInputValues,
ShipmentFormValues,
createShipmentFormSchema,
initialShipmentFormValues,
} from "./new-shipment-form/schema";
import { computeShipmentTotal } from "./new-shipment-form/total";
import { ContractCapacityNotice } from "./new-shipment-form/ContractCapacityNotice";
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
type ShipmentForm = ReturnType<
typeof useForm<ShipmentFormInputValues, any, ShipmentFormValues>
>;
/**
* 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();
};
export default function NewShipmentPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: contract, isLoading } = useQuery(
api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }),
);
// Coarse booking-window gate: block the form entirely when no window is
// currently open for the contract's routes. The day-picker inside the form
// still narrows to bookable days; this is the outer "is booking open at all"
// check that mirrors the contract detail page.
const { data: bookingWindows = [], isLoading: windowsLoading } = useQuery({
...api.bookings.getContractBookingWindows.queryOptions({
input: { contractId: id! },
refetchInterval: 60_000,
}),
enabled: !!id,
});
if (isLoading || windowsLoading) {
return (
<Center mih={400} p="xl">
<Loader color="edr-green" />
</Center>
);
}
if (!contract) {
return (
<Box p="xl">
<Paper withBorder radius="lg" p="xl">
<Text fw={700} mb="xs">
Contract not found
</Text>
<Button variant="default" onClick={() => navigate("/contracts")}>
Back to contracts
</Button>
</Paper>
</Box>
);
}
if (contract.customsClearingEnabled) {
return (
<Box p="xl">
<Alert color="blue" icon={<AlertCircle size={18} />} radius="md">
<Text fw={700} mb="xs">
Global Logistics handles bookings for this contract
</Text>
<Text size="sm" mb="md">
This contract includes customs clearance. Upload your clearance
documents once Global Logistics finalizes the clearance, they
create the booking on your behalf and you will be notified when
payment is due.
</Text>
<Button
color="edr-green"
radius="md"
onClick={() => navigate(`/contracts/${contract.id}/clearance`)}
>
Go to clearance
</Button>
</Alert>
</Box>
);
}
// Coarse gate: if the customer deep-links here while no booking window is
// open, show the same closed-state notice as the contract page instead of the
// form. Still allowed the moment any window isOpenNow. Intercity contracts
// are never window-gated — the shipment rides a passing train that staff
// pick at finalize time, so booking is always open. GENERAL contracts are not
// gated at creation either: the booking enters per-booking clearance first
// and picks its shipment day at proceed time.
if (
contract.tradeDirection !== "DOMESTIC" &&
contract.contractKind !== "GENERAL" &&
!hasOpenWindow(bookingWindows)
) {
return (
<Box style={{ padding: "28px 0 0" }}>
<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" }}
>
New Shipment Booking
</Title>
<Text size="sm" c="edr-muted" mt={4}>
Book a shipment against contract {contract.reference}.
</Text>
</Box>
<Button
variant="default"
radius="md"
leftSection={<ChevronLeft size={16} />}
onClick={() => navigate(`/contracts/${contract.id}`)}
>
Back to contract
</Button>
</Group>
<Box px="24px">
<Alert
color="yellow"
variant="light"
radius="md"
icon={<CalendarDays size={18} />}
title="Booking is not open right now"
>
<Text size="sm">{closedWindowMessage(bookingWindows)}</Text>
<Text size="sm" mt="xs">
Come back when the booking window opens to book your shipment.
</Text>
</Alert>
</Box>
</Box>
);
}
return <NewShipmentBookingForm contract={contract} contractId={id!} />;
}
function bulkUnitOfMeasure(
contract: Freight.IContract,
): "PER_TON" | "PER_ITEM" {
const hasPerItem = contract.pricingBreakdown?.lineItems?.some(
(li) => li.unit === "per_item",
);
return hasPerItem ? "PER_ITEM" : "PER_TON";
}
function NewShipmentBookingForm({
contract,
contractId,
}: {
contract: Freight.IContract;
contractId: string;
}) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [pendingValues, setPendingValues] = useState<ShipmentFormValues | null>(
null,
);
const form = useForm<ShipmentFormInputValues, any, ShipmentFormValues>({
defaultValues: initialShipmentFormValues,
resolver: zodResolver(
createShipmentFormSchema({
isContainer: contract.freightType === "CONTAINER",
isHazardous: contract.isHazardous ?? false,
isReefer: contract.isReefer ?? false,
unitOfMeasure: bulkUnitOfMeasure(contract),
// Intercity rides a passing train staff pick later — no date to choose.
requiresDate: contract.tradeDirection !== "DOMESTIC",
}),
),
mode: "onChange",
});
const submitMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
api.contracts.createBookingUnderContract.call({ id: contractId, dto }),
onSuccess: (booking) => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
queryClient.invalidateQueries({
queryKey: api.contracts.get.queryKey({ id: contractId }),
});
navigate(`/bookings/${booking.id}`);
},
});
// Pre-submit validation (container contracts only): warns on overweight
// containers and HARD-BLOCKS on 20ft wagon-pairing errors. Runs each time the
// price modal opens so re-reviewing after an edit re-checks.
const validateMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
api.contracts.validateShipment.call({ id: contractId, dto }),
});
function buildDto(
values: ShipmentFormValues,
): Freight.CreateBookingUnderContractDto {
const isContainer = contract.freightType === "CONTAINER";
return {
...(values.contractRouteId
? { contractRouteId: values.contractRouteId }
: {}),
// Intercity bookings carry no date — staff assign a passing train later.
...(values.scheduledDate
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
: {}),
...(isContainer
? {
containers: values.containers
.filter((l) => Number(l.quantity) >= 1)
.map((l) => ({
containerSize: l.containerSize,
quantity: Number(l.quantity),
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
units: l.units.map((u) => ({
containerNumber: u.containerNumber,
sealNumber: u.sealNumber || undefined,
vgmTons: Number(u.vgmTons),
})),
})),
}
: {
bulkLines: [
{
cargoTypeId: contract.cargoScope?.[0]?.cargoTypeId ?? null,
cargoWeightTons: values.cargoWeightTons
? Number(values.cargoWeightTons)
: undefined,
itemCount: values.itemCount
? Number(values.itemCount)
: undefined,
hazardousQuantity:
Number(values.bulkHazardousQuantity || 0) || undefined,
reeferQuantity:
Number(values.bulkReeferQuantity || 0) || undefined,
},
],
}),
...(values.notes ? { notes: values.notes } : {}),
};
}
// Submit validates the whole form, then opens the price modal for
// confirmation. The server-side shipment validation also returns the
// authoritative price breakdown (rail + first/last mile + every surcharge) —
// 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) => {
setPendingValues(values);
validateMutation.reset();
validateMutation.mutate(buildDto(values));
});
const handleConfirm = () => {
if (!pendingValues) return;
// Guard: never let a booking with unresolved 20ft pairing errors submit.
if ((validateMutation.data?.pairingErrors.length ?? 0) > 0) return;
// Guard: a line above the container type's max capacity can never book.
if ((validateMutation.data?.capacityErrors?.length ?? 0) > 0) return;
submitMutation.mutate(buildDto(pendingValues));
};
// Reject — close the modal and let the customer edit and re-book.
const handleReject = () => {
if (submitMutation.isPending) return;
setPendingValues(null);
validateMutation.reset();
};
const routes = contract.routes ?? [];
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" }}>
New Shipment Booking
</Title>
<Text size="sm" c="edr-muted" mt={4}>
Book a shipment against contract {contract.reference}.
</Text>
</Box>
<Button
variant="default"
radius="md"
leftSection={<ChevronLeft size={16} />}
onClick={() => navigate(`/contracts/${contract.id}`)}
>
Back to contract
</Button>
</Group>
<form
className="flex flex-col"
style={{ flex: 1 }}
onSubmit={(e) => e.preventDefault()}
>
<Box flex={1} p="24px">
{submitMutation.isError && (
<Alert
color="red"
icon={<AlertCircle size={16} />}
radius="md"
mb="lg"
>
<Text size="sm" fw={600}>
Failed to create the shipment booking
</Text>
<Text size="sm" mt={4} c="red.7">
{submitMutation.error instanceof Error
? submitMutation.error.message
: "An unexpected error occurred. Please try again."}
</Text>
</Alert>
)}
{/* Single-step form — all sections on one page. */}
<Stack gap="lg" className="mx-auto max-w-4xl">
<RouteStep form={form} contract={contract} routes={routes} />
<CargoStep form={form} contract={contract} />
<ScheduleStep form={form} contract={contract} routes={routes} />
<NotesSection form={form} />
</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",
}}
>
<Group justify="flex-end" className="mx-auto max-w-4xl">
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
>
Review price &amp; book
</Button>
</Group>
</Box>
</form>
<PriceConfirmModal
contract={contract}
values={pendingValues}
loading={submitMutation.isPending}
validation={validateMutation.data ?? null}
validationLoading={validateMutation.isPending}
onConfirm={handleConfirm}
onReject={handleReject}
/>
</Box>
);
}
function PriceConfirmModal({
contract,
values,
loading,
validation,
validationLoading,
onConfirm,
onReject,
}: {
contract: Freight.IContract;
values: ShipmentFormValues | null;
loading: boolean;
validation: ShipmentValidation | null;
validationLoading: boolean;
onConfirm: () => void;
onReject: () => void;
}) {
const baseTotal = useMemo(
() => (values ? computeShipmentTotal(contract, values) : null),
[contract, values],
);
const overweightLines = validation?.overweightLines ?? [];
const overweightSurchargeAmount = validation?.overweightSurchargeAmount ?? 0;
const pairingErrors = validation?.pairingErrors ?? [];
const hasPairingBlock = pairingErrors.length > 0;
const capacityErrors = validation?.capacityErrors ?? [];
const hasCapacityBlock = capacityErrors.length > 0;
// Authoritative server breakdown — the SAME BookingPricingService pass that
// prices the booking on create, so it carries every line the booking will be
// charged: rail freight, first/last mile trucking, overweight, hazard/reefer
// and any other rule-engine surcharge.
const serverTotal = useMemo(() => {
const items = validation?.lineItems;
if (!items?.length) return null;
return {
currency: validation?.currency ?? baseTotal?.currency ?? "ETB",
lines: items.map((li) => ({
label: li.description,
unitPrice: li.unitAmount,
unit: li.unit.toLowerCase(),
quantity: li.quantity,
amount: li.amount,
})),
total:
validation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0),
};
}, [validation, baseTotal]);
// Block confirm until the authoritative server price is in hand. The client
// baseTotal fallback is display-only; booking on it (e.g. after a validation
// error clears validationLoading with no data) would let the customer confirm
// an un-validated, possibly wrong price.
const confirmDisabled =
loading ||
validationLoading ||
hasPairingBlock ||
hasCapacityBlock ||
!serverTotal;
// Fallback while the server preview loads: the contract's frozen unit rates
// (container/bulk + hazard/reefer only) with the overweight surcharge folded
// in. Replaced by the full server breakdown the moment it arrives.
const total = useMemo(() => {
if (serverTotal) return serverTotal;
if (!baseTotal) return null;
if (!(overweightSurchargeAmount > 0)) return baseTotal;
return {
...baseTotal,
lines: [
...baseTotal.lines,
{
label: "Overweight surcharge",
unitPrice: overweightSurchargeAmount,
unit: "flat" as const,
quantity: 1,
amount: overweightSurchargeAmount,
},
],
total: baseTotal.total + overweightSurchargeAmount,
};
}, [serverTotal, baseTotal, overweightSurchargeAmount]);
return (
<Modal
opened={Boolean(values)}
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>
}
>
{total ? (
<Stack gap="md">
{validationLoading && (
<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 create 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>
)}
{hasCapacityBlock && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Cannot create booking — over maximum capacity"
>
<Stack gap={6}>
{capacityErrors.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
Reduce the cargo weight or split it across more containers to
book this shipment.
</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.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 ${overweightSurchargeAmount.toLocaleString()} ${
validation?.currency ?? total?.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>
)}
<Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}>
<Stack gap={10}>
{total.lines.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.label}
</Text>
<Text fz="xs" c="dimmed">
{line.quantity.toLocaleString()} ×{" "}
{line.unitPrice.toLocaleString()} {total.currency} ·{" "}
{formatRateUnit(line.unit)}
</Text>
</Box>
<Text
fz="sm"
fw={600}
c="#10202F"
style={{ whiteSpace: "nowrap" }}
>
{line.amount.toLocaleString()} {total.currency}
</Text>
</Group>
))}
{total.lines.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">
{total.total.toLocaleString()}{" "}
<Text span fz={16} fw={700} c="edr-muted">
{total.currency}
</Text>
</Text>
</Group>
</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>
) : null}
</Modal>
);
}
function RouteStep({
form,
contract,
routes,
}: {
form: ShipmentForm;
contract: Freight.IContract;
routes: Freight.IContractRoute[];
}) {
const multiRoute = routes.length > 1;
return (
<StepCard>
<StepHeader
icon={<MapPin size={22} />}
title="Route"
description={
multiRoute
? "Choose which contracted route this shipment ships on."
: "This shipment ships on the contract's only route."
}
/>
{multiRoute ? (
<Controller
name="contractRouteId"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Contract route *"
placeholder="Select a route..."
data={routes.map((r) => ({
value: r.id,
label: `${r.originYard?.label ?? r.originYardId}${
r.destinationYard?.label ?? r.destinationYardId
}`,
}))}
/>
)}
/>
) : (
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
<Text fz={14} fw={600} c="#10202F">
{routes[0]?.originYard?.label ?? "—"} {" "}
{routes[0]?.destinationYard?.label ?? "—"}
</Text>
<Text fz={12} c="dimmed" mt={2}>
{contract.tradeDirection}
</Text>
</Paper>
)}
</StepCard>
);
}
function ScheduleStep({
form,
contract,
routes,
}: {
form: ShipmentForm;
contract: Freight.IContract;
routes: Freight.IContractRoute[];
}) {
const contractRouteId = form.watch("contractRouteId");
const route = routes.find((r) => r.id === contractRouteId) ?? routes[0];
// Read the cargo entered in the previous step so the day list reflects what
// can actually be shipped (matching wagons + open train capacity).
const isContainer = contract.freightType === "CONTAINER";
const containerLines = form.watch("containers");
const cargoWeightTons = form.watch("cargoWeightTons");
const itemCount = form.watch("itemCount");
const cargoQuery = useMemo<Freight.AvailableDaysForCargoQuery | null>(() => {
if (!route?.originYardId || !route?.destinationYardId) return null;
if (isContainer) {
const containers = (containerLines ?? [])
.map((l) => ({
containerSize: l.containerSize,
quantity: Number(l.quantity || 0),
}))
.filter((c) => c.quantity >= 1);
if (containers.length === 0) return null;
return {
originYardId: route.originYardId,
destinationYardId: route.destinationYardId,
freightType: "CONTAINER",
containers,
};
}
const tons = Number(cargoWeightTons || 0);
if (tons <= 0) return null;
return {
originYardId: route.originYardId,
destinationYardId: route.destinationYardId,
freightType: "BULK",
cargoTypeCode:
contract.pricingBreakdown?.lineItems?.find((li) => li.cargoTypeCode)
?.cargoTypeCode ?? undefined,
totalWeightTons: tons,
};
// itemCount is referenced so the query refreshes when a PER_ITEM cargo
// amount changes (weight is the sizing input the backend uses).
}, [
route,
isContainer,
containerLines,
cargoWeightTons,
itemCount,
contract.pricingBreakdown,
]);
const isIntercity = contract.tradeDirection === "DOMESTIC";
const { data: availableDays, isLoading } = useQuery({
...api.bookings.getAvailableDaysForCargo.queryOptions({
input: cargoQuery ?? ({ freightType: "BULK" } as Freight.AvailableDaysForCargoQuery),
}),
enabled: cargoQuery !== null && !isIntercity,
});
if (isIntercity) {
return (
<StepCard>
<StepHeader
icon={<CalendarDays size={22} />}
title="Schedule"
description="Intercity shipments have no fixed day."
/>
<Alert color="blue" variant="light" radius="md" icon={<AlertCircle size={16} />}>
Your shipment rides the next import/export train passing through your
corridor. Operations assign it to a train with free capacity you
will be notified when it is accepted and payment is due.
</Alert>
</StepCard>
);
}
return (
<StepCard>
<StepHeader
icon={<CalendarDays size={22} />}
title="Schedule"
description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for your cargo can be selected."
/>
{cargoQuery === null ? (
<Alert color="yellow" variant="light" radius="md" icon={<AlertCircle size={16} />}>
Enter your cargo details first available shipment days depend on the
wagons your cargo needs.
</Alert>
) : (
<Controller
name="scheduledDate"
control={form.control}
render={({ field, fieldState }) => (
<Box>
<StepLabel>Shipment day *</StepLabel>
<Box mt={10} w="100%">
<OperationDatePicker
fullWidth
availableDays={availableDays ?? []}
isLoading={isLoading}
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 CargoStep({
form,
contract,
}: {
form: ShipmentForm;
contract: Freight.IContract;
}) {
const isContainer = contract.freightType === "CONTAINER";
// Sizes enabled by the contract scope.
const sizes = useMemo(
() =>
(contract.cargoScope ?? [])
.map((s) => s.containerSize)
.filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft"),
[contract],
);
// Seed one shipment line per contracted size exactly once (never during
// render — appending in render loops). Subsequent renders reuse the lines.
const seededRef = useRef(false);
useEffect(() => {
if (seededRef.current || !isContainer) return;
seededRef.current = true;
const existing = form.getValues("containers") ?? [];
if (existing.length > 0) return;
form.setValue(
"containers",
sizes.map((size) => ({
containerSize: size,
quantity: "1",
hazardousQuantity: "0",
reeferQuantity: "0",
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
})),
{ shouldValidate: false },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const lines = form.watch("containers") ?? [];
if (isContainer) {
return (
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Enter the quantity and per-container details for each size in your contract scope."
/>
<Stack gap={18}>
<ContractCapacityNotice contractId={contract.id} isContainer />
{lines.map((line, index) => (
<ContainerLineEditor
key={line.containerSize}
form={form}
index={index}
size={line.containerSize}
isHazardous={contract.isHazardous}
isReefer={contract.isReefer}
/>
))}
{sizes.length === 0 && (
<Text fz="sm" c="dimmed">
This contract has no container sizes in scope.
</Text>
)}
</Stack>
</StepCard>
);
}
return (
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Enter the amount you are shipping for this booking."
/>
<Stack gap={14}>
<ContractCapacityNotice contractId={contract.id} isContainer={false} />
<Controller
name="cargoWeightTons"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Quantity (tons)"
placeholder="e.g. 1200"
min={0}
step={0.01}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
<Controller
name="itemCount"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Item count (if applicable)"
placeholder="e.g. 500"
min={0}
step={1}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
{contract.isHazardous && (
<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}
/>
)}
/>
)}
{contract.isReefer && (
<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}
/>
)}
/>
)}
</Stack>
</StepCard>
);
}
function NotesSection({ form }: { form: ShipmentForm }) {
return (
<StepCard>
<Controller
name="notes"
control={form.control}
render={({ field }) => (
<Textarea
{...field}
label="Additional notes"
placeholder="Any special instructions for this shipment…"
rows={3}
radius="md"
/>
)}
/>
</StepCard>
);
}
function ContainerLineEditor({
form,
index,
size,
isHazardous,
isReefer,
}: {
form: ShipmentForm;
index: number;
size: "20ft" | "40ft";
isHazardous: boolean;
isReefer: boolean;
}) {
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: "" });
next.length = Math.max(0, qty);
form.setValue(`containers.${index}.units`, next, { shouldValidate: false });
};
return (
<Box
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 16 }}
>
<Text fz={14} fw={700} c="#10202F" mb={10}>
{size} containers
</Text>
<Group gap={12} grow mb={12} align="flex-start">
<Controller
name={`containers.${index}.quantity`}
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Quantity *"
min={1}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
onChange={(e) => {
field.onChange(e.currentTarget.value);
syncUnits(Number(e.currentTarget.value || 0));
}}
/>
)}
/>
{isHazardous && (
<Controller
name={`containers.${index}.hazardousQuantity`}
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Hazardous qty"
min={0}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
{isReefer && (
<Controller
name={`containers.${index}.reeferQuantity`}
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Reefer qty"
min={0}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
</Group>
<StepLabel>Per-container details</StepLabel>
<Stack gap={10} mt={8}>
{Array.from({ length: Math.max(quantity, units.length) }).map((_, u) => (
<Group key={u} gap={10} grow 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())
}
label={u === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
<Controller
name={`containers.${index}.units.${u}.sealNumber`}
control={form.control}
render={({ field }) => (
<TextInput
{...field}
label={u === 0 ? "Seal number" : undefined}
placeholder="Optional"
radius={10}
styles={fieldStyles}
/>
)}
/>
<Controller
name={`containers.${index}.units.${u}.vgmTons`}
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label={u === 0 ? "VGM (tons) *" : undefined}
placeholder="e.g. 24.5"
min={0}
step={0.01}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
</Group>
))}
</Stack>
</Box>
);
}