mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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:
@@ -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}` : ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user