Merge pull request #719 from Tria-plc/freight_feature/usermanagement

implement empty-container return service: add return quantity handlin…
This commit is contained in:
marshal
2026-07-16 02:06:08 +03:00
committed by GitHub
20 changed files with 324 additions and 20 deletions

View File

@@ -97,6 +97,7 @@ interface LineErrors {
quantity?: string;
hazardousQuantity?: string;
reeferQuantity?: string;
returnQuantity?: string;
units?: string;
}
@@ -135,6 +136,8 @@ interface ContainerLineDraft {
quantity: string;
hazardousQuantity: string;
reeferQuantity: string;
/** Units of this line shipping with empty-container return (contract WITH_RETURN only). */
returnQuantity: string;
units: UnitDraft[];
}
@@ -155,6 +158,7 @@ function emptyLine(size: string): ContainerLineDraft {
quantity: "1",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [emptyUnit()],
};
}
@@ -272,6 +276,14 @@ export default function GlCreateBookingForm() {
}, [contract]);
const isContainer = contract?.freightType === "CONTAINER";
// The contract gates the empty-container return service — like hazardous.
// WITH_RETURN contracts capture a per-line return quantity instead of the
// legacy booking-level toggle; other contracts cannot switch it on.
const contractWithReturn =
isContainer && contract?.equipmentReturn === "WITH_RETURN";
// Legacy contracts (no equipment return chosen at creation) keep the old
// booking-level toggle.
const legacyReturnToggle = isContainer && !contract?.equipmentReturn;
// Intercity shipments ride a passing import/export train staff pick at
// finalize time — no shipment day is chosen and no window gate applies.
const isIntercity = contract?.tradeDirection === "DOMESTIC";
@@ -354,6 +366,7 @@ export default function GlCreateBookingForm() {
quantity: String(Math.max(1, c.quantity)),
hazardousQuantity: String(c.hazardousQuantity ?? 0),
reeferQuantity: String(c.reeferQuantity ?? 0),
returnQuantity: "0",
units: Array.from({ length: Math.max(1, c.quantity) }, emptyUnit),
})),
);
@@ -390,6 +403,7 @@ export default function GlCreateBookingForm() {
quantity: String(qty),
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: Array.from({ length: qty }, emptyUnit),
};
}),
@@ -542,6 +556,8 @@ export default function GlCreateBookingForm() {
quantity: String(imported.length),
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length),
returnQuantity:
prev.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
@@ -614,9 +630,17 @@ export default function GlCreateBookingForm() {
errs.reeferQuantity = `Can't exceed the ${qty} container(s) in this line.`;
}
}
if (contractWithReturn) {
const w = Number(line.returnQuantity || 0);
if (Number.isNaN(w) || w < 0) {
errs.returnQuantity = "Enter a valid return quantity.";
} else if (w > qty) {
errs.returnQuantity = `Can't exceed the ${qty} container(s) in this line.`;
}
}
return errs;
});
}, [isContainer, contract, containerLines]);
}, [isContainer, contract, containerLines, contractWithReturn]);
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
@@ -653,7 +677,11 @@ export default function GlCreateBookingForm() {
const cargoValid = isContainer
? lineErrors.every(
(e) =>
!e.quantity && !e.units && !e.hazardousQuantity && !e.reeferQuantity,
!e.quantity &&
!e.units &&
!e.hazardousQuantity &&
!e.reeferQuantity &&
!e.returnQuantity,
) &&
unitErrors.every((line) =>
line.every((e) => !e.containerNumber && !e.vgmTons),
@@ -675,8 +703,10 @@ export default function GlCreateBookingForm() {
? { scheduledDate: new Date(scheduledDate).toISOString() }
: {}),
...(notes.trim() ? { notes: notes.trim() } : {}),
// Equipment return is a container concern — bulk keeps the contract default.
...(isContainer
// Equipment return: WITH_RETURN contracts derive it server-side from the
// per-line return quantities; only legacy contracts (no value chosen at
// creation) still send the booking-level toggle. Bulk keeps the default.
...(legacyReturnToggle
? { equipmentReturn: withReturn ? "WITH_RETURN" : "WITHOUT_RETURN" }
: {}),
};
@@ -689,6 +719,9 @@ export default function GlCreateBookingForm() {
quantity: Number(l.quantity),
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
...(contractWithReturn
? { returnQuantity: Number(l.returnQuantity || 0) }
: {}),
units: l.units.map((u) => ({
containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
@@ -1168,6 +1201,28 @@ export default function GlCreateBookingForm() {
styles={fieldStyles}
/>
)}
{contractWithReturn && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="With return qty"
description="Containers EDR returns empty"
min={0}
value={line.returnQuantity}
error={
showErrors
? lineErrors[lineIdx]?.returnQuantity
: undefined
}
onChange={(e) =>
patchLine(lineIdx, {
returnQuantity: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
)}
</Group>
<StepLabel>Per-container details</StepLabel>
@@ -1323,7 +1378,9 @@ export default function GlCreateBookingForm() {
</StepCard>
)}
{isContainer ? (
{/* Legacy contracts only — WITH_RETURN contracts capture per-line
return quantities above, WITHOUT_RETURN contracts locked it off. */}
{legacyReturnToggle ? (
<StepCard>
<StepHeader
icon={<Repeat size={22} />}

View File

@@ -136,6 +136,7 @@ const RATE_TRIGGERS = [
{ label: "Hazardous cargo", value: "HAZARDOUS" },
{ label: "Overweight (per excess ton)", value: "OVERWEIGHT" },
{ label: "Reefer cargo", value: "REEFER" },
{ label: "Empty container return", value: "WITH_RETURN" },
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
{ label: "Consolidation", value: "CONSOLIDATION" },
{ label: "Cancellation", value: "CANCELLATION" },
@@ -161,6 +162,9 @@ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
case "HAZARDOUS":
case "DEMURRAGE":
return ["PER_CONTAINER", "PER_TON"];
case "WITH_RETURN":
// Container-only service — bills per returned container.
return ["PER_CONTAINER", "FLAT"];
case "CANCELLATION":
return ["FLAT", "PER_INVOICE"];
case "CUSTOMS_CLEARANCE":

View File

@@ -592,8 +592,17 @@ export default function NewContractPage({
: Freight.ContractFreightType.Bulk,
serviceTypeId: data.serviceTypeId,
paymentCurrency: data.paymentCurrency,
// Equipment return is decided at booking time, not on the contract. Omit
// it here so we don't send a value the contract API rejects.
// Empty-container return is a contract-level opt-in (container freight
// only) — like hazardous. Per-booking return quantities are still set at
// booking time, but only on contracts created WITH_RETURN.
...(isContainer
? {
equipmentReturn:
data.equipmentReturn === "with_return"
? "WITH_RETURN"
: "WITHOUT_RETURN",
}
: {}),
isHazardous: data.isHazardous,
// Reefer is a contract-level flag for both container and bulk.
isReefer: data.isRefrigerated,

View File

@@ -258,6 +258,9 @@ function NewShipmentBookingForm({
isContainer: contract.freightType === "CONTAINER",
isHazardous: contract.isHazardous ?? false,
isReefer: contract.isReefer ?? false,
withReturnService:
contract.freightType === "CONTAINER" &&
contract.equipmentReturn === "WITH_RETURN",
unitOfMeasure: bulkUnitOfMeasure(contract),
// Intercity rides a passing train staff pick later — no date to choose.
requiresDate: contract.tradeDirection !== "DOMESTIC",
@@ -296,6 +299,12 @@ function NewShipmentBookingForm({
values: ShipmentFormValues,
): Freight.CreateBookingUnderContractDto {
const isContainer = contract.freightType === "CONTAINER";
// WITH_RETURN contracts carry a per-line return quantity and the server
// derives the booking's equipment return from it; only legacy contracts
// (no equipment return chosen at creation) still send the toggle.
const withReturnService =
isContainer && contract.equipmentReturn === "WITH_RETURN";
const legacyReturnToggle = isContainer && !contract.equipmentReturn;
return {
...(values.contractRouteId
? { contractRouteId: values.contractRouteId }
@@ -304,10 +313,16 @@ function NewShipmentBookingForm({
...(values.scheduledDate
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
: {}),
...(legacyReturnToggle
? {
equipmentReturn: values.withReturn
? "WITH_RETURN"
: "WITHOUT_RETURN",
}
: {}),
// Equipment return is a container concern — bulk keeps the contract default.
...(isContainer
? {
equipmentReturn: values.withReturn ? "WITH_RETURN" : "WITHOUT_RETURN",
containers: values.containers
.filter((l) => Number(l.quantity) >= 1)
.map((l) => ({
@@ -315,6 +330,9 @@ function NewShipmentBookingForm({
quantity: Number(l.quantity),
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
...(withReturnService
? { returnQuantity: Number(l.returnQuantity || 0) }
: {}),
units: l.units.map((u) => ({
containerNumber: u.containerNumber,
sealNumber: u.sealNumber || undefined,
@@ -443,9 +461,10 @@ function NewShipmentBookingForm({
<Stack gap="lg" className="mx-auto max-w-4xl">
<RouteStep form={form} contract={contract} routes={routes} />
<CargoStep form={form} contract={contract} />
{contract.freightType === "CONTAINER" && (
<EquipmentReturnStep 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" &&
!contract.equipmentReturn && <EquipmentReturnStep form={form} />}
<ScheduleStep form={form} contract={contract} routes={routes} />
<NotesSection form={form} />
</Stack>
@@ -1075,6 +1094,7 @@ function CargoStep({
quantity: "1",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
})),
{ shouldValidate: false },
@@ -1117,6 +1137,7 @@ function CargoStep({
quantity: "1",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
}
);
@@ -1126,6 +1147,8 @@ function CargoStep({
quantity: String(imported.length),
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length),
returnQuantity:
current.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
@@ -1234,6 +1257,7 @@ function CargoStep({
size={line.containerSize}
isHazardous={contract.isHazardous}
isReefer={contract.isReefer}
withReturnService={contract.equipmentReturn === "WITH_RETURN"}
/>
))}
{sizes.length === 0 && (
@@ -1433,12 +1457,15 @@ function ContainerLineEditor({
size,
isHazardous,
isReefer,
withReturnService,
}: {
form: ShipmentForm;
index: number;
size: "20ft" | "40ft";
isHazardous: boolean;
isReefer: boolean;
/** Contract opted into empty-container return — capture the per-line quantity. */
withReturnService?: boolean;
}) {
const line = form.watch(`containers.${index}`);
const quantity = Number(line?.quantity || 0);
@@ -1519,6 +1546,25 @@ function ContainerLineEditor({
)}
/>
)}
{withReturnService && (
<Controller
name={`containers.${index}.returnQuantity`}
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="With return qty"
description="Containers EDR returns empty"
min={0}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
</Group>
<StepLabel>Per-container details</StepLabel>

View File

@@ -162,7 +162,7 @@ export const contractFormSchema = z
}),
equipmentReturn: z
.enum(["with_return", "without_return"])
.default("with_return"),
.default("without_return"),
customsClearingEnabled: z.boolean().default(false),
customsClearingAgent: z.string().default(""),
@@ -275,7 +275,7 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
paymentCurrency: undefined,
firstMile: { enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null },
lastMile: { enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
equipmentReturn: "with_return",
equipmentReturn: "without_return",
customsClearingEnabled: false,
customsClearingAgent: "",

View File

@@ -126,11 +126,12 @@ export function Step1ContractType({
});
// ── Equipment return / customs ──
// Stored uppercase on the contract (WITH_RETURN / WITHOUT_RETURN).
form.setValue(
"equipmentReturn",
contract.equipmentReturn === "without_return"
? "without_return"
: "with_return",
(contract.equipmentReturn ?? "").toUpperCase() === "WITH_RETURN"
? "with_return"
: "without_return",
);
form.setValue(
"customsClearingEnabled",

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { Flame, Snowflake } from "lucide-react";
import { Flame, RotateCcw, Snowflake } from "lucide-react";
import {
Box,
Group,
@@ -242,6 +242,28 @@ export function Step3CargoScope({
/>
)}
/>
{/* Empty-container return is a container-only service. Like hazardous,
enabling it here adds the return surcharge as a unit rate; at
booking time the customer/GL sets how many containers return. */}
{cargoType === "container" && (
<Controller
name="equipmentReturn"
control={form.control}
render={({ field }) => (
<ToggleRow
icon={<RotateCcw size={18} />}
iconBg="#EAF6EC"
iconColor="#1E7B34"
title="Empty Container Return"
description="EDR returns the empty containers — applies a per-container return surcharge."
checked={field.value === "with_return"}
onChange={(v) =>
field.onChange(v ? "with_return" : "without_return")
}
/>
)}
/>
)}
</Stack>
</Box>
</Stack>

View File

@@ -18,6 +18,12 @@ export interface ShipmentValidationContext {
isContainer: boolean;
isHazardous: boolean;
isReefer: boolean;
/**
* Contract was created with the empty-container return service
* (equipment_return = WITH_RETURN, container freight only). Enables the
* per-line "with return" quantity, validated like hazardous/reefer.
*/
withReturnService?: boolean;
unitOfMeasure?: "PER_TON" | "PER_ITEM";
/**
* Intercity (DOMESTIC) shipments ride a passing import/export train that
@@ -52,6 +58,7 @@ const containerLineSchema = z.object({
.refine((v) => !Number.isNaN(Number(v)) && Number(v) >= 1, "At least 1."),
hazardousQuantity: z.string().default("0"),
reeferQuantity: z.string().default("0"),
returnQuantity: z.string().default("0"),
units: z.array(containerUnitSchema).default([]),
});
@@ -143,6 +150,22 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
});
}
}
if (ctx.withReturnService) {
const w = Number(line.returnQuantity || 0);
if (w < 0) {
refineCtx.addIssue({
code: "custom",
path: ["containers", i, "returnQuantity"],
message: "Enter a valid return quantity.",
});
} else if (w > qty) {
refineCtx.addIssue({
code: "custom",
path: ["containers", i, "returnQuantity"],
message: `Can't exceed the ${qty} container(s) in this line.`,
});
}
}
});
} else {
const isPerItem = ctx.unitOfMeasure === "PER_ITEM";