mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 00:10:57 +00:00
implement Global Logistics booking process for customs contracts and enhance contract document handling
This commit is contained in:
@@ -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 & 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 & book
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
export interface GlShipmentTotalLine {
|
||||
label: string;
|
||||
unitPrice: number;
|
||||
unit: Freight.ContractRateUnit | string;
|
||||
quantity: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface GlShipmentTotal {
|
||||
currency: string;
|
||||
lines: GlShipmentTotalLine[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** A normalized view of the form quantities, freight-shape agnostic. */
|
||||
export interface GlShipmentQuantities {
|
||||
isContainer: boolean;
|
||||
/** Container lines: size + total qty + hazardous/reefer qty. */
|
||||
containers: Array<{
|
||||
containerSize: string;
|
||||
quantity: number;
|
||||
hazardousQuantity: number;
|
||||
reeferQuantity: number;
|
||||
}>;
|
||||
/** Bulk: tons (or item count) + hazardous qty. */
|
||||
bulkQuantity: number;
|
||||
bulkHazardousQuantity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the booking total client-side from the contract's frozen unit rates ×
|
||||
* the quantities GL enters. Mirrors the portal customer estimate
|
||||
* (new-shipment-form/total.ts) — the server recomputes the authoritative total
|
||||
* on submit. Shown in the price-confirm modal before GL books on behalf of the
|
||||
* customer.
|
||||
*/
|
||||
export function computeGlShipmentTotal(
|
||||
contract: Freight.IContract,
|
||||
q: GlShipmentQuantities,
|
||||
): GlShipmentTotal {
|
||||
const breakdown = contract.pricingBreakdown;
|
||||
const currency = breakdown?.currency ?? contract.paymentCurrency ?? "ETB";
|
||||
const items = breakdown?.lineItems ?? [];
|
||||
const lines: GlShipmentTotalLine[] = [];
|
||||
|
||||
const rateFor = (
|
||||
predicate: (i: Freight.ContractUnitRateLineItem) => boolean,
|
||||
) => items.find(predicate);
|
||||
|
||||
if (q.isContainer) {
|
||||
let hazardTotalQty = 0;
|
||||
let reeferTotalQty = 0;
|
||||
|
||||
for (const line of q.containers) {
|
||||
const qty = line.quantity;
|
||||
if (qty <= 0) continue;
|
||||
const rate =
|
||||
rateFor(
|
||||
(i) =>
|
||||
i.containerSize === line.containerSize &&
|
||||
i.unit === "per_container" &&
|
||||
!i.conditionalOn,
|
||||
) ?? rateFor((i) => i.containerSize === line.containerSize);
|
||||
if (rate) {
|
||||
lines.push({
|
||||
label: rate.label,
|
||||
unitPrice: rate.unitPrice,
|
||||
unit: rate.unit,
|
||||
quantity: qty,
|
||||
amount: rate.unitPrice * qty,
|
||||
});
|
||||
}
|
||||
hazardTotalQty += line.hazardousQuantity;
|
||||
reeferTotalQty += line.reeferQuantity;
|
||||
}
|
||||
|
||||
if (contract.isHazardous && hazardTotalQty > 0) {
|
||||
const hz = rateFor((i) => i.conditionalOn === "is_hazardous");
|
||||
if (hz) {
|
||||
lines.push({
|
||||
label: hz.label,
|
||||
unitPrice: hz.unitPrice,
|
||||
unit: hz.unit,
|
||||
quantity: hazardTotalQty,
|
||||
amount: hz.unitPrice * hazardTotalQty,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (contract.isReefer && reeferTotalQty > 0) {
|
||||
const rf = rateFor((i) => i.conditionalOn === "is_reefer");
|
||||
if (rf) {
|
||||
lines.push({
|
||||
label: rf.label,
|
||||
unitPrice: rf.unitPrice,
|
||||
unit: rf.unit,
|
||||
quantity: reeferTotalQty,
|
||||
amount: rf.unitPrice * reeferTotalQty,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const qty = q.bulkQuantity;
|
||||
const rate =
|
||||
rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0];
|
||||
if (rate && qty > 0) {
|
||||
lines.push({
|
||||
label: rate.label,
|
||||
unitPrice: rate.unitPrice,
|
||||
unit: rate.unit,
|
||||
quantity: qty,
|
||||
amount: rate.unitPrice * qty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const total = lines.reduce((s, l) => s + l.amount, 0);
|
||||
return { currency, lines, total };
|
||||
}
|
||||
|
||||
/** Human-readable label for a contract unit-rate's charge unit. */
|
||||
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
|
||||
const map: Record<string, string> = {
|
||||
per_container: "container",
|
||||
per_ton: "ton",
|
||||
per_item: "item",
|
||||
per_km: "km",
|
||||
flat: "flat",
|
||||
};
|
||||
return map[unit] ?? unit.replace(/_/g, " ").replace(/^per /, "");
|
||||
}
|
||||
Reference in New Issue
Block a user