implement Global Logistics booking process for customs contracts and enhance contract document handling

This commit is contained in:
Marshal
2026-06-29 08:08:10 +00:00
parent 4be4cc9054
commit aeb5e0046e
13 changed files with 436 additions and 104 deletions

View File

@@ -11,19 +11,25 @@ import {
Grid,
Group,
Loader,
Modal,
NumberInput,
Paper,
Select,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
} from "@mantine/core";
import {
CheckCircle2,
Container as ContainerIcon,
FileText,
Package,
Plus,
Receipt,
Trash2,
X,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -36,6 +42,11 @@ import {
useContractMutations,
} from "@/hooks/contracts/useContracts";
import { Boxes } from "lucide-react";
import {
computeGlShipmentTotal,
formatRateUnit,
type GlShipmentQuantities,
} from "./gl-booking-form/total";
interface UnitDraft {
containerNumber: string;
@@ -73,6 +84,9 @@ export default function GlCreateBookingForm() {
const [notes, setNotes] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
// Price-confirm modal — GL reviews the estimate before booking on behalf of
// the customer, mirroring the portal customer flow.
const [priceOpen, setPriceOpen] = useState(false);
const isContainer = contract?.freightType === "CONTAINER";
const routes = useMemo(
@@ -104,6 +118,34 @@ export default function GlCreateBookingForm() {
const defaultBulkCargoTypeId = bulkCargoOptions[0]?.value ?? "";
// Normalized quantities for the client-side price estimate (same source the
// portal customer sees: the contract's frozen unit rates × entered qty).
const quantities: GlShipmentQuantities = useMemo(
() => ({
isContainer,
containers: containerLines.map((l) => ({
containerSize: l.containerSize,
quantity: l.units.length,
hazardousQuantity: Number(l.hazardousQuantity || 0),
reeferQuantity: Number(l.reeferQuantity || 0),
})),
bulkQuantity: bulkLines.reduce(
(s, l) => s + Number(l.cargoWeightTons || l.itemCount || 0),
0,
),
bulkHazardousQuantity: bulkLines.reduce(
(s, l) => s + Number(l.hazardousQuantity || 0),
0,
),
}),
[isContainer, containerLines, bulkLines],
);
const priceTotal = useMemo(
() => (contract ? computeGlShipmentTotal(contract, quantities) : null),
[contract, quantities],
);
if (isLoading) {
return (
<PageContainer>
@@ -119,7 +161,7 @@ export default function GlCreateBookingForm() {
<PageContainer>
<PageHeader
title="Contract not found"
backTo="/dashboard/clearance"
backTo="/dashboard/contracts/clearance"
/>
</PageContainer>
);
@@ -233,12 +275,15 @@ export default function GlCreateBookingForm() {
<PageHeader
title="Create booking (GL)"
subtitle={`Enter the shipment details on behalf of the customer for contract ${contract.reference}.`}
backTo={`/dashboard/clearance/${contract.id}`}
backTo={`/dashboard/contracts/clearance/${contract.id}`}
breadcrumbs={[
{ label: "Document Clearance", href: "/dashboard/clearance" },
{
label: "Document Clearance",
href: "/dashboard/contracts/clearance",
},
{
label: contract.reference,
href: `/dashboard/clearance/${contract.id}`,
href: `/dashboard/contracts/clearance/${contract.id}`,
},
{ label: "Create booking" },
]}
@@ -578,20 +623,120 @@ export default function GlCreateBookingForm() {
<Group justify="flex-end">
<Button
variant="default"
onClick={() => navigate(`/dashboard/clearance/${contract.id}`)}
onClick={() =>
navigate(`/dashboard/contracts/clearance/${contract.id}`)
}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
disabled={!canSubmit}
loading={mutations.createBooking.isPending}
onClick={handleSubmit}
onClick={() => setPriceOpen(true)}
>
Create booking
Review price &amp; book
</Button>
</Group>
</Stack>
{/* Price-confirm — GL reviews the estimate, then books on behalf of the
customer. The server recomputes the authoritative total on submit. */}
<Modal
opened={priceOpen}
onClose={() => {
if (!mutations.createBooking.isPending) setPriceOpen(false);
}}
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}>
Confirm shipment price
</Text>
<Text fz="xs" c="dimmed">
Booking on behalf of the customer for {contract.reference}.
</Text>
</Box>
</Group>
}
>
{priceTotal ? (
<Stack gap="md">
<Paper withBorder radius={16} p="lg">
<Stack gap={10}>
{priceTotal.lines.map((line, i) => (
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ minWidth: 0 }}>
<Text fz="sm" fw={500}>
{line.label}
</Text>
<Text fz="xs" c="dimmed">
{line.quantity.toLocaleString()} ×{" "}
{line.unitPrice.toLocaleString()} {priceTotal.currency} ·{" "}
{formatRateUnit(line.unit)}
</Text>
</Box>
<Text fz="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
{line.amount.toLocaleString()} {priceTotal.currency}
</Text>
</Group>
))}
{priceTotal.lines.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}>
{priceTotal.total.toLocaleString()}{" "}
<Text span fz={16} fw={700} c="dimmed">
{priceTotal.currency}
</Text>
</Text>
</Group>
</Paper>
<Group justify="space-between" mt="xs">
<Button
variant="default"
radius="md"
leftSection={<X size={16} />}
onClick={() => setPriceOpen(false)}
disabled={mutations.createBooking.isPending}
>
Back to edit
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
loading={mutations.createBooking.isPending}
onClick={handleSubmit}
>
Confirm &amp; book
</Button>
</Group>
</Stack>
) : null}
</Modal>
</PageContainer>
);
}