mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 10:45:44 +00:00
Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile
This commit is contained in:
@@ -39,6 +39,7 @@ import {
|
||||
getRouteDirection,
|
||||
initialBookingFormValues,
|
||||
operationToProfileType,
|
||||
operationToTradeDirection,
|
||||
stepFields,
|
||||
type BookingDocuments,
|
||||
type BookingFormValues,
|
||||
@@ -271,6 +272,7 @@ export default function NewBookingPage() {
|
||||
|
||||
const originYard = form.watch("originYard");
|
||||
const destinationYard = form.watch("destinationYard");
|
||||
const operationType = form.watch("operationType");
|
||||
|
||||
// The estimated shipment date lives in the Route step now; for general
|
||||
// contracts that date field is simply hidden there (the date is chosen per
|
||||
@@ -298,9 +300,16 @@ export default function NewBookingPage() {
|
||||
(y) => y.id === destinationYard,
|
||||
);
|
||||
|
||||
const route = getRouteDirection(origin, destination);
|
||||
return route;
|
||||
}, [originYard, destinationYard]);
|
||||
// Country-based derivation is authoritative when both yards are tagged, but
|
||||
// returns null if a yard is unselected or lacks a country (e.g. intercity
|
||||
// yards with no country set → DOMESTIC). The operation chosen in step 0 is
|
||||
// the user's explicit intent, so fall back to it to guarantee a valid value
|
||||
// and avoid posting tradeDirection: null (which the API rejects with @IsIn).
|
||||
return (
|
||||
getRouteDirection(origin, destination) ??
|
||||
(operationType ? operationToTradeDirection(operationType) : null)
|
||||
);
|
||||
}, [originYard, destinationYard, operationType, referenceData]);
|
||||
|
||||
// The company's onboarded profile types — drives which operations are offered
|
||||
// and which profile each operation stamps the booking to.
|
||||
@@ -489,30 +498,23 @@ export default function NewBookingPage() {
|
||||
}
|
||||
: { customsClearingEnabled: false }),
|
||||
...(cargoFreeText ? { cargoFreeText } : {}),
|
||||
// Multi-route general contracts: route #1 is the primary origin/destination
|
||||
// carrying the full contracted quantity (from the cargo step). Extra routes
|
||||
// are just additional origin/destination pairs the contract covers — no
|
||||
// per-route quantity is collected, so they are sent with quantity 0.
|
||||
// Multi-route general contracts: routes are pure origin→destination lanes
|
||||
// the contract covers — they carry NO quantity. Route #1 is the primary
|
||||
// origin/destination; the rest come from the extra-routes step. The
|
||||
// contracted quantity lives in a single shared pool (the container
|
||||
// quantities / bulk total), drawn down per order against a chosen lane.
|
||||
...(isContract
|
||||
? {
|
||||
routes: [
|
||||
{
|
||||
originYardId: data.originYard,
|
||||
destinationYardId: data.destinationYard,
|
||||
quantity:
|
||||
data.cargoType === "container"
|
||||
? data.containers.reduce(
|
||||
(sum, c) => sum + Number(c.qty || 0),
|
||||
0,
|
||||
)
|
||||
: totalWeight,
|
||||
},
|
||||
...(data.extraRoutes ?? [])
|
||||
.filter((r) => r.originYard && r.destinationYard)
|
||||
.map((r) => ({
|
||||
originYardId: r.originYard,
|
||||
destinationYardId: r.destinationYard,
|
||||
quantity: 0,
|
||||
})),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -67,6 +67,15 @@ export default function ContractDetailPage() {
|
||||
enabled: !!id && contract?.status !== "DRAFT",
|
||||
});
|
||||
|
||||
// Multi-route contracts return one line per contracted route; single-route
|
||||
// contracts return []. Drives the Routes section + the per-route order flow.
|
||||
const { data: routeLines } = useQuery({
|
||||
...api.bookingOrders.routes.queryOptions({
|
||||
input: { contractBookingId: id! },
|
||||
}),
|
||||
enabled: !!id && contract?.status !== "DRAFT",
|
||||
});
|
||||
|
||||
const poolLines = pool ?? [];
|
||||
|
||||
// Overall utilization across every pool line — drives the header ring + stat.
|
||||
@@ -220,6 +229,53 @@ export default function ContractDetailPage() {
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* Contracted routes — every origin/destination pair the contract covers,
|
||||
with its own remaining pool. Orders draw down one route at a time. */}
|
||||
{showPool && routeLines && routeLines.length > 0 && (
|
||||
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||
<Group gap={8} mb="lg">
|
||||
<Text fw={700} fz={16} style={{ color: INK }}>
|
||||
Contracted routes
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color="violet" radius="sm">
|
||||
{routeLines.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text fz={13} c="dimmed" mb="md" mt={-8}>
|
||||
Lanes this contract covers. Orders draw from the shared pool below —
|
||||
pick a lane per order for scheduling and routing.
|
||||
</Text>
|
||||
<Stack gap={10}>
|
||||
{routeLines.map((route) => (
|
||||
<Group
|
||||
key={route.routeLineId}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon size={38} radius="md" variant="light" color="edr-green">
|
||||
<MapPin size={18} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||
{route.originYardName ?? route.originYardId} →{" "}
|
||||
{route.destinationYardName ?? route.destinationYardId}
|
||||
</Text>
|
||||
{route.km != null && (
|
||||
<Text fz={12} c="dimmed" truncate>
|
||||
{route.km} km
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Drawdown pool */}
|
||||
{showPool && (
|
||||
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||
|
||||
@@ -59,9 +59,10 @@ export function PlaceOrderDialog({
|
||||
const isMultiRoute = routeLines.length > 0;
|
||||
const selectedRoute = routeLines.find((r) => r.routeLineId === routeLineId);
|
||||
|
||||
// The route the order ships on drives both the available-days query and the
|
||||
// remaining-quantity check: the chosen route line for multi-route contracts,
|
||||
// else the contract's own origin/destination.
|
||||
// The route the order ships on drives ONLY the available-days (schedule) query:
|
||||
// the chosen lane for multi-route contracts, else the contract's own
|
||||
// origin/destination. Quantity is always drawn from the shared pool below —
|
||||
// routes are pure lanes and carry no quantity.
|
||||
const originYardId = isMultiRoute
|
||||
? selectedRoute?.originYardId
|
||||
: contract.originYard?.id;
|
||||
@@ -129,15 +130,12 @@ export function PlaceOrderDialog({
|
||||
setReeferQty("");
|
||||
}
|
||||
|
||||
// Total quantity across the order; haz/reefer counts cannot exceed it.
|
||||
const orderTotalQty = isMultiRoute
|
||||
? typeof quantities["__route__"] === "number"
|
||||
? (quantities["__route__"] as number)
|
||||
: 0
|
||||
: pool.reduce((sum, l) => {
|
||||
const raw = quantities[lineKey(l)];
|
||||
return sum + (typeof raw === "number" ? raw : 0);
|
||||
}, 0);
|
||||
// Total quantity across the order; haz/reefer counts cannot exceed it. Always
|
||||
// summed from the shared pool lines, regardless of routing.
|
||||
const orderTotalQty = pool.reduce((sum, l) => {
|
||||
const raw = quantities[lineKey(l)];
|
||||
return sum + (typeof raw === "number" ? raw : 0);
|
||||
}, 0);
|
||||
|
||||
const hazValue = hazardousOn && typeof hazardousQty === "number" ? hazardousQty : 0;
|
||||
const reeferValue = reeferOn && typeof reeferQty === "number" ? reeferQty : 0;
|
||||
@@ -153,30 +151,8 @@ export function PlaceOrderDialog({
|
||||
|
||||
function handleSubmit() {
|
||||
if (!scheduledDate) return;
|
||||
|
||||
if (isMultiRoute) {
|
||||
if (!selectedRoute) return;
|
||||
const raw = quantities["__route__"];
|
||||
const qty = typeof raw === "number" ? raw : 0;
|
||||
if (qty <= 0) return;
|
||||
if (!hazReeferValid) return;
|
||||
createMutation.mutate({
|
||||
contractBookingId: contract.id,
|
||||
routeLineId: selectedRoute.routeLineId,
|
||||
scheduledDate: new Date(scheduledDate).toISOString(),
|
||||
lines: [
|
||||
{
|
||||
containerTypeId: isContainer
|
||||
? (selectedRoute.containerTypeId ?? null)
|
||||
: null,
|
||||
quantity: qty,
|
||||
hazardousQuantity: hazValue,
|
||||
reeferQuantity: reeferValue,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Multi-route contracts require a chosen lane (drives scheduling/routing).
|
||||
if (isMultiRoute && !selectedRoute) return;
|
||||
|
||||
const lines: Freight.CreateBookingOrderLineDto[] = pool
|
||||
.map((line) => {
|
||||
@@ -201,19 +177,20 @@ export function PlaceOrderDialog({
|
||||
|
||||
createMutation.mutate({
|
||||
contractBookingId: contract.id,
|
||||
// The lane only routes/schedules the order; quantity comes from the pool.
|
||||
...(isMultiRoute && selectedRoute
|
||||
? { routeLineId: selectedRoute.routeLineId }
|
||||
: {}),
|
||||
scheduledDate: new Date(scheduledDate).toISOString(),
|
||||
lines,
|
||||
});
|
||||
}
|
||||
|
||||
const orderableLines = pool.filter((l) => l.remainingQuantity > 0);
|
||||
const routeQtyRaw = quantities["__route__"];
|
||||
const hasQuantity = isMultiRoute
|
||||
? typeof routeQtyRaw === "number" && routeQtyRaw > 0
|
||||
: pool.some((l) => {
|
||||
const raw = quantities[lineKey(l)];
|
||||
return typeof raw === "number" && raw > 0;
|
||||
});
|
||||
const hasQuantity = pool.some((l) => {
|
||||
const raw = quantities[lineKey(l)];
|
||||
return typeof raw === "number" && raw > 0;
|
||||
});
|
||||
const canSubmit =
|
||||
!!scheduledDate &&
|
||||
hasQuantity &&
|
||||
@@ -221,13 +198,10 @@ export function PlaceOrderDialog({
|
||||
(!isMultiRoute || !!selectedRoute) &&
|
||||
!createMutation.isPending;
|
||||
|
||||
// Routes are pure lanes — the label shows origin → destination only.
|
||||
const routeOptions = routeLines.map((r) => ({
|
||||
value: r.routeLineId,
|
||||
label: `${r.originYardName ?? r.originYardId} → ${r.destinationYardName ?? r.destinationYardId} · ${formatQuantity(
|
||||
r.remainingQuantity,
|
||||
null,
|
||||
isContainer,
|
||||
)} remaining`,
|
||||
label: `${r.originYardName ?? r.originYardId} → ${r.destinationYardName ?? r.destinationYardId}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -285,104 +259,67 @@ export function PlaceOrderDialog({
|
||||
styles={{ input: { height: 44 } }}
|
||||
/>
|
||||
|
||||
{isMultiRoute ? (
|
||||
<Stack gap="sm">
|
||||
<Text fz={13} fw={600} style={{ color: INK }}>
|
||||
Quantity
|
||||
</Text>
|
||||
{!selectedRoute ? (
|
||||
<Text fz={13} c="dimmed">
|
||||
Select a route to draw down from.
|
||||
</Text>
|
||||
) : selectedRoute.remainingQuantity <= 0 ? (
|
||||
<Alert color="gray" radius="md" icon={<AlertCircle size={16} />}>
|
||||
This route is fully drawn down — no quantity remains.
|
||||
</Alert>
|
||||
) : (
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text fz={14} fw={600} style={{ color: INK }}>
|
||||
{selectedRoute.containerTypeName ??
|
||||
(isContainer ? "Containers" : "Tons")}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed">
|
||||
{formatQuantity(
|
||||
selectedRoute.remainingQuantity,
|
||||
null,
|
||||
isContainer,
|
||||
)}{" "}
|
||||
remaining
|
||||
</Text>
|
||||
</div>
|
||||
<NumberInput
|
||||
value={quantities["__route__"] ?? ""}
|
||||
onChange={(v) =>
|
||||
setQuantities({ __route__: v === "" ? "" : Number(v) })
|
||||
}
|
||||
min={0}
|
||||
max={selectedRoute.remainingQuantity}
|
||||
step={isContainer ? 1 : 0.5}
|
||||
clampBehavior="strict"
|
||||
radius="md"
|
||||
w={130}
|
||||
placeholder="0"
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Text fz={13} fw={600} style={{ color: INK }}>
|
||||
Quantity
|
||||
</Text>
|
||||
{orderableLines.length === 0 && (
|
||||
<Alert color="gray" radius="md" icon={<AlertCircle size={16} />}>
|
||||
This contract is fully drawn down — no quantity remains.
|
||||
</Alert>
|
||||
{isMultiRoute && !selectedRoute ? (
|
||||
<Text fz={13} c="dimmed">
|
||||
Select a route first, then enter how much to ship on it.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{orderableLines.length === 0 && (
|
||||
<Alert color="gray" radius="md" icon={<AlertCircle size={16} />}>
|
||||
This contract is fully drawn down — no quantity remains.
|
||||
</Alert>
|
||||
)}
|
||||
{orderableLines.map((line) => {
|
||||
const key = lineKey(line);
|
||||
const label = isContainer
|
||||
? (line.containerTypeName ?? "Containers")
|
||||
: line.unitOfMeasure === "PER_ITEM"
|
||||
? "Items"
|
||||
: "Tons";
|
||||
return (
|
||||
<Group key={key} justify="space-between" wrap="nowrap" gap="md">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text fz={14} fw={600} style={{ color: INK }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed">
|
||||
{formatQuantity(
|
||||
line.remainingQuantity,
|
||||
line.unitOfMeasure,
|
||||
isContainer,
|
||||
)}{" "}
|
||||
remaining
|
||||
</Text>
|
||||
</div>
|
||||
<NumberInput
|
||||
value={quantities[key] ?? ""}
|
||||
onChange={(v) =>
|
||||
setQuantities((prev) => ({
|
||||
...prev,
|
||||
[key]: v === "" ? "" : Number(v),
|
||||
}))
|
||||
}
|
||||
min={0}
|
||||
max={line.remainingQuantity}
|
||||
step={
|
||||
isContainer || line.unitOfMeasure === "PER_ITEM" ? 1 : 0.5
|
||||
}
|
||||
clampBehavior="strict"
|
||||
radius="md"
|
||||
w={130}
|
||||
placeholder="0"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{orderableLines.map((line) => {
|
||||
const key = lineKey(line);
|
||||
const label = isContainer
|
||||
? (line.containerTypeName ?? "Containers")
|
||||
: line.unitOfMeasure === "PER_ITEM"
|
||||
? "Items"
|
||||
: "Tons";
|
||||
return (
|
||||
<Group key={key} justify="space-between" wrap="nowrap" gap="md">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text fz={14} fw={600} style={{ color: INK }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed">
|
||||
{formatQuantity(
|
||||
line.remainingQuantity,
|
||||
line.unitOfMeasure,
|
||||
isContainer,
|
||||
)}{" "}
|
||||
remaining
|
||||
</Text>
|
||||
</div>
|
||||
<NumberInput
|
||||
value={quantities[key] ?? ""}
|
||||
onChange={(v) =>
|
||||
setQuantities((prev) => ({
|
||||
...prev,
|
||||
[key]: v === "" ? "" : Number(v),
|
||||
}))
|
||||
}
|
||||
min={0}
|
||||
max={line.remainingQuantity}
|
||||
step={isContainer || line.unitOfMeasure === "PER_ITEM" ? 1 : 0.5}
|
||||
clampBehavior="strict"
|
||||
radius="md"
|
||||
w={130}
|
||||
placeholder="0"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack gap="sm">
|
||||
<Text fz={13} fw={600} style={{ color: INK }}>
|
||||
|
||||
@@ -65,6 +65,21 @@ export const CONTRACT_STATUS_CONFIG: Record<
|
||||
FULLY_EXECUTED: { label: "Awaiting Payment", color: "#9A6700", bg: "#FFF6E5" },
|
||||
CONTRACT_ACTIVE: { label: "Active", color: "#0A6F4D", bg: "#E7F6EE" },
|
||||
CONTRACT_CLOSED: { label: "Closed", color: "#6B7C8E", bg: "#EEF2F6" },
|
||||
// Drawdown-order statuses, mirrored from the order's child booking as it moves
|
||||
// through the same flow as a one-time booking (clearance → accept → pay →
|
||||
// allocate). Reused by the order badge on ContractDetailPage.
|
||||
PENDING: { label: "Pending", color: "#9A6700", bg: "#FFF6E5" },
|
||||
AWAITING_DOCUMENTS: { label: "Awaiting Documents", color: "#9A6700", bg: "#FFF6E5" },
|
||||
DOCUMENTS_UNDER_REVIEW: { label: "Documents Under Review", color: "#2E5B96", bg: "#EAF1FB" },
|
||||
CLEARANCE_READY: { label: "Clearance Ready", color: "#0A6F4D", bg: "#E7F6EE" },
|
||||
OPERATION_REQUEST_PENDING: { label: "Operation Review", color: "#9A6700", bg: "#FFF6E5" },
|
||||
OPERATION_CHANGES_REQUESTED: { label: "Changes Requested", color: "#9A6700", bg: "#FFF6E5" },
|
||||
OPERATION_PRICE_PENDING_CONFIRM: { label: "Confirm New Price", color: "#9A6700", bg: "#FFF6E5" },
|
||||
ROAD_DISPATCH_PENDING: { label: "Awaiting Dispatch", color: "#9A6700", bg: "#FFF6E5" },
|
||||
SELECTED_FOR_BATCH: { label: "Awaiting Payment", color: "#9A6700", bg: "#FFF6E5" },
|
||||
PAID: { label: "Paid", color: "#0A6F4D", bg: "#E7F6EE" },
|
||||
IN_TRANSIT: { label: "In Transit", color: "#2E5B96", bg: "#EAF1FB" },
|
||||
COMPLETED: { label: "Completed", color: "#0A6F4D", bg: "#E7F6EE" },
|
||||
EXPIRED: { label: "Expired", color: "#B42318", bg: "#FEECEB" },
|
||||
CANCELLED: { label: "Cancelled", color: "#B42318", bg: "#FEECEB" },
|
||||
REJECTED: { label: "Rejected", color: "#B42318", bg: "#FEECEB" },
|
||||
|
||||
Reference in New Issue
Block a user