diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts index da7fce027..3c5bc4019 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts @@ -167,7 +167,6 @@ export class BookingOrdersService { } const isContainer = contract.freightType === 'CONTAINER'; - const orderTotal = dto.lines.reduce((sum, l) => sum + l.quantity, 0); // Hazardous/reefer counts the customer entered cannot exceed the line they // belong to. Validated for every order regardless of routing. @@ -184,43 +183,31 @@ export class BookingOrdersService { } } - if (routeLineId) { - // Multi-route: validate against the chosen route line's remaining pool. - for (const line of dto.lines) { - if (line.quantity <= 0) { - throw new BadRequestException('Order quantities must be greater than zero'); - } + // The contract has a single shared drawdown pool (per container type for + // CONTAINER, or one bulk bucket). Routes are pure lanes — the chosen route + // only fixed origin/destination/km above — so every order, routed or not, + // validates each line against the same shared pool. + const poolLines = await this.generalContractService.getQuantityLines( + contract.id, + ); + for (const line of dto.lines) { + if (line.quantity <= 0) { + throw new BadRequestException('Order quantities must be greater than zero'); } - const chosen = routeLines.find((r) => r.routeLineId === routeLineId)!; - if (orderTotal > chosen.remainingQuantity) { + const key = isContainer ? (line.containerTypeId ?? '') : ''; + const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key); + if (!poolLine) { throw new BadRequestException( - `Requested ${orderTotal} exceeds remaining ${chosen.remainingQuantity} for this route`, + isContainer + ? `Container type ${line.containerTypeId} is not part of this contract` + : 'This contract has no matching quantity pool', ); } - } else { - // Single-route: validate each line against the per-container-type pool. - const poolLines = await this.generalContractService.getQuantityLines( - contract.id, - ); - for (const line of dto.lines) { - if (line.quantity <= 0) { - throw new BadRequestException('Order quantities must be greater than zero'); - } - const key = isContainer ? (line.containerTypeId ?? '') : ''; - const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key); - if (!poolLine) { - throw new BadRequestException( - isContainer - ? `Container type ${line.containerTypeId} is not part of this contract` - : 'This contract has no matching quantity pool', - ); - } - if (line.quantity > poolLine.remainingQuantity) { - throw new BadRequestException( - `Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` + - (poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''), - ); - } + if (line.quantity > poolLine.remainingQuantity) { + throw new BadRequestException( + `Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` + + (poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''), + ); } } diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts index 3c85ad31e..bfdcc72b8 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts @@ -22,7 +22,13 @@ export class ContractQuantityLineView { remainingQuantity!: number; } -/** A contracted/ordered/remaining pool line for one route of a general contract. */ +/** + * A contracted route (lane) of a general contract. Routes are pure + * origin→destination lanes the contract covers; they carry NO quantity. The + * contract has a single shared drawdown pool (see {@link ContractQuantityLineView}), + * and an order picks one lane (for scheduling/billing) while drawing from that + * shared pool. + */ export class ContractRouteLineView { @ApiProperty({ description: 'Contract route line id' }) routeLineId!: string; @@ -39,21 +45,6 @@ export class ContractRouteLineView { @ApiProperty({ nullable: true }) destinationYardName!: string | null; - @ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' }) - containerTypeId!: string | null; - - @ApiProperty({ nullable: true }) - containerTypeName!: string | null; - - @ApiProperty() - contractedQuantity!: number; - - @ApiProperty() - orderedQuantity!: number; - - @ApiProperty() - remainingQuantity!: number; - @ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' }) km!: number | null; } diff --git a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts index 4cf5dfe59..58122bd65 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts @@ -125,10 +125,12 @@ export class GeneralContractService { } /** - * Per-route drawdown pool for a multi-route general contract: contracted vs. - * ordered vs. remaining, one entry per contracted route line. Returns [] for - * single-route contracts (no route lines) — callers fall back to - * {@link getQuantityLines}. + * The contracted routes (lanes) of a multi-route general contract — pure + * origin→destination pairs the contract covers. Routes carry NO quantity; the + * contract draws from a single shared pool ({@link getQuantityLines}). An order + * picks one lane (for scheduling + road billing) and draws from that pool. + * Returns [] for single-route contracts (no route lines) — callers then use the + * contract's own origin/destination. */ async getRouteLines( contractBookingId: string, @@ -140,52 +142,18 @@ export class GeneralContractService { relations: { originYard: true, destinationYard: true, - containerType: true, }, order: { createdAt: 'ASC' }, }); - if (routeLines.length === 0) return []; - const ordered = await this.orderedByRouteLine(contractBookingId); - - return routeLines.map((rl) => { - const orderedQty = ordered.get(rl.id) ?? 0; - const contracted = Number(rl.quantity); - return { - routeLineId: rl.id, - originYardId: rl.originYardId, - originYardName: rl.originYard?.label ?? null, - destinationYardId: rl.destinationYardId, - destinationYardName: rl.destinationYard?.label ?? null, - containerTypeId: rl.containerTypeId ?? null, - containerTypeName: rl.containerType?.label ?? null, - contractedQuantity: contracted, - orderedQuantity: orderedQty, - remainingQuantity: Math.max(0, contracted - orderedQty), - km: rl.km != null ? Number(rl.km) : null, - }; - }); - } - - /** Sum of non-cancelled order quantities, keyed by route_line_id. */ - private async orderedByRouteLine( - contractBookingId: string, - ): Promise> { - const rows = await this.dataSource - .getRepository(BookingOrder) - .createQueryBuilder('o') - .innerJoin('o.lines', 'line') - .select('o.route_line_id', 'key') - .addSelect('SUM(line.quantity)', 'total') - .where('o.contract_booking_id = :contractBookingId', { contractBookingId }) - .andWhere('o.route_line_id IS NOT NULL') - .andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`) - .groupBy('o.route_line_id') - .getRawMany<{ key: string; total: string }>(); - - const map = new Map(); - for (const row of rows) if (row.key) map.set(row.key, Number(row.total)); - return map; + return routeLines.map((rl) => ({ + routeLineId: rl.id, + originYardId: rl.originYardId, + originYardName: rl.originYard?.label ?? null, + destinationYardId: rl.destinationYardId, + destinationYardName: rl.destinationYard?.label ?? null, + km: rl.km != null ? Number(rl.km) : null, + })); } /** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */ @@ -221,14 +189,12 @@ export class GeneralContractService { return line?.remainingQuantity ?? 0; } - /** True once every contracted line is fully drawn down. */ + /** + * True once the contract's shared pool is fully drawn down. Routes are pure + * lanes with no quantity, so exhaustion is purely a function of the shared + * per-container-type (or bulk) pool, regardless of how many routes exist. + */ async isExhausted(contractBookingId: string): Promise { - // Multi-route contracts are exhausted when every route line is drawn down; - // single-route contracts fall back to the per-container-type pool. - const routeLines = await this.getRouteLines(contractBookingId); - if (routeLines.length > 0) { - return routeLines.every((l) => l.remainingQuantity <= 0); - } const lines = await this.getQuantityLines(contractBookingId); return lines.every((l) => l.remainingQuantity <= 0); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 19994d6a8..92ed5d689 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -485,8 +485,11 @@ export class BookingsService { warnings.push(`Estimated wagons required: ${wagonCount}`); } - // Multi-route general contracts: persist the contracted routes + quantities. - // Each drawdown order later draws from one of these route lines. + // Multi-route general contracts: persist the contracted routes (lanes). Routes + // carry NO quantity — the contract has a single shared pool (the cargo-step + // total / container quantities). Each drawdown order picks one lane for + // scheduling + road billing and draws from that shared pool. `quantity` on the + // route line is retained for legacy rows but is no longer meaningful (0). if (isGeneralContract && dto.routes?.length) { const routeRepo = this.dataSource.getRepository(ContractRouteLine); await routeRepo.save( @@ -495,9 +498,8 @@ export class BookingsService { contractBookingId: booking.id, originYardId: r.originYardId, destinationYardId: r.destinationYardId, - containerTypeId: - dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null, - quantity: r.quantity, + containerTypeId: null, + quantity: 0, km: r.km ?? null, }), ), diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index ee5faa465..677ef03fd 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -55,6 +55,12 @@ export class CreateBookingContainerDto { vgmPerUnitTons!: number; } +/** + * A contracted route (lane) of a general contract — a pure origin→destination + * pair the contract covers. Routes carry NO quantity; the contract draws from a + * single shared pool (the container quantities / bulk total on the booking). An + * order picks one lane (for scheduling + road billing) and draws from that pool. + */ export class CreateContractRouteDto { @ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' }) @IsUUID() @@ -64,20 +70,6 @@ export class CreateContractRouteDto { @IsUUID() destinationYardId!: string; - @ApiPropertyOptional({ - format: 'uuid', - description: 'Container type for CONTAINER contracts; omit for BULK', - }) - @IsOptional() - @IsUUID() - containerTypeId?: string; - - @ApiProperty({ description: 'Contracted quantity for this route', minimum: 1 }) - @IsNumber() - @Min(0) - @Transform(({ value }) => Number(value)) - quantity!: number; - @ApiPropertyOptional({ description: 'Road distance (km) for this route; used to bill road orders.', minimum: 0, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ContractOrdersPanel.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ContractOrdersPanel.tsx new file mode 100644 index 000000000..09b9ecbc0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ContractOrdersPanel.tsx @@ -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 ( +
+ +
+ ); + } + + return ( + + {/* Drawdown pool */} + + + + + Contracted quantity + + + How much of this contract has been ordered versus what remains. + + + {totals.contracted > 0 && ( + + {totals.pct}% + + } + /> + )} + + + {poolLines.length === 0 && ( + + No quantity pool available. + + )} + {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 ( +
+ + + + {label} + + {depleted && ( + + Fully ordered + + )} + + + + {formatQuantity( + line.remainingQuantity, + line.unitOfMeasure, + isContainer, + )} + {" "} + remaining of{" "} + {formatQuantity( + line.contractedQuantity, + line.unitOfMeasure, + isContainer, + )} + + + +
+ ); + })} +
+
+ + {/* Orders */} + + + + Orders + + + {orders?.length ?? 0} + + + {!orders || orders.length === 0 ? ( + + + + + + No orders have been placed against this contract yet. + + + ) : ( + + {orders.map((order) => { + const childId = order.bookingId; + const clickable = Boolean(childId); + return ( + + navigate(`/dashboard/booking-requests/${childId}`) + : undefined + } + > + + + + + + + {order.reference} + + + Ship{" "} + {new Date(order.scheduledDate).toLocaleDateString()} + {order.lines.length > 0 + ? ` · ${summariseLines(order.lines)}` + : ""} + + + + + + {clickable && ( + + )} + + + ); + })} + + )} + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index c9bbff7ef..a023fafda 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -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"; diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 8e8b9f94c..7fb2d1398 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -45,6 +45,14 @@ export const QUERY_KEYS = { byId: (id: string) => ["bookings", "detail", id] as const, }, + BOOKING_ORDERS: { + ROOT: ["booking-orders"] as const, + byContract: (contractBookingId: string) => + ["booking-orders", "by-contract", contractBookingId] as const, + pool: (contractBookingId: string) => + ["booking-orders", "pool", contractBookingId] as const, + }, + TRAIN_SCHEDULING: { ROOT: ["train-scheduling"] as const, eligible: (freightType?: string, filters?: TrainScheduleFilters) => diff --git a/apps/edr-freight-web/backoffice/src/hooks/bookings/useContractOrders.ts b/apps/edr-freight-web/backoffice/src/hooks/bookings/useContractOrders.ts new file mode 100644 index 000000000..9d2c06b10 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/bookings/useContractOrders.ts @@ -0,0 +1,28 @@ +import { useQuery } from "@tanstack/react-query"; + +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { bookingOrdersService } from "@/services/booking-orders.service"; + +/** Orders placed against a general contract (id = the contract booking id). */ +export function useContractOrders( + contractBookingId: string | undefined, + enabled = true, +) { + return useQuery({ + queryKey: QUERY_KEYS.BOOKING_ORDERS.byContract(contractBookingId ?? ""), + queryFn: () => bookingOrdersService.listByContract(contractBookingId!), + enabled: Boolean(contractBookingId) && enabled, + }); +} + +/** Contracted / ordered / remaining drawdown pool for a general contract. */ +export function useContractPool( + contractBookingId: string | undefined, + enabled = true, +) { + return useQuery({ + queryKey: QUERY_KEYS.BOOKING_ORDERS.pool(contractBookingId ?? ""), + queryFn: () => bookingOrdersService.pool(contractBookingId!), + enabled: Boolean(contractBookingId) && enabled, + }); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index 2a7198c9b..bd8ed9a98 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -2,6 +2,7 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { ArrowLeft, FileSignature, + Layers, LayoutGrid, Package, ShieldCheck, @@ -36,6 +37,7 @@ import { BookingContractSummaryCard, BookingDocumentsCard, ClearanceReviewSection, + ContractOrdersPanel, type BookingFileView, } from "@/components/bookings/detail"; import { WarehouseInfoCard } from "@/components/warehouses"; @@ -159,9 +161,17 @@ export default function BookingRequestDetailPage() { ["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"].includes( booking.status, ); + // A general contract drives an "Orders" tab: each drawdown order spawns a + // child booking that staff manage (clearance/approval) independently. + const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT"; + const showTabs = showClearanceTab || isGeneralContract; const requestedTab = searchParams.get("tab"); const activeTab = - requestedTab === "clearance" && showClearanceTab ? "clearance" : "overview"; + requestedTab === "clearance" && showClearanceTab + ? "clearance" + : requestedTab === "orders" && isGeneralContract + ? "orders" + : "overview"; const setActiveTab = (tab: string | null) => { const next = new URLSearchParams(searchParams); if (tab && tab !== "overview") next.set("tab", tab); @@ -201,7 +211,7 @@ export default function BookingRequestDetailPage() { {/* LEFT — primary content, split into tabs to keep each view focused */} - {showClearanceTab ? ( + {showTabs ? ( Overview - } - > - Customer clearance - + {isGeneralContract && ( + }> + Orders + + )} + {showClearanceTab && ( + } + > + Customer clearance + + )} @@ -231,12 +248,22 @@ export default function BookingRequestDetailPage() { onDownload={handleDownloadFile} /> - - refetch()} - /> - + {isGeneralContract && ( + + + + )} + {showClearanceTab && ( + + refetch()} + /> + + )} ) : ( => { + const response = await client.get("/booking-orders", { + params: { contractBookingId }, + }); + return unwrap(response.data) as Freight.IBookingOrder[]; + }, + + /** Contracted / ordered / remaining quantities for a general contract. */ + pool: async ( + contractBookingId: string, + ): Promise => { + const response = await client.get( + `/booking-orders/contract/${contractBookingId}/pool`, + ); + return unwrap(response.data) as Freight.ContractQuantityLine[]; + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 53a63b34d..77521de64 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -132,6 +132,10 @@ export interface BookingDetail { isGovernment?: boolean; governmentInstitution?: string | null; status: BookingStatus; + /** ONE_TIME shipment vs an umbrella GENERAL_CONTRACT drawn down by orders. */ + bookingType?: "ONE_TIME" | "GENERAL_CONTRACT"; + /** General contracts only: when the ordering window closes. */ + expiresAt?: string | null; scheduledDate: string; totalAmount: number; adjustedTotalAmount?: number | null; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 4061a6a89..89ee0edbd 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -498,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, })), ], } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 42b577327..b921826bc 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -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() { )} + {/* 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 && ( + + + + Contracted routes + + + {routeLines.length} + + + + Lanes this contract covers. Orders draw from the shared pool below — + pick a lane per order for scheduling and routing. + + + {routeLines.map((route) => ( + + + + + + + + {route.originYardName ?? route.originYardId} →{" "} + {route.destinationYardName ?? route.destinationYardId} + + {route.km != null && ( + + {route.km} km + + )} + + + + ))} + + + )} + {/* Drawdown pool */} {showPool && ( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx b/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx index cfcd7d176..a2864d2c3 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx @@ -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 ? ( - - - Quantity - - {!selectedRoute ? ( - - Select a route to draw down from. - - ) : selectedRoute.remainingQuantity <= 0 ? ( - }> - This route is fully drawn down — no quantity remains. - - ) : ( - -
- - {selectedRoute.containerTypeName ?? - (isContainer ? "Containers" : "Tons")} - - - {formatQuantity( - selectedRoute.remainingQuantity, - null, - isContainer, - )}{" "} - remaining - -
- - setQuantities({ __route__: v === "" ? "" : Number(v) }) - } - min={0} - max={selectedRoute.remainingQuantity} - step={isContainer ? 1 : 0.5} - clampBehavior="strict" - radius="md" - w={130} - placeholder="0" - /> -
- )} -
- ) : ( Quantity - {orderableLines.length === 0 && ( - }> - This contract is fully drawn down — no quantity remains. - + {isMultiRoute && !selectedRoute ? ( + + Select a route first, then enter how much to ship on it. + + ) : ( + <> + {orderableLines.length === 0 && ( + }> + This contract is fully drawn down — no quantity remains. + + )} + {orderableLines.map((line) => { + const key = lineKey(line); + const label = isContainer + ? (line.containerTypeName ?? "Containers") + : line.unitOfMeasure === "PER_ITEM" + ? "Items" + : "Tons"; + return ( + +
+ + {label} + + + {formatQuantity( + line.remainingQuantity, + line.unitOfMeasure, + isContainer, + )}{" "} + remaining + +
+ + 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" + /> +
+ ); + })} + )} - {orderableLines.map((line) => { - const key = lineKey(line); - const label = isContainer - ? (line.containerTypeName ?? "Containers") - : line.unitOfMeasure === "PER_ITEM" - ? "Items" - : "Tons"; - return ( - -
- - {label} - - - {formatQuantity( - line.remainingQuantity, - line.unitOfMeasure, - isContainer, - )}{" "} - remaining - -
- - 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" - /> -
- ); - })}
- )} diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 16a630c3a..d9b6d8055 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -631,11 +631,15 @@ export interface CreateBookingContainerDto { } /** A contracted route+quantity line for a GENERAL contract. */ +/** + * A contracted route (lane) of a general contract — a pure origin→destination + * pair. Routes carry NO quantity; the contract draws from a single shared pool. + */ export interface CreateContractRouteDto { originYardId: string; destinationYardId: string; - containerTypeId?: string | undefined; - quantity: number; + /** Road distance (km) for this route; used to bill road orders. */ + km?: number | undefined; } export interface CreateBookingDto { @@ -728,17 +732,18 @@ export interface CreateBookingOrderLineDto { } /** Per-route contracted / ordered / remaining pool line (multi-route contracts). */ +/** + * A contracted route (lane) of a general contract — a pure origin→destination + * pair the contract covers. Routes carry NO quantity; the contract draws from a + * single shared pool ({@link ContractQuantityLine}). An order picks one lane (for + * scheduling + road billing) and draws from that shared pool. + */ export interface ContractRouteLine { routeLineId: string; originYardId: string; originYardName?: string | null; destinationYardId: string; destinationYardName?: string | null; - containerTypeId?: string | null; - containerTypeName?: string | null; - contractedQuantity: number; - orderedQuantity: number; - remainingQuantity: number; /** Road distance (km) for this route; used to bill road orders. Null for rail-only. */ km?: number | null; }