mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +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 {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user