Files
edr-platform/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx
2026-07-03 13:26:40 +00:00

1052 lines
32 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 } 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";
type ShipmentForm = ReturnType<
typeof useForm<ShipmentFormInputValues, any, ShipmentFormValues>
>;
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 }),
);
if (isLoading) {
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>
);
}
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),
}),
),
mode: "onChange",
});
const isContainerContract = contract.freightType === "CONTAINER";
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 }
: {}),
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. For container contracts we also run the server-side shipment
// validation (overweight warnings + 20ft pairing hard-blocks) so the modal
// can surface them before the booking is created.
const handleReview = form.handleSubmit((values) => {
setPendingValues(values);
if (isContainerContract) {
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;
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 confirmDisabled = loading || validationLoading || hasPairingBlock;
// The contract's frozen unit rates (computeShipmentTotal) don't carry an
// overweight line — that surcharge only exists in the live rule engine. Fold
// the real amount from validateShipment into the displayed total so the
// customer sees the actual charge the overweight warning refers to, not just
// the warning text.
const total = useMemo(() => {
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,
};
}, [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">
Checking container weights and wagon pairing
</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>
)}
{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 { data: availableDays, isLoading } = useQuery({
...api.bookings.getAvailableDaysForCargo.queryOptions({
input: cargoQuery ?? ({ freightType: "BULK" } as Freight.AvailableDaysForCargoQuery),
}),
enabled: cargoQuery !== null,
});
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"
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"
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"
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"
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"
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"
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"
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}
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"
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>
);
}