mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 01:18:18 +00:00
- 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.
1224 lines
38 KiB
TypeScript
1224 lines
38 KiB
TypeScript
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 & 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 & 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 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>
|
||
);
|
||
}
|