mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: enhance train scheduling and contract management features
- Added StationWorkControls to manage loading/unloading phases in TrainScheduleV2DetailPage. - Implemented API endpoints for recording station work and managing wagon detach requests. - Updated contract templates to include Ethiopian customs handling options. - Enhanced shipment forms to collect customs clearing agent details for without-customs bookings. - Introduced NUMBER_OF_WAGONS as a unit of measure for bulk cargo, allowing customers to specify wagon counts. - Improved validation for customs clearing agent information in shipment forms. - Updated various components and services to accommodate new features and ensure data integrity.
This commit is contained in:
@@ -648,14 +648,9 @@ export default function NewContractPage({
|
||||
: {}),
|
||||
// Customs bundling is a property of the chosen service, not of a stored
|
||||
// form flag — derive it here so stale drafts can't misreport it. A
|
||||
// non-bundled contract still records the customer's own clearing agent.
|
||||
...(serviceType?.includesCustoms
|
||||
? { customsClearingEnabled: true }
|
||||
: {
|
||||
customsClearingEnabled: false,
|
||||
customsClearingAgent:
|
||||
data.customsClearingAgent?.trim() || undefined,
|
||||
}),
|
||||
// non-bundled contract collects the clearing agent per booking, at
|
||||
// booking completion — nothing on the contract.
|
||||
customsClearingEnabled: Boolean(serviceType?.includesCustoms),
|
||||
cargoScope,
|
||||
routes,
|
||||
};
|
||||
|
||||
@@ -239,7 +239,19 @@ export default function NewShipmentPage() {
|
||||
|
||||
function bulkUnitOfMeasure(
|
||||
contract: Freight.IContract,
|
||||
): "PER_TON" | "PER_ITEM" {
|
||||
): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" {
|
||||
// The cargo type's own configured unit wins; the pricing-line sniff below is
|
||||
// the legacy fallback for contracts loaded without the cargoScope relation.
|
||||
const configured = contract.cargoScope?.find(
|
||||
(scope) => scope.cargoType?.unitOfMeasure,
|
||||
)?.cargoType?.unitOfMeasure;
|
||||
if (
|
||||
configured === "PER_TON" ||
|
||||
configured === "PER_ITEM" ||
|
||||
configured === "NUMBER_OF_WAGONS"
|
||||
) {
|
||||
return configured;
|
||||
}
|
||||
const hasPerItem = contract.pricingBreakdown?.lineItems?.some(
|
||||
(li) => li.unit === "per_item",
|
||||
);
|
||||
@@ -288,6 +300,10 @@ function mapBookingToShipmentValues(
|
||||
: "",
|
||||
withReturn: booking.equipmentReturn === "WITH_RETURN",
|
||||
cargoDescription: b.cargoFreeText ?? "",
|
||||
// The agent entered at the first completion stays on a resubmit.
|
||||
customsClearingAgent: booking.customsClearingAgent ?? "",
|
||||
customsClearingAgentEmail: booking.customsClearingAgentEmail ?? "",
|
||||
customsClearingAgentPhone: booking.customsClearingAgentPhone ?? "",
|
||||
...(b.contractRouteId ? { contractRouteId: b.contractRouteId } : {}),
|
||||
};
|
||||
if (contract.freightType === "CONTAINER") {
|
||||
@@ -317,9 +333,9 @@ function mapBookingToShipmentValues(
|
||||
.filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft");
|
||||
values.containers = (sizes.length ? sizes : (["20ft", "40ft"] as const)).map(lineFor);
|
||||
} else {
|
||||
const perItem = bulkUnitOfMeasure(contract) === "PER_ITEM";
|
||||
const uom = bulkUnitOfMeasure(contract);
|
||||
const amount = Number(b.cargoTotalWeightVgm ?? 0);
|
||||
if (perItem) {
|
||||
if (uom === "PER_ITEM") {
|
||||
values.itemCount = amount ? String(amount) : "";
|
||||
values.cargoWeightTons =
|
||||
b.bulkTotalWeightTons != null
|
||||
@@ -327,6 +343,17 @@ function mapBookingToShipmentValues(
|
||||
: "";
|
||||
} else {
|
||||
values.cargoWeightTons = amount ? String(amount) : "";
|
||||
if (uom === "NUMBER_OF_WAGONS") {
|
||||
const wagons = Number(
|
||||
(b as { bulkRequestedWagons?: number | string | null })
|
||||
.bulkRequestedWagons ?? 0,
|
||||
);
|
||||
const items = Number(
|
||||
(b as { bulkItemCount?: number | string | null }).bulkItemCount ?? 0,
|
||||
);
|
||||
values.requestedWagons = wagons ? String(wagons) : "";
|
||||
values.itemCount = items ? String(items) : "";
|
||||
}
|
||||
}
|
||||
values.bulkHazardousQuantity = String(Number(b.bulkHazardousQuantity ?? 0));
|
||||
values.bulkReeferQuantity = String(Number(b.bulkReeferQuantity ?? 0));
|
||||
@@ -413,6 +440,11 @@ function NewShipmentBookingForm({
|
||||
// (mirrors the ScheduleStep picker's visibility).
|
||||
requiresTrain:
|
||||
contract.tradeDirection === "EXPORT" && Boolean(completeBookingId),
|
||||
// Without-customs import/export completion collects the customer's own
|
||||
// clearing agent per booking (this page never renders for a customs
|
||||
// contract — see the gate above). Intercity has no border to clear.
|
||||
requiresClearingAgent:
|
||||
Boolean(completeBookingId) && contract.tradeDirection !== "DOMESTIC",
|
||||
}),
|
||||
),
|
||||
mode: "onChange",
|
||||
@@ -575,7 +607,20 @@ function NewShipmentBookingForm({
|
||||
Number(values.bulkReeferQuantity || 0) || undefined,
|
||||
},
|
||||
],
|
||||
...(bulkUnitOfMeasure(contract) === "NUMBER_OF_WAGONS" &&
|
||||
values.requestedWagons
|
||||
? { requestedWagons: Number(values.requestedWagons) }
|
||||
: {}),
|
||||
}),
|
||||
// Customer's own clearing agent — collected at completion; the server
|
||||
// requires all three for a without-customs import/export booking.
|
||||
...(values.customsClearingAgent?.trim()
|
||||
? {
|
||||
customsClearingAgent: values.customsClearingAgent.trim(),
|
||||
customsClearingAgentEmail: values.customsClearingAgentEmail.trim(),
|
||||
customsClearingAgentPhone: values.customsClearingAgentPhone.trim(),
|
||||
}
|
||||
: {}),
|
||||
...(values.notes ? { notes: values.notes } : {}),
|
||||
};
|
||||
}
|
||||
@@ -710,6 +755,10 @@ function NewShipmentBookingForm({
|
||||
)}
|
||||
<RouteStep form={form} contract={contract} routes={routes} />
|
||||
<CargoStep form={form} contract={contract} />
|
||||
{Boolean(completeBookingId) &&
|
||||
contract.tradeDirection !== "DOMESTIC" && (
|
||||
<ClearingAgentStep form={form} />
|
||||
)}
|
||||
{/* Legacy contracts only — WITH_RETURN contracts capture per-line
|
||||
return quantities in the cargo step; WITHOUT_RETURN locked it off. */}
|
||||
{contract.freightType === "CONTAINER" &&
|
||||
@@ -1410,7 +1459,11 @@ function CargoStep({
|
||||
const isContainer = contract.freightType === "CONTAINER";
|
||||
// Break-bulk (PER_ITEM) cargo needs BOTH the item count (which prices it) and
|
||||
// the total tonnage (which sizes the wagons); PER_TON needs tonnage only.
|
||||
const isPerItem = bulkUnitOfMeasure(contract) === "PER_ITEM";
|
||||
// NUMBER_OF_WAGONS needs the tonnage PLUS the wagon count (an optional item
|
||||
// count may ride along as information).
|
||||
const bulkUom = bulkUnitOfMeasure(contract);
|
||||
const isPerItem = bulkUom === "PER_ITEM";
|
||||
const isByWagons = bulkUom === "NUMBER_OF_WAGONS";
|
||||
// Sizes enabled by the contract scope.
|
||||
const sizes = useMemo(
|
||||
() =>
|
||||
@@ -1766,7 +1819,9 @@ function CargoStep({
|
||||
description={
|
||||
isPerItem
|
||||
? "Combined weight of all the items — used to work out how many wagons the shipment needs."
|
||||
: undefined
|
||||
: isByWagons
|
||||
? "Spread evenly across the wagons you request below."
|
||||
: undefined
|
||||
}
|
||||
placeholder="e.g. 1200"
|
||||
min={0}
|
||||
@@ -1777,6 +1832,47 @@ function CargoStep({
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isByWagons && (
|
||||
<>
|
||||
<Controller
|
||||
name="itemCount"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Number of items (optional)"
|
||||
placeholder="e.g. 500"
|
||||
min={0}
|
||||
step={1}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="requestedWagons"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Number of wagons needed *"
|
||||
description="Your cargo is allocated exactly this many wagons; a per-wagon rate bills this count."
|
||||
placeholder="e.g. 40"
|
||||
min={1}
|
||||
step={1}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{contract.isHazardous && (
|
||||
<Controller
|
||||
name="bulkHazardousQuantity"
|
||||
@@ -1892,6 +1988,71 @@ function EquipmentReturnStep({ form }: { form: ShipmentForm }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Completion of a without-customs import/export booking: the customer names
|
||||
* their own customs clearing agent per booking — name, email and phone are
|
||||
* all required (the schema and the server both enforce it).
|
||||
*/
|
||||
function ClearingAgentStep({ form }: { form: ShipmentForm }) {
|
||||
return (
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<FileText size={22} />}
|
||||
title="Customs Clearing Agent"
|
||||
description="Your service does not include customs clearance — enter the agent handling customs for this booking."
|
||||
/>
|
||||
<Stack gap="sm">
|
||||
<Controller
|
||||
name="customsClearingAgent"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
label="Agent name *"
|
||||
placeholder="Customs clearing agent name"
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Group grow align="flex-start">
|
||||
<Controller
|
||||
name="customsClearingAgentEmail"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="email"
|
||||
label="Agent email *"
|
||||
placeholder="agent@example.com"
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="customsClearingAgentPhone"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="tel"
|
||||
label="Agent phone *"
|
||||
placeholder="+251 9…"
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function NotesSection({ form }: { form: ShipmentForm }) {
|
||||
return (
|
||||
<StepCard>
|
||||
|
||||
@@ -107,7 +107,6 @@ export function contractToFormValues(
|
||||
lng: contract.lastMileDeliveryLng ?? null,
|
||||
},
|
||||
customsClearingEnabled: contract.customsClearingEnabled,
|
||||
customsClearingAgent: contract.customsClearingAgent ?? "",
|
||||
|
||||
cargoType: isContainer ? "container" : "bulk",
|
||||
enabledContainerSizes:
|
||||
|
||||
@@ -156,7 +156,6 @@ export const contractFormSchema = z
|
||||
.enum(["with_return", "without_return"])
|
||||
.default("without_return"),
|
||||
customsClearingEnabled: z.boolean().default(false),
|
||||
customsClearingAgent: z.string().default(""),
|
||||
|
||||
// ── Cargo SCOPE (no quantities) ──
|
||||
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
||||
@@ -274,7 +273,6 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
|
||||
lastMile: { enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
|
||||
equipmentReturn: "without_return",
|
||||
customsClearingEnabled: false,
|
||||
customsClearingAgent: "",
|
||||
|
||||
cargoType: "container",
|
||||
enabledContainerSizes: [...CONTAINER_SIZES],
|
||||
@@ -307,7 +305,6 @@ export const contractStepFields: Record<
|
||||
"serviceTypeId",
|
||||
"equipmentReturn",
|
||||
"customsClearingEnabled",
|
||||
"customsClearingAgent",
|
||||
"firstMile",
|
||||
"lastMile",
|
||||
],
|
||||
|
||||
@@ -131,7 +131,6 @@ export function Step1ContractType({
|
||||
"customsClearingEnabled",
|
||||
contract.customsClearingEnabled ?? false,
|
||||
);
|
||||
form.setValue("customsClearingAgent", contract.customsClearingAgent ?? "");
|
||||
|
||||
// ── Route (single route per contract) ──
|
||||
const routes = contract.routes ?? [];
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Check,
|
||||
Container,
|
||||
FileCheck2,
|
||||
FileText,
|
||||
Info,
|
||||
// PackageCheck,
|
||||
ShieldCheck,
|
||||
@@ -306,10 +305,6 @@ export function Step2ServiceType({
|
||||
if (form.getValues("customsClearingEnabled") !== desired) {
|
||||
form.setValue("customsClearingEnabled", desired, { shouldDirty: true });
|
||||
}
|
||||
// A bundled-customs service never carries a customer-named agent.
|
||||
if (desired && form.getValues("customsClearingAgent")) {
|
||||
form.setValue("customsClearingAgent", "", { shouldDirty: true });
|
||||
}
|
||||
}, [includesCustoms, form]);
|
||||
|
||||
// A hidden mile must not leak a stale enabled=true into the payload. The
|
||||
@@ -357,13 +352,6 @@ export function Step2ServiceType({
|
||||
// shipment (at booking, or on the shipment request when GL books). Intercity
|
||||
// still bills in ETB, but that is applied at booking time, not here.
|
||||
const isIntercity = operationType === "intercity";
|
||||
useEffect(() => {
|
||||
// The customs clearing agent field is hidden for intercity — drop any value
|
||||
// carried over from a draft or an operation-type switch.
|
||||
if (isIntercity && form.getValues("customsClearingAgent")) {
|
||||
form.setValue("customsClearingAgent", "", { shouldDirty: true });
|
||||
}
|
||||
}, [isIntercity, form]);
|
||||
|
||||
return (
|
||||
<Stack gap={18}>
|
||||
@@ -566,8 +554,9 @@ export function Step2ServiceType({
|
||||
)}
|
||||
|
||||
|
||||
{/* Intercity (domestic) moves never cross a border, so no customs
|
||||
clearing agent is collected. */}
|
||||
{/* Without bundled customs the customer names their own clearing
|
||||
agent per booking, at booking completion — nothing to collect
|
||||
on the contract. Intercity never crosses a border. */}
|
||||
{isIntercity ? null : includesCustoms ? (
|
||||
<Box
|
||||
px={16}
|
||||
@@ -614,57 +603,7 @@ export function Step2ServiceType({
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
) : (
|
||||
<Controller
|
||||
name="customsClearingAgent"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Box
|
||||
px={16}
|
||||
py={14}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: "1.5px solid #E6ECF2",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<Group gap={13} align="flex-start" wrap="nowrap" mb="sm">
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
flexShrink: 0,
|
||||
borderRadius: 11,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#F1F4F7",
|
||||
color: "#64748B",
|
||||
}}
|
||||
>
|
||||
<FileText size={18} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
Customs Clearing Agent
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
|
||||
Enter the name of your customs clearing agent for this
|
||||
contract.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<TextInput
|
||||
{...field}
|
||||
placeholder="Customs clearing agent name"
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
@@ -216,15 +216,13 @@ export function Step8Review({
|
||||
|
||||
// Customs is a property of the chosen service (bundled → Global Logistics),
|
||||
// not of the stored form flag — a stale draft flag must not misreport it.
|
||||
// Without bundling, the customer may still name their own clearing agent.
|
||||
const ownAgent = values.customsClearingAgent?.trim();
|
||||
// Without bundling, the customer names their own agent per booking, at
|
||||
// booking completion — nothing is recorded on the contract.
|
||||
const customsTag: { label: string; color: string } = isIntercity
|
||||
? { label: "Not applicable · domestic", color: "gray" }
|
||||
: serviceType?.includesCustoms || values.customsClearingEnabled
|
||||
? { label: "EDR handles it · Global Logistics", color: "edr-green" }
|
||||
: ownAgent
|
||||
? { label: `Own agent · ${ownAgent}`, color: "blue" }
|
||||
: { label: "Not requested", color: "gray" };
|
||||
: { label: "Own agent · named per booking", color: "blue" };
|
||||
|
||||
// Mirror the step-2 gating: imports never truck the first mile, exports never
|
||||
// truck the last mile, and a service that doesn't bundle a mile can't have it.
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface ShipmentValidationContext {
|
||||
* per-line "with return" quantity, validated like hazardous/reefer.
|
||||
*/
|
||||
withReturnService?: boolean;
|
||||
unitOfMeasure?: "PER_TON" | "PER_ITEM";
|
||||
unitOfMeasure?: "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS";
|
||||
/**
|
||||
* Intercity (DOMESTIC) shipments ride a passing import/export train that
|
||||
* staff pick later, so no shipment day is chosen. Defaults to true.
|
||||
@@ -35,6 +35,12 @@ export interface ShipmentValidationContext {
|
||||
* customer picks for the chosen day. Defaults to false.
|
||||
*/
|
||||
requiresTrain?: boolean;
|
||||
/**
|
||||
* Completion of a without-customs import/export booking: the customer's own
|
||||
* clearing agent (name, email, phone) is required per booking. Defaults to
|
||||
* false — direct drawdown creates and intercity never collect it.
|
||||
*/
|
||||
requiresClearingAgent?: boolean;
|
||||
}
|
||||
|
||||
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
|
||||
@@ -92,8 +98,15 @@ const shipmentFormBase = z.object({
|
||||
cargoDescription: z.string().default(""),
|
||||
cargoWeightTons: z.string().default(""),
|
||||
itemCount: z.string().default(""),
|
||||
// NUMBER_OF_WAGONS cargo only: wagons this shipment needs (required then).
|
||||
requestedWagons: z.string().default(""),
|
||||
bulkHazardousQuantity: z.string().default("0"),
|
||||
bulkReeferQuantity: z.string().default("0"),
|
||||
// Customer's own customs clearing agent — collected per booking when the
|
||||
// service does not bundle customs (required at completion, see superRefine).
|
||||
customsClearingAgent: z.string().default(""),
|
||||
customsClearingAgentEmail: z.string().default(""),
|
||||
customsClearingAgentPhone: z.string().default(""),
|
||||
notes: z.string().default(""),
|
||||
});
|
||||
|
||||
@@ -121,6 +134,30 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.requiresClearingAgent) {
|
||||
if (!data.customsClearingAgent.trim()) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["customsClearingAgent"],
|
||||
message: "Enter your customs clearing agent's name.",
|
||||
});
|
||||
}
|
||||
if (!z.email().safeParse(data.customsClearingAgentEmail.trim()).success) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["customsClearingAgentEmail"],
|
||||
message: "Enter a valid email for your clearing agent.",
|
||||
});
|
||||
}
|
||||
if (!data.customsClearingAgentPhone.trim()) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["customsClearingAgentPhone"],
|
||||
message: "Enter your clearing agent's phone number.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// No default currency — the customer must pick one before submitting.
|
||||
if (!data.paymentCurrency) {
|
||||
refineCtx.addIssue({
|
||||
@@ -264,6 +301,20 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// NUMBER_OF_WAGONS cargo: the wagon count is the customer's order — the
|
||||
// weight spreads evenly across it (the server also checks each wagon's
|
||||
// share against wagon capacity).
|
||||
if (ctx.unitOfMeasure === "NUMBER_OF_WAGONS") {
|
||||
const wagons = Number(data.requestedWagons || 0);
|
||||
if (!Number.isInteger(wagons) || wagons < 1) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["requestedWagons"],
|
||||
message: "Enter the number of wagons needed (at least 1).",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const boundBulkPortion = (
|
||||
on: boolean,
|
||||
raw: string,
|
||||
@@ -321,8 +372,12 @@ export const initialShipmentFormValues: DeepPartial<ShipmentFormValues> = {
|
||||
cargoDescription: "",
|
||||
cargoWeightTons: "",
|
||||
itemCount: "",
|
||||
requestedWagons: "",
|
||||
bulkHazardousQuantity: "0",
|
||||
bulkReeferQuantity: "0",
|
||||
customsClearingAgent: "",
|
||||
customsClearingAgentEmail: "",
|
||||
customsClearingAgentPhone: "",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
@@ -336,6 +391,7 @@ export const shipmentStepFields: Record<
|
||||
"cargoDescription",
|
||||
"cargoWeightTons",
|
||||
"itemCount",
|
||||
"requestedWagons",
|
||||
"bulkHazardousQuantity",
|
||||
"bulkReeferQuantity",
|
||||
"withReturn",
|
||||
|
||||
@@ -35,6 +35,10 @@ export function formatAmount(amount: number | string | null | undefined) {
|
||||
* PER_TON cargo has no item count and falls back the other way for legacy rows.
|
||||
*/
|
||||
function bulkQtyForUnit(values: ShipmentFormValues, unit: string): number {
|
||||
// NUMBER_OF_WAGONS cargo: a per_wagon rate bills the wagon count the
|
||||
// customer requested (0 when the cargo is not wagon-requested — the caller
|
||||
// then skips the line, matching the "shown at real pricing" fallback).
|
||||
if (unit === "per_wagon") return Number(values.requestedWagons || 0);
|
||||
return unit === "per_item"
|
||||
? Number(values.itemCount || 0)
|
||||
: Number(values.cargoWeightTons || values.itemCount || 0);
|
||||
@@ -190,7 +194,12 @@ export function computeShipmentTotal(
|
||||
// amount; per-wagon depends on the wagon capacity the train stocks — shown at
|
||||
// real pricing.
|
||||
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
|
||||
if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) {
|
||||
if (
|
||||
lashing &&
|
||||
(lashing.unit === "per_ton" ||
|
||||
lashing.unit === "per_item" ||
|
||||
lashing.unit === "per_wagon")
|
||||
) {
|
||||
const qty = bulkQtyForUnit(values, lashing.unit);
|
||||
if (qty > 0) {
|
||||
lines.push({
|
||||
@@ -217,7 +226,11 @@ export function computeShipmentTotal(
|
||||
cl.unit === "per_wagon"
|
||||
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
|
||||
: boxes;
|
||||
} else if (cl.unit === "per_ton" || cl.unit === "per_item") {
|
||||
} else if (
|
||||
cl.unit === "per_ton" ||
|
||||
cl.unit === "per_item" ||
|
||||
cl.unit === "per_wagon"
|
||||
) {
|
||||
qty = bulkQtyForUnit(values, cl.unit);
|
||||
} else if (cl.unit === "flat") {
|
||||
qty = 1;
|
||||
|
||||
Reference in New Issue
Block a user