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:
Marshal
2026-08-25 21:44:21 +00:00
parent d5a5085d6d
commit b926a3116e
67 changed files with 2998 additions and 255 deletions

View File

@@ -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,
};

View File

@@ -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>

View File

@@ -107,7 +107,6 @@ export function contractToFormValues(
lng: contract.lastMileDeliveryLng ?? null,
},
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? "",
cargoType: isContainer ? "container" : "bulk",
enabledContainerSizes:

View File

@@ -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",
],

View File

@@ -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 ?? [];

View File

@@ -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>
)}

View File

@@ -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.

View File

@@ -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",

View File

@@ -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;