mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: full wagon cancel, leg board, wagon dates
feat(freight): editable train leg times, SL invoice payer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 ──────────────────────────────────────── */}
|
||||
|
||||
@@ -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" />,
|
||||
|
||||
@@ -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 & edit
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
onClick={onConfirm}
|
||||
loading={loading}
|
||||
disabled={confirmDisabled}
|
||||
>
|
||||
Confirm & book
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** The booking's lane — fixed at initiate time from the chosen route. */
|
||||
function RouteCard({ booking }: { booking: ShippingLineBooking }) {
|
||||
return (
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user