feat: full wagon cancel, leg board, wagon dates

feat(freight): editable train leg times, SL invoice payer
This commit is contained in:
Marshal
2026-08-15 10:12:37 +00:00
parent c9c2d4dcb3
commit dc38e843a6
31 changed files with 1619 additions and 217 deletions

View File

@@ -423,7 +423,7 @@ export function AppLayout({
)}
{/* Search pill */}
<Group
{/* <Group
gap={8}
align="center"
visibleFrom="sm"
@@ -441,7 +441,7 @@ export function AppLayout({
<Text size="sm" style={{ color: mutedColor, userSelect: "none" }}>
Search shipments, bookings…
</Text>
</Group>
</Group> */}
{/* Notifications */}
<NotificationBellContainer />
@@ -549,7 +549,7 @@ export function AppLayout({
navigate("/bookings/new", { state: { fresh: true } })
}
>
New Booking
New Contract
</Menu.Item>
<Divider />
<Menu.Item

View File

@@ -742,7 +742,7 @@ export default function BookingsListPage() {
Track every cargo booking from draft to delivery.
</Text>
</Box>
<Button
{/* <Button
component={Link}
to="/contracts"
color="edr-green"
@@ -750,7 +750,7 @@ export default function BookingsListPage() {
leftSection={<Plus size={16} />}
>
New booking
</Button>
</Button> */}
</Group>
{/* ── Summary stat cards ──────────────────────────────────────── */}

View File

@@ -114,6 +114,17 @@ export default function ShippingLineBookingsPage() {
);
},
},
{
id: "scheduledDate",
header: () => <ColHeader label="Shipment date" />,
cell: ({ row }) => (
<Text fz={13} c={row.original.scheduledDate ? undefined : "edr-muted"}>
{row.original.scheduledDate
? new Date(row.original.scheduledDate).toLocaleDateString()
: "—"}
</Text>
),
},
{
id: "status",
header: () => <ColHeader label="Status" />,

View File

@@ -17,6 +17,7 @@ import {
Box,
Button,
Center,
Divider,
FileButton,
Group,
List,
@@ -26,14 +27,15 @@ import {
Select,
Stack,
Switch,
Table,
Text,
TextInput,
Textarea,
ThemeIcon,
Title,
} from "@mantine/core";
import {
AlertCircle,
AlertTriangle,
CalendarDays,
CheckCircle2,
ChevronLeft,
@@ -43,6 +45,7 @@ import {
MapPin,
Package,
PackageCheck,
Receipt,
Snowflake,
Train,
X,
@@ -55,6 +58,7 @@ import {
StepLabel,
fieldStyles,
} from "../contracts/new-contract-form/shared";
import { formatRateUnit } from "../contracts/new-contract-form/unit-rates";
import {
ShipmentFormInputValues,
ShipmentFormValues,
@@ -65,6 +69,7 @@ import {
downloadContainerImportTemplate,
parseContainerExcel,
} from "../contracts/new-shipment-form/container-excel";
import { formatAmount } from "../contracts/new-shipment-form/total";
import {
shippingLineBookingsService,
type CompleteBookingContainerLine,
@@ -213,21 +218,21 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
: 0;
const hasOdd20ft = ft20Total % 2 === 1;
// Two-step submit: the payload is priced first (authoritative quote, saved
// server-side with fresh rate snapshots on every preview), the shipping line
// confirms the figure, and only then does the booking submit.
// Two-step submit, same shape as the customer shipment form: the confirm
// modal opens at once with the payload pending, the server prices it (and
// runs the 20ft pairing check) while the modal shows a loader, the shipping
// line confirms the figure, and only then does the booking submit.
const [pendingPayload, setPendingPayload] =
useState<CompleteShippingLineBookingPayload | null>(null);
const [quote, setQuote] = useState<ShippingLinePriceQuote | null>(null);
const previewMutation = useMutation({
mutationFn: (payload: CompleteShippingLineBookingPayload) =>
shippingLineBookingsService.pricePreview(booking.id, payload),
onSuccess: (result, payload) => {
setPendingPayload(payload);
setQuote(result);
},
// A pricing failure (no rate configured) closes the confirm dialog — the
// error modal takes over with the server's message.
onError: () => setPendingPayload(null),
});
const quote = previewMutation.data ?? null;
const submitMutation = useMutation({
mutationFn: (payload: CompleteShippingLineBookingPayload) =>
@@ -241,11 +246,17 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
// A submit failure (day filled up, window closed meanwhile) must not leave
// a stale confirm dialog on screen — the error modal takes over.
onError: () => {
setQuote(null);
previewMutation.reset();
setPendingPayload(null);
},
});
const closeConfirm = () => {
if (submitMutation.isPending) return;
previewMutation.reset();
setPendingPayload(null);
};
/**
* Map a container size to the configured container type: reefer type when
* any container on the line is refrigerated, standard type otherwise —
@@ -313,11 +324,21 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
return;
}
setPayloadError(null);
// Price first — the confirm dialog opens when the quote arrives; the
// booking submits only after the shipping line confirms the figure.
// Open the confirm dialog now and price into it; the booking submits only
// after the shipping line confirms the figure.
setPendingPayload(payload);
previewMutation.reset();
previewMutation.mutate(payload);
});
const handleConfirm = () => {
if (!pendingPayload || !quote) return;
// Guard: never let unresolved 20ft pairing errors submit — the server
// rejects them anyway; the disabled button just says so first.
if (quote.pairingErrors.length > 0) return;
submitMutation.mutate(pendingPayload);
};
const showValidationSummary =
form.formState.isSubmitted && !form.formState.isValid;
@@ -446,112 +467,14 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
</Stack>
</Modal>
{/* Price confirmation: the quote just computed (and snapshotted)
server-side. Nothing submits until the figure is confirmed. */}
<Modal
opened={Boolean(quote)}
onClose={() => {
setQuote(null);
setPendingPayload(null);
}}
centered
radius="md"
size="lg"
title={
<Group gap={8}>
<PackageCheck size={18} color="var(--mantine-color-teal-6)" />
<Text fw={700} fz={16}>
Confirm your booking price
</Text>
</Group>
}
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
>
{quote && (
<Stack gap="md">
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="xs" horizontalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Charge</Table.Th>
<Table.Th ta="right">Qty</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{quote.lineItems.map((item, i) => (
<Table.Tr key={i}>
<Table.Td>
<Text size="sm">{item.description}</Text>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" c="dimmed">
{item.quantity ?? 1}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={600}>
{Number(item.amount).toLocaleString()}{" "}
{item.currency}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group
justify="space-between"
p="sm"
style={{
borderRadius: 10,
background: "var(--mantine-color-teal-0)",
}}
>
<Text fw={700}>Total</Text>
<Text fw={800} fz={18}>
{Number(quote.totalAmount).toLocaleString()} {quote.currency}
</Text>
</Group>
<Text size="xs" c="dimmed">
The amount is charged to your credit account no payment is
due now. EDR bills your accumulated charges periodically.
</Text>
{quote.warnings.length > 0 && (
<Alert
color="yellow"
radius="md"
icon={<AlertCircle size={16} />}
>
{quote.warnings.join(" ")}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => {
setQuote(null);
setPendingPayload(null);
}}
>
Go back
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<PackageCheck size={16} />}
loading={submitMutation.isPending}
onClick={() =>
pendingPayload && submitMutation.mutate(pendingPayload)
}
>
Confirm & book
</Button>
</Group>
</Stack>
)}
</Modal>
<PriceConfirmModal
opened={Boolean(pendingPayload)}
quote={quote}
quoteLoading={previewMutation.isPending}
loading={submitMutation.isPending}
onConfirm={handleConfirm}
onReject={closeConfirm}
/>
<Box flex={1} p="24px">
<Stack gap="lg" className="mx-auto max-w-4xl">
@@ -618,6 +541,225 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
);
}
/**
* Price confirmation — the customer shipment form's modal, one-to-one: opens
* the moment the form submits, shows a loader while the server prices and
* checks 20ft pairing, then the authoritative breakdown. Pairing violations
* hard-block confirm (the server rejects them on /complete too); overweight
* containers only warn — the surcharge is already inside the total.
*/
function PriceConfirmModal({
opened,
quote,
quoteLoading,
loading,
onConfirm,
onReject,
}: {
opened: boolean;
quote: ShippingLinePriceQuote | null;
quoteLoading: boolean;
loading: boolean;
onConfirm: () => void;
onReject: () => void;
}) {
const pairingErrors = quote?.pairingErrors ?? [];
const hasPairingBlock = pairingErrors.length > 0;
const overweightLines = quote?.overweightLines ?? [];
const overweightSurchargeAmount =
quote?.lineItems.find((li) => li.code === "OVERWEIGHT_PER_TON")?.amount ??
0;
// Confirm waits for the authoritative price and a clean pairing check.
const confirmDisabled =
loading || quoteLoading || hasPairingBlock || !quote;
return (
<Modal
opened={opened}
onClose={onReject}
closeOnClickOutside={!loading}
closeOnEscape={!loading}
withCloseButton={!loading}
centered
radius="lg"
size="lg"
title={
<Group gap={10}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<Receipt size={18} />
</ThemeIcon>
<Box>
<Text fw={800} fz={16} c="#10202F">
Confirm shipment price
</Text>
<Text fz="xs" c="dimmed">
Review the total before booking this shipment.
</Text>
</Box>
</Group>
}
>
<Stack gap="md">
{quoteLoading && (
<Group gap={8} c="dimmed">
<Loader size="xs" color="edr-green" />
<Text fz="sm" c="dimmed">
Computing the final price breakdown and checking container
weights
</Text>
</Group>
)}
{hasPairingBlock && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Cannot complete booking — 20ft wagon pairing"
>
<Stack gap={6}>
{pairingErrors.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
Adjust the 20ft container weights or quantities so pairs differ
by no more than 10 tons.
</Text>
</Stack>
</Alert>
)}
{overweightLines.length > 0 && (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertTriangle size={16} />}
title="Overweight containers"
>
<Stack gap={6}>
{overweightLines.map((line, i) => (
<Text key={i} fz="sm" c="#9A5B00">
{line.containerTypeCode}: {line.totalVgmTons}t exceeds limit{" "}
{line.maxAllowedTons}t (+{line.excessTons}t overweight)
</Text>
))}
<Text fz="xs" c="#9A5B00" mt={2}>
{overweightSurchargeAmount > 0
? `An overweight surcharge of ${formatAmount(overweightSurchargeAmount)} ${
quote?.currency ?? ""
} applies (included in the total below). You can still submit, or go back and adjust weights.`
: "An overweight surcharge applies. You can still submit, or go back and adjust weights."}
</Text>
</Stack>
</Alert>
)}
{quote && quote.warnings.length > 0 && (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertTriangle size={16} />}
>
{quote.warnings.join(" ")}
</Alert>
)}
{quote && (
<Paper
withBorder
radius={16}
p="lg"
style={{ borderColor: "#E6ECF2" }}
>
<Stack gap={10}>
{quote.lineItems.map((line, i) => (
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ minWidth: 0 }}>
<Text fz="sm" c="#10202F" fw={500}>
{line.description}
</Text>
<Text fz="xs" c="dimmed">
{(line.quantity ?? 1).toLocaleString()} ×{" "}
{formatAmount(line.unitAmount ?? line.amount)}{" "}
{quote.currency}
{line.unit
? ` · ${formatRateUnit(line.unit.toLowerCase())}`
: ""}
</Text>
</Box>
<Text
fz="sm"
fw={600}
c="#10202F"
style={{ whiteSpace: "nowrap" }}
>
{formatAmount(line.amount)} {quote.currency}
</Text>
</Group>
))}
{quote.lineItems.length === 0 && (
<Text fz="sm" c="dimmed">
No priced lines check the cargo details.
</Text>
)}
</Stack>
<Divider my="md" />
<Group justify="space-between" align="flex-end">
<Text
fz="xs"
fw={700}
tt="uppercase"
c="edr-green"
style={{ letterSpacing: "0.06em" }}
>
Total
</Text>
<Text fw={800} fz={28} c="#10202F">
{formatAmount(quote.totalAmount)}{" "}
<Text span fz={16} fw={700} c="edr-muted">
{quote.currency}
</Text>
</Text>
</Group>
<Text fz="xs" c="dimmed" mt="sm">
The amount is charged to your credit account no payment is due
now. EDR bills your accumulated charges periodically.
</Text>
</Paper>
)}
<Group justify="space-between" mt="xs">
<Button
variant="default"
radius="md"
leftSection={<X size={16} />}
onClick={onReject}
disabled={loading}
>
Reject &amp; edit
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
onClick={onConfirm}
loading={loading}
disabled={confirmDisabled}
>
Confirm &amp; book
</Button>
</Group>
</Stack>
</Modal>
);
}
/** The booking's lane — fixed at initiate time from the chosen route. */
function RouteCard({ booking }: { booking: ShippingLineBooking }) {
return (

View File

@@ -67,6 +67,15 @@ export interface ShippingLinePriceQuote {
currency: string;
lineItems: Freight.PricingBreakdownLineItem[];
warnings: string[];
/** Containers over their type's weight limit — a surcharge, not a block. */
overweightLines: {
containerTypeCode: string;
totalVgmTons: number;
maxAllowedTons: number;
excessTons: number;
}[];
/** 20ft wagon-pairing violations (pair weight diff over the cap) — hard block. */
pairingErrors: string[];
}
/** One wagon the batch engine allocated to the booking. */