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

@@ -130,6 +130,7 @@ interface LineErrors {
interface BulkErrors {
quantity?: string;
wagons?: string;
hazardous?: string;
reefer?: string;
}
@@ -175,6 +176,8 @@ interface ContainerLineDraft {
interface BulkDraft {
cargoWeightTons: string;
itemCount: string;
/** NUMBER_OF_WAGONS cargo only: wagons this shipment needs. */
requestedWagons: string;
hazardousQuantity: string;
reeferQuantity: string;
}
@@ -203,7 +206,19 @@ function emptyLine(size: string): ContainerLineDraft {
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",
);
@@ -322,6 +337,7 @@ export default function GlCreateBookingForm() {
const [bulk, setBulk] = useState<BulkDraft>({
cargoWeightTons: "",
itemCount: "",
requestedWagons: "",
hazardousQuantity: "0",
reeferQuantity: "0",
});
@@ -521,16 +537,18 @@ export default function GlCreateBookingForm() {
})),
);
} else if (lines.bulk) {
setBulk({
setBulk((b) => ({
cargoWeightTons:
lines.bulk.cargoWeightTons != null
? String(lines.bulk.cargoWeightTons)
lines.bulk!.cargoWeightTons != null
? String(lines.bulk!.cargoWeightTons)
: "",
itemCount:
lines.bulk.itemCount != null ? String(lines.bulk.itemCount) : "",
hazardousQuantity: String(lines.bulk.hazardousQuantity ?? 0),
lines.bulk!.itemCount != null ? String(lines.bulk!.itemCount) : "",
// The request never carries a wagon count — GL enters it here.
requestedWagons: b.requestedWagons,
hazardousQuantity: String(lines.bulk!.hazardousQuantity ?? 0),
reeferQuantity: "0",
});
}));
}
if (bookingRequest.contractRouteId)
setContractRouteId(bookingRequest.contractRouteId);
@@ -618,6 +636,7 @@ export default function GlCreateBookingForm() {
returnQuantity: Number(l.returnQuantity || 0),
})),
bulkQuantity: Number(bulk.cargoWeightTons || bulk.itemCount || 0),
bulkRequestedWagons: Number(bulk.requestedWagons || 0),
bulkHazardousQuantity: Number(bulk.hazardousQuantity || 0),
bulkReeferQuantity: Number(bulk.reeferQuantity || 0),
}),
@@ -979,6 +998,12 @@ export default function GlCreateBookingForm() {
if (Number.isNaN(qty) || qty <= 0) {
errs.quantity = "Enter a quantity greater than 0.";
}
if (bulkUom === "NUMBER_OF_WAGONS") {
const wagons = Number(bulk.requestedWagons || 0);
if (!Number.isInteger(wagons) || wagons < 1) {
errs.wagons = "Enter the number of wagons needed (at least 1).";
}
}
const h = Number(bulk.hazardousQuantity || 0);
if (Number.isNaN(h) || h < 0) {
errs.hazardous = "Enter a valid hazardous quantity.";
@@ -1017,7 +1042,10 @@ export default function GlCreateBookingForm() {
line.every((e) => !e.containerNumber && !e.vgmTons),
) &&
!cargoDescriptionError
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
: !bulkErrors.quantity &&
!bulkErrors.wagons &&
!bulkErrors.hazardous &&
!bulkErrors.reefer;
// COMPLETION never blocks on an odd 20ft total: a customs instance can share
// the wagon via the manual pair (consolidationActive), and anything else is
@@ -1152,6 +1180,9 @@ export default function GlCreateBookingForm() {
reeferQuantity: Number(bulk.reeferQuantity || 0) || undefined,
},
];
if (bulkUom === "NUMBER_OF_WAGONS" && bulk.requestedWagons !== "") {
payload.requestedWagons = Number(bulk.requestedWagons);
}
}
return payload;
@@ -2049,6 +2080,27 @@ export default function GlCreateBookingForm() {
radius={10}
styles={fieldStyles}
/>
{bulkUom === "NUMBER_OF_WAGONS" && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="Number of wagons needed"
placeholder="e.g. 40"
description="The cargo weight is spread evenly across these wagons; a per-wagon rate bills this count."
min={1}
step={1}
value={bulk.requestedWagons}
error={showErrors ? bulkErrors.wagons : undefined}
onChange={(e) =>
setBulk((b) => ({
...b,
requestedWagons: e.currentTarget.value,
}))
}
radius={10}
styles={fieldStyles}
/>
)}
{contract.isHazardous && (
<TextInput
type="number"

View File

@@ -28,6 +28,8 @@ export interface GlShipmentQuantities {
}>;
/** Bulk: tons (or item count) + hazardous/reefer qty. */
bulkQuantity: number;
/** NUMBER_OF_WAGONS cargo: the wagon count GL enters (0 otherwise). */
bulkRequestedWagons: number;
bulkHazardousQuantity: number;
bulkReeferQuantity: number;
}
@@ -132,7 +134,6 @@ export function computeGlShipmentTotal(
}
}
} else {
const qty = q.bulkQuantity;
const rate =
rateFor(
(i) =>
@@ -140,6 +141,9 @@ export function computeGlShipmentTotal(
!i.isClearance &&
!i.conditionalOn,
) ?? items[0];
// NUMBER_OF_WAGONS cargo: a per-wagon base rate bills the requested count.
const qty =
rate?.unit === "per_wagon" ? q.bulkRequestedWagons : q.bulkQuantity;
if (rate && qty > 0) {
lines.push({
label: rate.label,
@@ -179,8 +183,14 @@ export function computeGlShipmentTotal(
// it (the commodity needs lashing). Per-ton scales by tonnage; 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")) {
const tons = q.bulkQuantity;
if (
lashing &&
(lashing.unit === "per_ton" ||
lashing.unit === "per_item" ||
(lashing.unit === "per_wagon" && q.bulkRequestedWagons > 0))
) {
const tons =
lashing.unit === "per_wagon" ? q.bulkRequestedWagons : q.bulkQuantity;
if (tons > 0) {
lines.push({
label: lashing.label,
@@ -208,6 +218,8 @@ export function computeGlShipmentTotal(
: boxes;
} else if (cl.unit === "per_ton" || cl.unit === "per_item") {
qty = q.bulkQuantity;
} else if (cl.unit === "per_wagon") {
qty = q.bulkRequestedWagons;
} else if (cl.unit === "flat") {
qty = 1;
}