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

add lashing surcharge for cargo types with hasLashing flag
This commit is contained in:
marshal
2026-07-18 02:27:50 +03:00
committed by GitHub
59 changed files with 1919 additions and 140 deletions

View File

@@ -87,7 +87,8 @@ function phaseCountdown(
}
}
/** Cargo weight already allocated to this train (sum of on-train bookings). */
/** GROSS weight already on this train (each booking's cargo + wagon tare) —
* compared against the locomotive pull limit, which is a gross ceiling. */
function usedWeight(schedule: TrainScheduleDetail): number {
return (schedule.bookings ?? []).reduce(
(sum, b) => sum + (Number(b.weightTons) || 0),

View File

@@ -52,6 +52,8 @@ interface CargoNode extends RuleEngineRecord {
code?: string;
parentGroupId?: string | null;
requiresDirectorApproval?: boolean;
/** When true, bookings of this cargo type incur the flat LASHING surcharge. */
hasLashing?: boolean;
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
unitOfMeasure?: string | null;
/** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */
@@ -97,6 +99,9 @@ const FORM_FIELDS: FormFieldDef[] = [
((record.wagonTypes as { id: string }[] | undefined) ?? []).map((wt) => wt.id),
},
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
// When on, every booking of this cargo type is charged the flat LASHING
// surcharge (a rate with trigger = Lashing).
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
];

View File

@@ -146,6 +146,7 @@ const RATE_TRIGGERS = [
{ label: "Empty container return", value: "WITH_RETURN" },
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
{ label: "Consolidation", value: "CONSOLIDATION" },
{ label: "Lashing (flat, per booking)", value: "LASHING" },
{ label: "Cancellation", value: "CANCELLATION" },
{ label: "Demurrage", value: "DEMURRAGE" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
@@ -192,6 +193,9 @@ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
case "CUSTOMS_CLEARANCE":
// Flat per clearance (ONE_TIME) / per shipment request (GENERAL).
return ["FLAT"];
case "LASHING":
// Flat cargo-securing fee, billed once per booking.
return ["FLAT"];
case "CONSOLIDATION":
case "SHIPPING_LINE":
case "PIL_EXTRA_FEE":
@@ -278,6 +282,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "Select parent cargo type (optional)",
},
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
],
},

View File

@@ -584,9 +584,16 @@ export default function TrainScheduleV2DetailPage() {
}
if (key === "wagon" && displayWagonPlan.length) {
return (
<Badge variant="light" color="edr-green" radius="sm">
{displayWagonPlan.length} wagons
</Badge>
<Group gap="xs">
{schedule.reverseWagonOrder ? (
<Badge variant="light" color="orange" radius="sm">
Reversed order
</Badge>
) : null}
<Badge variant="light" color="edr-green" radius="sm">
{displayWagonPlan.length} wagons
</Badge>
</Group>
);
}
if (key === "container" && containerUnits.length) {
@@ -1056,9 +1063,19 @@ export default function TrainScheduleV2DetailPage() {
},
{
label: "Wagons / load",
// Gross: cargo load + the tare of every wagon in the consist — the
// weight the locomotive actually hauls.
value: `${schedule.trainSet?.wagonCount ?? displayWagonPlan.length} · ${
schedule.trainSet?.totalWeightTons ?? 0
Math.round(
((schedule.trainSet?.totalWeightTons ?? 0) +
(schedule.trainSet?.wagons ?? []).reduce(
(sum, w) => sum + (Number(w.tareWeightTons) || 0),
0,
)) *
100,
) / 100
}T`,
hint: "gross · wagon tare + cargo",
icon: Weight,
},
{

View File

@@ -5,6 +5,7 @@ import {
Box,
Button,
Card,
Checkbox,
Group,
Menu,
Modal,
@@ -121,6 +122,7 @@ export default function TrainScheduleV2ListPage() {
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const [trainId, setTrainId] = useState("");
const [reverseWagonOrder, setReverseWagonOrder] = useState(false);
// Recomputed each time the create modal opens so a long-lived tab can't keep
// offering a stale "now" as the earliest selectable departure.
const minScheduleDate = useMemo(
@@ -513,10 +515,12 @@ export default function TrainScheduleV2ListPage() {
routeId,
scheduleDate: new Date(scheduleDate).toISOString(),
trainId,
reverseWagonOrder,
},
});
toast({ title: "Train schedule created" });
showScheduleWarnings(created.warnings);
setReverseWagonOrder(false);
setCreateOpen(false);
navigate(`/dashboard/operations/train-scheduling-v2/${created.id}`);
} catch (err) {
@@ -805,6 +809,12 @@ export default function TrainScheduleV2ListPage() {
: "Select a route first"
}
/>
<Checkbox
label="Reverse wagon order"
description="Place wagons on the train in reverse — the physically-last wagon becomes position 1. Composition and allocations are unchanged; only the order flips. Applies every time this schedule's wagon plan is built."
checked={reverseWagonOrder}
onChange={(e) => setReverseWagonOrder(e.currentTarget.checked)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setCreateOpen(false)}>
Cancel

View File

@@ -7,6 +7,25 @@ import { trainSchedulingService } from "@/services/trainScheduling.service";
import { useToast } from "@/hooks/use-toast";
import type { TrainSchedulingGlobalRules } from "@/types/trainScheduling";
/**
* Coerce an API rules object into editable form state: `numeric` columns arrive
* as strings ("250.00") and nullable offsets arrive as null — map both to a real
* number, or "" for blank/null so a NumberInput edits cleanly and a cleared
* offset stays blank.
*/
function toFormState(
rules: TrainSchedulingGlobalRules,
): Partial<Record<keyof TrainSchedulingGlobalRules, number | string>> {
const numeric: Partial<Record<keyof TrainSchedulingGlobalRules, number | string>> = {};
for (const [key, value] of Object.entries(rules)) {
if (key === "id") continue;
const num = value === "" || value == null ? "" : Number(value);
numeric[key as keyof TrainSchedulingGlobalRules] =
typeof num === "number" && Number.isNaN(num) ? "" : num;
}
return numeric;
}
export default function TrainSchedulingGlobalRulesPage() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
@@ -20,18 +39,7 @@ export default function TrainSchedulingGlobalRulesPage() {
void (async () => {
try {
const rules = await trainSchedulingService.getGlobalRules();
// `numeric` columns come back from the API as strings (e.g. "250.00").
// Coerce every field to a real number so Mantine's controlled
// NumberInput edits cleanly (a string value fights the caret) and the
// default can be cleared and replaced.
const numeric: Partial<Record<keyof TrainSchedulingGlobalRules, number | string>> = {};
for (const [key, value] of Object.entries(rules)) {
if (key === "id") continue;
const num = value === "" || value == null ? "" : Number(value);
numeric[key as keyof TrainSchedulingGlobalRules] =
typeof num === "number" && Number.isNaN(num) ? "" : num;
}
setForm(numeric);
setForm(toFormState(rules));
} catch {
toast({ title: "Failed to load train scheduling rules", variant: "destructive" });
} finally {
@@ -73,10 +81,23 @@ export default function TrainSchedulingGlobalRulesPage() {
payload[key] = num;
}
// Close offsets are optional: a blank box means "no offset" (window closes at
// departure) and is sent as 0, which the API stores as null. A filled box is
// sent as its minute value.
const offsetFields: (keyof TrainSchedulingGlobalRules)[] = [
"importCloseOffsetMinutes",
"exportCloseOffsetMinutes",
];
for (const key of offsetFields) {
const raw = form[key];
const num = raw === "" || raw == null ? 0 : Number(raw);
payload[key] = Number.isFinite(num) ? num : 0;
}
setSaving(true);
try {
const updated = await trainSchedulingService.updateGlobalRules(payload);
setForm(updated);
setForm(toFormState(updated));
toast({ title: "Train scheduling rules saved" });
} catch {
toast({ title: "Failed to save rules", variant: "destructive" });
@@ -198,6 +219,37 @@ export default function TrainSchedulingGlobalRulesPage() {
min={1}
disabled={loading}
/>
</Stack>
</Card>
<Card maw={720} mt="md">
<Stack gap="md">
<PageHeader
title="Booking close offset"
subtitle="How long before departure a schedule stops accepting bookings. e.g. a 3-hour import offset closes a 17:00 departure's window at 14:00; a 1-day export offset closes a Jul-10 16:00 departure at Jul-9 16:00. Leave blank to close exactly at departure. Set separately for import and export."
/>
<DurationField
label="Import close offset"
description="Import/domestic booking windows close this long before departure (blank = at departure)"
value={form.importCloseOffsetMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
setForm((current) => ({ ...current, importCloseOffsetMinutes: value }))
}
min={0}
disabled={loading}
/>
<DurationField
label="Export close offset"
description="Export booking windows close this long before departure (blank = at departure)"
value={form.exportCloseOffsetMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
setForm((current) => ({ ...current, exportCloseOffsetMinutes: value }))
}
min={0}
disabled={loading}
/>
<Group justify="flex-end">
<Button loading={saving} disabled={loading} onClick={() => void handleSave()}>
Save rules

View File

@@ -120,6 +120,10 @@ export interface TrainSchedulingGlobalRules {
windowDurationHours: number;
docReviewMinutes: number;
paymentWindowMinutes: number;
/** Minutes before departure the import window closes; null = close at departure. */
importCloseOffsetMinutes: number | null;
/** Minutes before departure the export window closes; null = close at departure. */
exportCloseOffsetMinutes: number | null;
}
export interface TrainSchedulePreviewResponse {
@@ -534,6 +538,8 @@ export interface TrainScheduleDetail {
trainName?: string | null;
} | null;
direction?: string | null;
/** Wagon order reversed on this train (physically-last wagon = position 1). */
reverseWagonOrder?: boolean;
/** True when this schedule needs loading confirmed before dispatch (import-Djibouti). */
requiresLoadingConfirmation?: boolean;
/** True when loading is already confirmed (or not required for this direction). */
@@ -811,6 +817,8 @@ export interface CreateTrainSchedulePayload {
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
/** Reverse the wagon order on this train: physically-last wagon becomes position 1. */
reverseWagonOrder?: boolean;
}
export interface AssignBookingsPayload {