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