mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
Merge pull request #782 from Tria-plc/freight_feature/usermanagement
add lashing surcharge for cargo types with hasLashing flag
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user