Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile

This commit is contained in:
marshal
2026-06-25 10:09:45 +03:00
17 changed files with 655 additions and 302 deletions

View File

@@ -41,12 +41,54 @@ export class BookingOrdersService {
) {}
/** Orders placed against a contract, with their lines and child booking. */
listByContract(contractBookingId: string): Promise<BookingOrder[]> {
return this.ordersRepository.findByContract(contractBookingId);
async listByContract(contractBookingId: string): Promise<BookingOrder[]> {
const orders = await this.ordersRepository.findByContract(contractBookingId);
await Promise.all(orders.map((o) => this.syncOrderFromChild(o)));
return orders;
}
findById(id: string): Promise<BookingOrder | null> {
return this.ordersRepository.findById(id);
async findById(id: string): Promise<BookingOrder | null> {
const order = await this.ordersRepository.findById(id);
if (order) await this.syncOrderFromChild(order);
return order;
}
/**
* The order is a ledger row; the spawned child ONE_TIME booking is what
* actually moves through the workflow (clearance → marketing/ops accept →
* pay → allocate), exactly like a one-time booking. Nothing writes the order
* row after creation, so its stored status would stay 'PENDING' forever.
*
* Mirror the child onto the order whenever it is read: copy the child's
* status, schedulingStatus and trainScheduleId onto the order (mutating the
* in-memory instance the caller gets back), and persist that snapshot when it
* has drifted so list/detail views and any stored reporting stay in sync.
*/
private async syncOrderFromChild(order: BookingOrder): Promise<void> {
const child = order.booking;
if (!child) return;
const nextStatus = child.status;
const nextScheduling = child.schedulingStatus;
const nextTrainScheduleId = child.trainScheduleId ?? null;
const drifted =
order.status !== nextStatus ||
order.schedulingStatus !== nextScheduling ||
(order.trainScheduleId ?? null) !== nextTrainScheduleId;
// Reflect the child onto the instance returned to the caller.
order.status = nextStatus;
order.schedulingStatus = nextScheduling;
order.trainScheduleId = nextTrainScheduleId;
if (drifted) {
await this.ordersRepository.update(order.id, {
status: nextStatus,
schedulingStatus: nextScheduling,
trainScheduleId: nextTrainScheduleId,
});
}
}
/**
@@ -125,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.
@@ -142,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}` : ''),
);
}
}

View File

@@ -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;
}

View File

@@ -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<Map<string, number>> {
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<string, number>();
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<boolean> {
// 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);
}

View File

@@ -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,
}),
),

View File

@@ -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,

View File

@@ -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>
);
}

View File

@@ -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";

View File

@@ -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) =>

View File

@@ -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,
});
}

View File

@@ -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() {
<Grid gap="lg">
{/* LEFT — primary content, split into tabs to keep each view focused */}
<Grid.Col span={{ base: 12, lg: 8 }}>
{showClearanceTab ? (
{showTabs ? (
<Tabs
value={activeTab}
onChange={setActiveTab}
@@ -216,12 +226,19 @@ export default function BookingRequestDetailPage() {
>
Overview
</Tabs.Tab>
<Tabs.Tab
value="clearance"
leftSection={<ShieldCheck size={16} />}
>
Customer clearance
</Tabs.Tab>
{isGeneralContract && (
<Tabs.Tab value="orders" leftSection={<Layers size={16} />}>
Orders
</Tabs.Tab>
)}
{showClearanceTab && (
<Tabs.Tab
value="clearance"
leftSection={<ShieldCheck size={16} />}
>
Customer clearance
</Tabs.Tab>
)}
</Tabs.List>
<Tabs.Panel value="overview">
@@ -231,12 +248,22 @@ export default function BookingRequestDetailPage() {
onDownload={handleDownloadFile}
/>
</Tabs.Panel>
<Tabs.Panel value="clearance">
<ClearanceReviewSection
bookingId={booking.id}
onChanged={() => refetch()}
/>
</Tabs.Panel>
{isGeneralContract && (
<Tabs.Panel value="orders">
<ContractOrdersPanel
contractBookingId={booking.id}
isContainer={booking.freightType === "CONTAINER"}
/>
</Tabs.Panel>
)}
{showClearanceTab && (
<Tabs.Panel value="clearance">
<ClearanceReviewSection
bookingId={booking.id}
onChanged={() => refetch()}
/>
</Tabs.Panel>
)}
</Tabs>
) : (
<OverviewPanel

View File

@@ -0,0 +1,30 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import type { Freight } from "@edr/types";
/**
* Drawdown orders placed against a general contract (a booking with
* bookingType = GENERAL_CONTRACT). Each order spawns a child ONE_TIME booking
* that carries its own clearance/approval — managed on the child's detail page.
*/
export const bookingOrdersService = {
/** Orders placed against a general contract, with their lines + child status. */
listByContract: async (
contractBookingId: string,
): Promise<Freight.IBookingOrder[]> => {
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<Freight.ContractQuantityLine[]> => {
const response = await client.get(
`/booking-orders/contract/${contractBookingId}/pool`,
);
return unwrap(response.data) as Freight.ContractQuantityLine[];
},
};

View File

@@ -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;

View File

@@ -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 origindestination 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,
})),
],
}

View File

@@ -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 }}>

View File

@@ -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 }}>

View File

@@ -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" },

View File

@@ -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;
}