mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
add hard capacity ceiling to weight limit rules
This commit is contained in:
@@ -444,6 +444,7 @@ export default function GlCreateBookingForm() {
|
||||
|
||||
const displayTotal = serverTotal ?? priceTotal;
|
||||
const pairingErrors = validation?.pairingErrors ?? [];
|
||||
const capacityErrors = validation?.capacityErrors ?? [];
|
||||
const overweightLines = validation?.overweightLines ?? [];
|
||||
|
||||
const openPriceModal = () => {
|
||||
@@ -459,6 +460,8 @@ export default function GlCreateBookingForm() {
|
||||
if (!contract || !windowOpen) return;
|
||||
// Never book past unresolved 20ft pairing hard-blocks.
|
||||
if (pairingErrors.length > 0) return;
|
||||
// A line above the container type's max capacity can never book.
|
||||
if (capacityErrors.length > 0) return;
|
||||
const payload = buildPayload();
|
||||
if (!payload) return;
|
||||
|
||||
@@ -938,6 +941,28 @@ export default function GlCreateBookingForm() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{capacityErrors.length > 0 && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
title="Cannot create booking — over maximum capacity"
|
||||
>
|
||||
<Stack gap={6}>
|
||||
{capacityErrors.map((msg, i) => (
|
||||
<Text key={i} fz="sm" c="red.8">
|
||||
{msg}
|
||||
</Text>
|
||||
))}
|
||||
<Text fz="xs" c="red.7" mt={2}>
|
||||
Reduce the cargo weight or split it across more containers
|
||||
to book this shipment.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{overweightLines.length > 0 && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
@@ -1023,7 +1048,9 @@ export default function GlCreateBookingForm() {
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
loading={mutations.createBooking.isPending}
|
||||
disabled={
|
||||
validateShipmentMutation.isPending || pairingErrors.length > 0
|
||||
validateShipmentMutation.isPending ||
|
||||
pairingErrors.length > 0 ||
|
||||
capacityErrors.length > 0
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
@@ -47,6 +48,18 @@ interface ScheduleWorkspacePanelProps {
|
||||
|
||||
const GREEN = "var(--mantine-color-edr-green-6)";
|
||||
|
||||
/** Pull the API's violation detail out of an error (e.g. "No CW3 wagon available…"). */
|
||||
function apiErrorMessage(error: unknown, fallback: string): string {
|
||||
if (isAxiosError(error)) {
|
||||
const data = error.response?.data as Record<string, unknown> | undefined;
|
||||
const violations = data?.violations;
|
||||
if (Array.isArray(violations) && violations.length) return violations.join(", ");
|
||||
if (typeof data?.message === "string") return data.message;
|
||||
if (Array.isArray(data?.message)) return (data.message as string[]).join(", ");
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deadline + label for the window phase this schedule is currently in.
|
||||
* Phases run: window open (windowClosesAt) → document review (docReviewEndsAt)
|
||||
@@ -198,8 +211,12 @@ export function ScheduleWorkspacePanel({
|
||||
onChanged();
|
||||
void poolQuery.refetch();
|
||||
})
|
||||
.catch(() =>
|
||||
toast({ title: "Could not add booking", variant: "destructive" }),
|
||||
.catch((error) =>
|
||||
toast({
|
||||
title: "Could not add booking",
|
||||
description: apiErrorMessage(error, "Validation failed — check capacity and status."),
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -211,8 +228,12 @@ export function ScheduleWorkspacePanel({
|
||||
onChanged();
|
||||
void poolQuery.refetch();
|
||||
})
|
||||
.catch(() =>
|
||||
toast({ title: "Could not remove booking", variant: "destructive" }),
|
||||
.catch((error) =>
|
||||
toast({
|
||||
title: "Could not remove booking",
|
||||
description: apiErrorMessage(error, "Please try again."),
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -226,8 +247,12 @@ export function ScheduleWorkspacePanel({
|
||||
onChanged();
|
||||
void poolQuery.refetch();
|
||||
})
|
||||
.catch(() =>
|
||||
toast({ title: "Could not reassign booking", variant: "destructive" }),
|
||||
.catch((error) =>
|
||||
toast({
|
||||
title: "Could not reassign booking",
|
||||
description: apiErrorMessage(error, "Target train may be closed or full."),
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -341,6 +341,10 @@ const RuleEngineResourcePage = () => {
|
||||
} else if (config.slug === "priority-configs") {
|
||||
// Label is required by the backend but hidden in the UI for now.
|
||||
payload = { ...values, label: String(Date.now()) };
|
||||
} else if (config.slug === "weight-limit-rules") {
|
||||
// Empty max capacity means "no ceiling" — send null explicitly so an
|
||||
// edit can clear a previously-set ceiling (omitting the key keeps it).
|
||||
payload = { ...values, maxCapacityTons: values.maxCapacityTons ?? null };
|
||||
}
|
||||
|
||||
if (editing?.id) {
|
||||
|
||||
@@ -374,6 +374,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
},
|
||||
{ id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" },
|
||||
{ id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" },
|
||||
{
|
||||
id: "maxCapacityTons",
|
||||
header: "Max capacity (t)",
|
||||
accessorKey: "maxCapacityTons",
|
||||
format: "number",
|
||||
},
|
||||
],
|
||||
formFields: [
|
||||
{
|
||||
@@ -391,6 +397,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
options: TRADE_DIRECTIONS,
|
||||
},
|
||||
{ name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true },
|
||||
{
|
||||
name: "maxCapacityTons",
|
||||
label: "Max capacity (tons)",
|
||||
type: "number",
|
||||
optional: true,
|
||||
description:
|
||||
"Hard ceiling — a booking whose line weight exceeds this cannot be created at all. Leave empty for no ceiling (overweight surcharge only).",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -43,8 +43,8 @@ export interface ShipmentPriceLine {
|
||||
* Pre-create validation + authoritative price preview for a booking under a
|
||||
* contract. `lineItems`/`totalAmount` are the full server-computed breakdown —
|
||||
* the same pricing pass the booking persists at create (rail freight,
|
||||
* first/last mile, overweight and every other surcharge). `pairingErrors` are
|
||||
* HARD BLOCKS; `overweightLines` are warnings.
|
||||
* first/last mile, overweight and every other surcharge). `pairingErrors` and
|
||||
* `capacityErrors` are HARD BLOCKS; `overweightLines` are warnings.
|
||||
*/
|
||||
export interface ShipmentValidation {
|
||||
overweightLines: Array<{
|
||||
@@ -56,6 +56,8 @@ export interface ShipmentValidation {
|
||||
overweightSurchargeAmount: number;
|
||||
currency: string | null;
|
||||
pairingErrors: string[];
|
||||
/** Lines above the container type's hard max capacity — booking cannot be created. */
|
||||
capacityErrors?: string[];
|
||||
lineItems?: ShipmentPriceLine[];
|
||||
totalAmount?: number;
|
||||
}
|
||||
|
||||
@@ -524,11 +524,10 @@ export default function NewBookingPage() {
|
||||
}
|
||||
: { customsClearingEnabled: false }),
|
||||
...(cargoFreeText ? { cargoFreeText } : {}),
|
||||
// Multi-route general contracts: routes are pure origin→destination lanes
|
||||
// the contract covers — they carry NO quantity. Route #1 is the primary
|
||||
// origin/destination; the rest come from the extra-routes step. The
|
||||
// contracted quantity lives in a single shared pool (the container
|
||||
// quantities / bulk total), drawn down per order against a chosen lane.
|
||||
// A general contract covers exactly ONE route — the same single
|
||||
// origin→destination pair as a one-time booking (multi-route on bookings
|
||||
// was dropped). The contracted quantity lives in a single shared pool
|
||||
// (container quantities / bulk total), drawn down per order.
|
||||
...(isContract
|
||||
? {
|
||||
routes: [
|
||||
@@ -536,12 +535,6 @@ export default function NewBookingPage() {
|
||||
originYardId: data.originYard,
|
||||
destinationYardId: data.destinationYard,
|
||||
},
|
||||
...(data.extraRoutes ?? [])
|
||||
.filter((r) => r.originYard && r.destinationYard)
|
||||
.map((r) => ({
|
||||
originYardId: r.originYard,
|
||||
destinationYardId: r.destinationYard,
|
||||
})),
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -144,10 +144,9 @@ export const bookingFormSchema = z
|
||||
// The contracted quantity now comes from the cargo step (cargoWeight), the
|
||||
// same as a one-time booking, so per-route quantity is no longer entered.
|
||||
primaryRouteQuantity: z.string().default(""),
|
||||
// Additional routes for a GENERAL contract (the primary origin/destination
|
||||
// above is route #1). Each route is just an (origin, destination) pair —
|
||||
// identical to the one-time route — so a contract can cover several routes.
|
||||
// Ignored for one-time bookings. quantity/km kept for payload back-compat.
|
||||
// LEGACY — multi-route general contracts were dropped; a contract booking
|
||||
// now covers exactly one route, like a one-time booking. Field retained only
|
||||
// so previously saved drafts still hydrate; never collected or sent anymore.
|
||||
extraRoutes: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -434,7 +433,6 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
||||
"originYard",
|
||||
"destinationYard",
|
||||
"primaryRouteQuantity",
|
||||
"extraRoutes",
|
||||
// Estimated shipment date now lives in the Route step (one-time bookings only).
|
||||
"scheduledDate",
|
||||
],
|
||||
|
||||
@@ -1,26 +1,9 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Box, Skeleton, Stack } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import {
|
||||
CalendarDays,
|
||||
MapPin,
|
||||
Plus,
|
||||
Route as RouteIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { CalendarDays, MapPin, Route as RouteIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Controller,
|
||||
useFieldArray,
|
||||
type UseFormReturn,
|
||||
} from "react-hook-form";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
type BookingFormValues,
|
||||
@@ -70,16 +53,6 @@ export function Step4Route({
|
||||
}
|
||||
}, [operationType]);
|
||||
|
||||
const {
|
||||
fields: extraRoutes,
|
||||
append: appendRoute,
|
||||
remove: removeRoute,
|
||||
} = useFieldArray({ control: form.control, name: "extraRoutes" });
|
||||
|
||||
// useFieldArray's `fields` don't re-render on value change, so watch the live
|
||||
// route values to filter each row's yard options by what it has selected.
|
||||
const watchedExtraRoutes = form.watch("extraRoutes") ?? [];
|
||||
|
||||
const yardOptions = useMemo(() => {
|
||||
if (!referenceData?.yard) return [];
|
||||
return referenceData.yard.map((y) => ({ value: y.id, label: y.name }));
|
||||
@@ -129,25 +102,6 @@ export function Step4Route({
|
||||
}
|
||||
}, [destinationCountry, dest, form]);
|
||||
|
||||
// Same cleanup for the extra contract routes: when the operation type changes,
|
||||
// clear any extra-route yard whose country no longer matches the required side
|
||||
// so an added route can't contradict the operation either.
|
||||
useEffect(() => {
|
||||
watchedExtraRoutes.forEach((route, i) => {
|
||||
const ro = referenceData?.yard.find((y) => y.id === route?.originYard);
|
||||
if (originCountry && ro && ro.country !== originCountry) {
|
||||
form.setValue(`extraRoutes.${i}.originYard`, "");
|
||||
}
|
||||
const rd = referenceData?.yard.find(
|
||||
(y) => y.id === route?.destinationYard,
|
||||
);
|
||||
if (destinationCountry && rd && rd.country !== destinationCountry) {
|
||||
form.setValue(`extraRoutes.${i}.destinationYard`, "");
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [originCountry, destinationCountry, referenceData, form]);
|
||||
|
||||
const directionStyle: Record<string, string> = {
|
||||
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
|
||||
IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
|
||||
@@ -161,11 +115,10 @@ export function Step4Route({
|
||||
|
||||
const stationSelectDisabled = yardOptions.length === 0;
|
||||
|
||||
// A general contract can cover several routes, but each route is just an
|
||||
// (origin, destination) pair — the same shape as the one-time route. The
|
||||
// contracted quantity comes from the cargo step, so no per-route quantity or
|
||||
// distance is collected here. Cargo handling (hazardous / refrigerated) also
|
||||
// lives in the Cargo step now, not here.
|
||||
// A general contract covers exactly ONE route — the same single
|
||||
// origin/destination pair as a one-time booking. (Multi-route contracts were
|
||||
// dropped; the multi-lane concept lives on the contracts module, not on
|
||||
// bookings.) The contracted quantity comes from the cargo step.
|
||||
|
||||
// Earliest selectable shipment date (today, local) for the date input's `min`.
|
||||
const todayISODate = useMemo(() => {
|
||||
@@ -256,103 +209,6 @@ export function Step4Route({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGeneralContract && !isLoading && (
|
||||
<Box mt={18}>
|
||||
<Group justify="space-between" align="center" mb={8}>
|
||||
<StepLabel>Additional contract routes</StepLabel>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
radius="md"
|
||||
leftSection={<Plus size={14} />}
|
||||
disabled={stationSelectDisabled}
|
||||
onClick={() =>
|
||||
appendRoute({
|
||||
originYard: "",
|
||||
destinationYard: "",
|
||||
quantity: "",
|
||||
km: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
Add route
|
||||
</Button>
|
||||
</Group>
|
||||
<Text fz={12} c="#6B7C8E" mb={12}>
|
||||
A general contract can cover several routes. The route above is your
|
||||
primary route; add more origin–destination routes the contract should
|
||||
cover.
|
||||
</Text>
|
||||
<Stack gap={12}>
|
||||
{extraRoutes.map((rf, i) => {
|
||||
// Each extra route is constrained by the SAME operation type as the
|
||||
// primary route: its origin must sit in originCountry and its
|
||||
// destination in destinationCountry. Watch this row's current values
|
||||
// so each side also excludes the yard picked on the other side.
|
||||
const rowOrigin = watchedExtraRoutes[i]?.originYard ?? "";
|
||||
const rowDestination =
|
||||
watchedExtraRoutes[i]?.destinationYard ?? "";
|
||||
const rowOriginData = yardsForSide(originCountry, rowDestination);
|
||||
const rowDestData = yardsForSide(destinationCountry, rowOrigin);
|
||||
return (
|
||||
<Group
|
||||
key={rf.id}
|
||||
gap={10}
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
className="rounded-xl"
|
||||
style={{ border: "1px solid #E6ECF2", padding: 12 }}
|
||||
>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Controller
|
||||
name={`extraRoutes.${i}.originYard`}
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Origin"
|
||||
placeholder="Origin..."
|
||||
disabled={stationSelectDisabled}
|
||||
data={rowOriginData}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Controller
|
||||
name={`extraRoutes.${i}.destinationYard`}
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Destination"
|
||||
placeholder="Destination..."
|
||||
disabled={stationSelectDisabled}
|
||||
data={rowDestData}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="xs"
|
||||
mt={24}
|
||||
px={6}
|
||||
onClick={() => removeRoute(i)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
</StepCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -299,6 +299,8 @@ function NewShipmentBookingForm({
|
||||
if (!pendingValues) return;
|
||||
// Guard: never let a booking with unresolved 20ft pairing errors submit.
|
||||
if ((validateMutation.data?.pairingErrors.length ?? 0) > 0) return;
|
||||
// Guard: a line above the container type's max capacity can never book.
|
||||
if ((validateMutation.data?.capacityErrors?.length ?? 0) > 0) return;
|
||||
submitMutation.mutate(buildDto(pendingValues));
|
||||
};
|
||||
|
||||
@@ -444,7 +446,10 @@ function PriceConfirmModal({
|
||||
const overweightSurchargeAmount = validation?.overweightSurchargeAmount ?? 0;
|
||||
const pairingErrors = validation?.pairingErrors ?? [];
|
||||
const hasPairingBlock = pairingErrors.length > 0;
|
||||
const confirmDisabled = loading || validationLoading || hasPairingBlock;
|
||||
const capacityErrors = validation?.capacityErrors ?? [];
|
||||
const hasCapacityBlock = capacityErrors.length > 0;
|
||||
const confirmDisabled =
|
||||
loading || validationLoading || hasPairingBlock || hasCapacityBlock;
|
||||
|
||||
// Authoritative server breakdown — the SAME BookingPricingService pass that
|
||||
// prices the booking on create, so it carries every line the booking will be
|
||||
@@ -550,6 +555,28 @@ function PriceConfirmModal({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasCapacityBlock && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
title="Cannot create booking — over maximum capacity"
|
||||
>
|
||||
<Stack gap={6}>
|
||||
{capacityErrors.map((msg, i) => (
|
||||
<Text key={i} fz="sm" c="red.8">
|
||||
{msg}
|
||||
</Text>
|
||||
))}
|
||||
<Text fz="xs" c="red.7" mt={2}>
|
||||
Reduce the cargo weight or split it across more containers to
|
||||
book this shipment.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{overweightLines.length > 0 && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
|
||||
@@ -71,6 +71,8 @@ export interface ShipmentValidation {
|
||||
overweightSurchargeAmount: number;
|
||||
currency: string | null;
|
||||
pairingErrors: string[];
|
||||
/** Lines above the container type's hard max capacity — booking cannot be created. */
|
||||
capacityErrors?: string[];
|
||||
lineItems?: ShipmentPriceLine[];
|
||||
totalAmount?: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user