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 {

View File

@@ -25,6 +25,7 @@ import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDoc
import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
import { useFileViewer } from "@/hooks/useFileViewer";
import { OperationDatePicker } from "./OperationDatePicker";
import { DayAvailabilityHint } from "./DayAvailabilityHint";
import type { ClearanceFlowController } from "./useClearanceFlow";
const BORDER = "#E6ECF2";
@@ -233,6 +234,13 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
value={scheduledDate}
onChange={setScheduledDate}
/>
{scheduledDate && (
<DayAvailabilityHint
bookingId={booking.id}
date={scheduledDate}
tradeDirection={booking.tradeDirection}
/>
)}
</Box>
)}

View File

@@ -0,0 +1,93 @@
import { Alert, Loader, Text } from "@mantine/core";
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
interface DayAvailabilityHintProps {
bookingId: string;
/** yyyy-MM-dd (or ISO) — the day the customer has picked. */
date: string;
/** EXPORT never splits: a shortfall means the whole booking must move. */
tradeDirection: "IMPORT" | "EXPORT";
}
/**
* Advisory free-wagon count for the selected shipment day. It never blocks —
* the real authorities are the batch engine (import) and the request-operation
* gate (export). This is a planning nudge so the customer knows, before they
* proceed, whether the day has room and whether a split/rejection is likely.
*
* - EXPORT: a shortfall is a hard problem (no split) — proceeding will be
* rejected — so we warn in red and quote the largest single-train leftover.
* - IMPORT/DOMESTIC: a shortfall just means the batch may split the booking or
* defer a remainder to a later window — an amber heads-up, not a blocker.
*/
export function DayAvailabilityHint({
bookingId,
date,
tradeDirection,
}: DayAvailabilityHintProps) {
const { data, isLoading, isError } = useQuery({
...api.bookings.getDayAvailability.queryOptions({ bookingId, date }),
enabled: Boolean(bookingId && date),
});
if (!date) return null;
if (isLoading) {
return (
<Text fz="12px" c="dimmed" mt="xs">
<Loader size={11} mr={6} style={{ verticalAlign: "middle" }} />
Checking wagon availability for this day
</Text>
);
}
// The advisory is best-effort; if it fails the customer can still proceed and
// the server-side gate stays authoritative, so we simply show nothing.
if (isError || !data) return null;
if (!data.trainsForDay) {
return (
<Alert color="gray" radius="md" icon={<Info size={15} />} mt="xs" p="xs">
<Text fz="12px">
No departure on this day carries your route pick another day.
</Text>
</Alert>
);
}
if (data.fits) {
return (
<Text fz="12px" c="teal" mt="xs">
<CheckCircle2 size={13} style={{ verticalAlign: "middle" }} />{" "}
{data.freeWagons} wagon{data.freeWagons === 1 ? "" : "s"} available on
this day your booking fits.
</Text>
);
}
// Shortfall.
if (tradeDirection === "EXPORT") {
return (
<Alert color="red" radius="md" icon={<AlertCircle size={15} />} mt="xs" p="xs">
<Text fz="12px">
Not enough space on this day. An export booking must ride one train
whole, so it can't be split the largest train still has room for
about {data.freeWagons} wagon{data.freeWagons === 1 ? "" : "s"}.
Reduce the booking or pick another day.
</Text>
</Alert>
);
}
return (
<Alert color="yellow" radius="md" icon={<AlertCircle size={15} />} mt="xs" p="xs">
<Text fz="12px">
About {data.freeWagons} wagon{data.freeWagons === 1 ? "" : "s"} are free
on this day less than your booking needs. You can still proceed: the
operations team will load what fits and the rest returns for you to
rebook on a later day.
</Text>
</Alert>
);
}

View File

@@ -460,6 +460,13 @@ export const api = {
({ bookingId }) => bookingsService.getAvailableDaysForBooking(bookingId),
),
getDayAvailability: endpoint<
{ bookingId: string; date: string },
Freight.DayAvailabilityResponse
>("train-scheduling", "dayAvailability", ({ bookingId, date }) =>
bookingsService.getDayAvailability(bookingId, date),
),
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
"train-scheduling",
"myBookingWindows",

View File

@@ -444,6 +444,18 @@ export const bookingsService = {
return (data.data as Freight.AvailableDaysResponse).days;
},
// Advisory free-wagon count for a shipment day (planning hint, not enforced).
getDayAvailability: async (
bookingId: string,
date: string,
): Promise<Freight.DayAvailabilityResponse> => {
const { data } = await client.get(
`/api/bookings/${bookingId}/day-availability`,
{ params: { date } },
);
return data.data as Freight.DayAvailabilityResponse;
},
/**
* Upcoming/open booking windows on the signed-in customer's active-contract
* lanes (import booking-day windows + export 24h pre-departure windows).