mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 22:25:42 +00:00
feat: Refactor general contract handling and introduce drawdown orders
- Updated ContractRouteLineView to clarify that routes carry no quantity and are pure origin-destination lanes. - Modified GeneralContractService to reflect changes in route handling, removing quantity-related logic. - Adjusted BookingsService to persist contracted routes without quantities, aligning with the new contract structure. - Revised CreateBookingDto and CreateContractRouteDto to remove quantity fields, emphasizing shared pool usage. - Added ContractOrdersPanel component to display drawdown orders and their associated pool. - Implemented useContractOrders and useContractPool hooks for fetching order and pool data. - Created booking-orders.service.ts to manage API interactions for drawdown orders and pool data. - Updated BookingRequestDetailPage and PlaceOrderDialog to accommodate new order handling logic.
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Progress,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { ChevronRight, Inbox, PackageCheck } from "lucide-react";
|
||||
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import {
|
||||
useContractOrders,
|
||||
useContractPool,
|
||||
} from "@/hooks/bookings/useContractOrders";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
export interface ContractOrdersPanelProps {
|
||||
/** The general-contract booking whose drawdown orders are listed. */
|
||||
contractBookingId: string;
|
||||
/** Whether the contract is container-based (affects quantity labels). */
|
||||
isContainer: boolean;
|
||||
}
|
||||
|
||||
/** Format a contracted/remaining quantity with its unit. */
|
||||
function formatQuantity(
|
||||
qty: number,
|
||||
unit: Freight.ContractQuantityLine["unitOfMeasure"],
|
||||
isContainerLine: boolean,
|
||||
): string {
|
||||
const rounded = Number.isInteger(qty) ? qty : Number(qty.toFixed(2));
|
||||
if (isContainerLine) return `${rounded} containers`;
|
||||
if (unit === "PER_ITEM") return `${rounded} items`;
|
||||
return `${rounded} tons`;
|
||||
}
|
||||
|
||||
/** Summarise an order's lines, e.g. "2 20FT, 1 40FT" or "15". */
|
||||
function summariseLines(lines: Freight.IBookingOrderLine[]): string {
|
||||
return lines
|
||||
.map((l) => {
|
||||
const qty = Number(l.quantity);
|
||||
const label = Number.isInteger(qty) ? `${qty}` : qty.toFixed(2);
|
||||
return `${label}${l.containerTypeName ? ` ${l.containerTypeName}` : ""}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoffice "Orders" tab for a general contract: shows the drawdown pool and
|
||||
* lists each order placed against the contract. Each order links to its child
|
||||
* booking's detail page, where staff approve it and review clearance/customer
|
||||
* documents independently (same screen as a one-time booking).
|
||||
*/
|
||||
export function ContractOrdersPanel({
|
||||
contractBookingId,
|
||||
isContainer,
|
||||
}: ContractOrdersPanelProps) {
|
||||
const navigate = useNavigate();
|
||||
const { data: orders, isLoading: ordersLoading } =
|
||||
useContractOrders(contractBookingId);
|
||||
const { data: pool, isLoading: poolLoading } =
|
||||
useContractPool(contractBookingId);
|
||||
|
||||
const poolLines = pool ?? [];
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const contracted = poolLines.reduce(
|
||||
(s, l) => s + (l.contractedQuantity || 0),
|
||||
0,
|
||||
);
|
||||
const ordered = poolLines.reduce((s, l) => s + (l.orderedQuantity || 0), 0);
|
||||
const pct = contracted > 0 ? Math.round((ordered / contracted) * 100) : 0;
|
||||
return { contracted, ordered, pct };
|
||||
}, [poolLines]);
|
||||
|
||||
if (ordersLoading || poolLoading) {
|
||||
return (
|
||||
<Center mih={240}>
|
||||
<Loader color="gray" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Drawdown pool */}
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="lg">
|
||||
<Box>
|
||||
<Text fw={700} fz={16}>
|
||||
Contracted quantity
|
||||
</Text>
|
||||
<Text fz={13} c="dimmed" mt={2}>
|
||||
How much of this contract has been ordered versus what remains.
|
||||
</Text>
|
||||
</Box>
|
||||
{totals.contracted > 0 && (
|
||||
<RingProgress
|
||||
size={72}
|
||||
thickness={7}
|
||||
roundCaps
|
||||
sections={[{ value: totals.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Text ta="center" fz={13} fw={800}>
|
||||
{totals.pct}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Stack gap="lg">
|
||||
{poolLines.length === 0 && (
|
||||
<Text fz={13} c="dimmed">
|
||||
No quantity pool available.
|
||||
</Text>
|
||||
)}
|
||||
{poolLines.map((line, i) => {
|
||||
const pct =
|
||||
line.contractedQuantity > 0
|
||||
? Math.min(
|
||||
100,
|
||||
(line.orderedQuantity / line.contractedQuantity) * 100,
|
||||
)
|
||||
: 0;
|
||||
const label = isContainer
|
||||
? (line.containerTypeName ?? "Containers")
|
||||
: line.unitOfMeasure === "PER_ITEM"
|
||||
? "Items"
|
||||
: "Tons";
|
||||
const depleted = line.remainingQuantity <= 0;
|
||||
return (
|
||||
<div key={line.containerTypeId ?? `bulk-${i}`}>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Group gap={8} align="center">
|
||||
<Text fz={14} fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
{depleted && (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
Fully ordered
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz={13} c="dimmed">
|
||||
<Text span fw={700} c={depleted ? "dimmed" : "edr-green"}>
|
||||
{formatQuantity(
|
||||
line.remainingQuantity,
|
||||
line.unitOfMeasure,
|
||||
isContainer,
|
||||
)}
|
||||
</Text>{" "}
|
||||
remaining of{" "}
|
||||
{formatQuantity(
|
||||
line.contractedQuantity,
|
||||
line.unitOfMeasure,
|
||||
isContainer,
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={pct}
|
||||
color={depleted ? "gray" : "edr-green"}
|
||||
size="md"
|
||||
radius="xl"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Orders */}
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<Text fw={700} fz={16}>
|
||||
Orders
|
||||
</Text>
|
||||
<Badge variant="light" color="violet" radius="sm">
|
||||
{orders?.length ?? 0}
|
||||
</Badge>
|
||||
</Group>
|
||||
{!orders || orders.length === 0 ? (
|
||||
<Stack align="center" gap={8} py="xl">
|
||||
<ThemeIcon size={48} radius="xl" variant="light" color="gray">
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fz={13} c="dimmed" ta="center" maw={360}>
|
||||
No orders have been placed against this contract yet.
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={10}>
|
||||
{orders.map((order) => {
|
||||
const childId = order.bookingId;
|
||||
const clickable = Boolean(childId);
|
||||
return (
|
||||
<Group
|
||||
key={order.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
cursor: clickable ? "pointer" : "default",
|
||||
}}
|
||||
onClick={
|
||||
clickable
|
||||
? () =>
|
||||
navigate(`/dashboard/booking-requests/${childId}`)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
size={38}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="violet"
|
||||
>
|
||||
<PackageCheck size={18} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={700} truncate>
|
||||
{order.reference}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed" truncate>
|
||||
Ship{" "}
|
||||
{new Date(order.scheduledDate).toLocaleDateString()}
|
||||
{order.lines.length > 0
|
||||
? ` · ${summariseLines(order.lines)}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<BookingStatusBadge status={order.status} />
|
||||
{clickable && (
|
||||
<ChevronRight
|
||||
size={16}
|
||||
color="var(--mantine-color-gray-5)"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./booking-detail.styles";
|
||||
export * from "./SectionCard";
|
||||
export * from "./ClearanceReviewSection";
|
||||
export * from "./ContractOrdersPanel";
|
||||
export * from "./MetricTile";
|
||||
export * from "./BookingDetailToolbar";
|
||||
export * from "./BookingDetailHeader";
|
||||
|
||||
Reference in New Issue
Block a user