add hard capacity ceiling to weight limit rules

This commit is contained in:
Marshal
2026-07-04 01:11:23 +00:00
parent 97cc9d76b1
commit 8ea2c8e95a
19 changed files with 340 additions and 196 deletions

View File

@@ -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}
>

View File

@@ -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",
}),
);
};

View File

@@ -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) {

View File

@@ -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).",
},
],
},
{

View File

@@ -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;
}