mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile
This commit is contained in:
@@ -41,12 +41,54 @@ export class BookingOrdersService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Orders placed against a contract, with their lines and child booking. */
|
/** Orders placed against a contract, with their lines and child booking. */
|
||||||
listByContract(contractBookingId: string): Promise<BookingOrder[]> {
|
async listByContract(contractBookingId: string): Promise<BookingOrder[]> {
|
||||||
return this.ordersRepository.findByContract(contractBookingId);
|
const orders = await this.ordersRepository.findByContract(contractBookingId);
|
||||||
|
await Promise.all(orders.map((o) => this.syncOrderFromChild(o)));
|
||||||
|
return orders;
|
||||||
}
|
}
|
||||||
|
|
||||||
findById(id: string): Promise<BookingOrder | null> {
|
async findById(id: string): Promise<BookingOrder | null> {
|
||||||
return this.ordersRepository.findById(id);
|
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 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
|
// Hazardous/reefer counts the customer entered cannot exceed the line they
|
||||||
// belong to. Validated for every order regardless of routing.
|
// belong to. Validated for every order regardless of routing.
|
||||||
@@ -142,43 +183,31 @@ export class BookingOrdersService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (routeLineId) {
|
// The contract has a single shared drawdown pool (per container type for
|
||||||
// Multi-route: validate against the chosen route line's remaining pool.
|
// CONTAINER, or one bulk bucket). Routes are pure lanes — the chosen route
|
||||||
for (const line of dto.lines) {
|
// only fixed origin/destination/km above — so every order, routed or not,
|
||||||
if (line.quantity <= 0) {
|
// validates each line against the same shared pool.
|
||||||
throw new BadRequestException('Order quantities must be greater than zero');
|
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)!;
|
const key = isContainer ? (line.containerTypeId ?? '') : '';
|
||||||
if (orderTotal > chosen.remainingQuantity) {
|
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
|
||||||
|
if (!poolLine) {
|
||||||
throw new BadRequestException(
|
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 {
|
if (line.quantity > poolLine.remainingQuantity) {
|
||||||
// Single-route: validate each line against the per-container-type pool.
|
throw new BadRequestException(
|
||||||
const poolLines = await this.generalContractService.getQuantityLines(
|
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
|
||||||
contract.id,
|
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
|
||||||
);
|
);
|
||||||
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}` : ''),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,13 @@ export class ContractQuantityLineView {
|
|||||||
remainingQuantity!: number;
|
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 {
|
export class ContractRouteLineView {
|
||||||
@ApiProperty({ description: 'Contract route line id' })
|
@ApiProperty({ description: 'Contract route line id' })
|
||||||
routeLineId!: string;
|
routeLineId!: string;
|
||||||
@@ -39,21 +45,6 @@ export class ContractRouteLineView {
|
|||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
destinationYardName!: string | null;
|
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' })
|
@ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' })
|
||||||
km!: number | null;
|
km!: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,10 +125,12 @@ export class GeneralContractService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-route drawdown pool for a multi-route general contract: contracted vs.
|
* The contracted routes (lanes) of a multi-route general contract — pure
|
||||||
* ordered vs. remaining, one entry per contracted route line. Returns [] for
|
* origin→destination pairs the contract covers. Routes carry NO quantity; the
|
||||||
* single-route contracts (no route lines) — callers fall back to
|
* contract draws from a single shared pool ({@link getQuantityLines}). An order
|
||||||
* {@link getQuantityLines}.
|
* 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(
|
async getRouteLines(
|
||||||
contractBookingId: string,
|
contractBookingId: string,
|
||||||
@@ -140,52 +142,18 @@ export class GeneralContractService {
|
|||||||
relations: {
|
relations: {
|
||||||
originYard: true,
|
originYard: true,
|
||||||
destinationYard: true,
|
destinationYard: true,
|
||||||
containerType: true,
|
|
||||||
},
|
},
|
||||||
order: { createdAt: 'ASC' },
|
order: { createdAt: 'ASC' },
|
||||||
});
|
});
|
||||||
if (routeLines.length === 0) return [];
|
|
||||||
|
|
||||||
const ordered = await this.orderedByRouteLine(contractBookingId);
|
return routeLines.map((rl) => ({
|
||||||
|
routeLineId: rl.id,
|
||||||
return routeLines.map((rl) => {
|
originYardId: rl.originYardId,
|
||||||
const orderedQty = ordered.get(rl.id) ?? 0;
|
originYardName: rl.originYard?.label ?? null,
|
||||||
const contracted = Number(rl.quantity);
|
destinationYardId: rl.destinationYardId,
|
||||||
return {
|
destinationYardName: rl.destinationYard?.label ?? null,
|
||||||
routeLineId: rl.id,
|
km: rl.km != null ? Number(rl.km) : null,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
|
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
|
||||||
@@ -221,14 +189,12 @@ export class GeneralContractService {
|
|||||||
return line?.remainingQuantity ?? 0;
|
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> {
|
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);
|
const lines = await this.getQuantityLines(contractBookingId);
|
||||||
return lines.every((l) => l.remainingQuantity <= 0);
|
return lines.every((l) => l.remainingQuantity <= 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -485,8 +485,11 @@ export class BookingsService {
|
|||||||
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi-route general contracts: persist the contracted routes + quantities.
|
// Multi-route general contracts: persist the contracted routes (lanes). Routes
|
||||||
// Each drawdown order later draws from one of these route lines.
|
// 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) {
|
if (isGeneralContract && dto.routes?.length) {
|
||||||
const routeRepo = this.dataSource.getRepository(ContractRouteLine);
|
const routeRepo = this.dataSource.getRepository(ContractRouteLine);
|
||||||
await routeRepo.save(
|
await routeRepo.save(
|
||||||
@@ -495,9 +498,8 @@ export class BookingsService {
|
|||||||
contractBookingId: booking.id,
|
contractBookingId: booking.id,
|
||||||
originYardId: r.originYardId,
|
originYardId: r.originYardId,
|
||||||
destinationYardId: r.destinationYardId,
|
destinationYardId: r.destinationYardId,
|
||||||
containerTypeId:
|
containerTypeId: null,
|
||||||
dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null,
|
quantity: 0,
|
||||||
quantity: r.quantity,
|
|
||||||
km: r.km ?? null,
|
km: r.km ?? null,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ export class CreateBookingContainerDto {
|
|||||||
vgmPerUnitTons!: number;
|
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 {
|
export class CreateContractRouteDto {
|
||||||
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
|
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
@@ -64,20 +70,6 @@ export class CreateContractRouteDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
destinationYardId!: string;
|
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({
|
@ApiPropertyOptional({
|
||||||
description: 'Road distance (km) for this route; used to bill road orders.',
|
description: 'Road distance (km) for this route; used to bill road orders.',
|
||||||
minimum: 0,
|
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 "./booking-detail.styles";
|
||||||
export * from "./SectionCard";
|
export * from "./SectionCard";
|
||||||
export * from "./ClearanceReviewSection";
|
export * from "./ClearanceReviewSection";
|
||||||
|
export * from "./ContractOrdersPanel";
|
||||||
export * from "./MetricTile";
|
export * from "./MetricTile";
|
||||||
export * from "./BookingDetailToolbar";
|
export * from "./BookingDetailToolbar";
|
||||||
export * from "./BookingDetailHeader";
|
export * from "./BookingDetailHeader";
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ export const QUERY_KEYS = {
|
|||||||
byId: (id: string) => ["bookings", "detail", id] as const,
|
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: {
|
TRAIN_SCHEDULING: {
|
||||||
ROOT: ["train-scheduling"] as const,
|
ROOT: ["train-scheduling"] as const,
|
||||||
eligible: (freightType?: string, filters?: TrainScheduleFilters) =>
|
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 {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
|
Layers,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Package,
|
Package,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
BookingContractSummaryCard,
|
BookingContractSummaryCard,
|
||||||
BookingDocumentsCard,
|
BookingDocumentsCard,
|
||||||
ClearanceReviewSection,
|
ClearanceReviewSection,
|
||||||
|
ContractOrdersPanel,
|
||||||
type BookingFileView,
|
type BookingFileView,
|
||||||
} from "@/components/bookings/detail";
|
} from "@/components/bookings/detail";
|
||||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||||
@@ -159,9 +161,17 @@ export default function BookingRequestDetailPage() {
|
|||||||
["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"].includes(
|
["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"].includes(
|
||||||
booking.status,
|
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 requestedTab = searchParams.get("tab");
|
||||||
const activeTab =
|
const activeTab =
|
||||||
requestedTab === "clearance" && showClearanceTab ? "clearance" : "overview";
|
requestedTab === "clearance" && showClearanceTab
|
||||||
|
? "clearance"
|
||||||
|
: requestedTab === "orders" && isGeneralContract
|
||||||
|
? "orders"
|
||||||
|
: "overview";
|
||||||
const setActiveTab = (tab: string | null) => {
|
const setActiveTab = (tab: string | null) => {
|
||||||
const next = new URLSearchParams(searchParams);
|
const next = new URLSearchParams(searchParams);
|
||||||
if (tab && tab !== "overview") next.set("tab", tab);
|
if (tab && tab !== "overview") next.set("tab", tab);
|
||||||
@@ -201,7 +211,7 @@ export default function BookingRequestDetailPage() {
|
|||||||
<Grid gap="lg">
|
<Grid gap="lg">
|
||||||
{/* LEFT — primary content, split into tabs to keep each view focused */}
|
{/* LEFT — primary content, split into tabs to keep each view focused */}
|
||||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
{showClearanceTab ? (
|
{showTabs ? (
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onChange={setActiveTab}
|
onChange={setActiveTab}
|
||||||
@@ -216,12 +226,19 @@ export default function BookingRequestDetailPage() {
|
|||||||
>
|
>
|
||||||
Overview
|
Overview
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
<Tabs.Tab
|
{isGeneralContract && (
|
||||||
value="clearance"
|
<Tabs.Tab value="orders" leftSection={<Layers size={16} />}>
|
||||||
leftSection={<ShieldCheck size={16} />}
|
Orders
|
||||||
>
|
</Tabs.Tab>
|
||||||
Customer clearance
|
)}
|
||||||
</Tabs.Tab>
|
{showClearanceTab && (
|
||||||
|
<Tabs.Tab
|
||||||
|
value="clearance"
|
||||||
|
leftSection={<ShieldCheck size={16} />}
|
||||||
|
>
|
||||||
|
Customer clearance
|
||||||
|
</Tabs.Tab>
|
||||||
|
)}
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="overview">
|
<Tabs.Panel value="overview">
|
||||||
@@ -231,12 +248,22 @@ export default function BookingRequestDetailPage() {
|
|||||||
onDownload={handleDownloadFile}
|
onDownload={handleDownloadFile}
|
||||||
/>
|
/>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="clearance">
|
{isGeneralContract && (
|
||||||
<ClearanceReviewSection
|
<Tabs.Panel value="orders">
|
||||||
bookingId={booking.id}
|
<ContractOrdersPanel
|
||||||
onChanged={() => refetch()}
|
contractBookingId={booking.id}
|
||||||
/>
|
isContainer={booking.freightType === "CONTAINER"}
|
||||||
</Tabs.Panel>
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
)}
|
||||||
|
{showClearanceTab && (
|
||||||
|
<Tabs.Panel value="clearance">
|
||||||
|
<ClearanceReviewSection
|
||||||
|
bookingId={booking.id}
|
||||||
|
onChanged={() => refetch()}
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : (
|
) : (
|
||||||
<OverviewPanel
|
<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;
|
isGovernment?: boolean;
|
||||||
governmentInstitution?: string | null;
|
governmentInstitution?: string | null;
|
||||||
status: BookingStatus;
|
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;
|
scheduledDate: string;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
adjustedTotalAmount?: number | null;
|
adjustedTotalAmount?: number | null;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
getRouteDirection,
|
getRouteDirection,
|
||||||
initialBookingFormValues,
|
initialBookingFormValues,
|
||||||
operationToProfileType,
|
operationToProfileType,
|
||||||
|
operationToTradeDirection,
|
||||||
stepFields,
|
stepFields,
|
||||||
type BookingDocuments,
|
type BookingDocuments,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
@@ -271,6 +272,7 @@ export default function NewBookingPage() {
|
|||||||
|
|
||||||
const originYard = form.watch("originYard");
|
const originYard = form.watch("originYard");
|
||||||
const destinationYard = form.watch("destinationYard");
|
const destinationYard = form.watch("destinationYard");
|
||||||
|
const operationType = form.watch("operationType");
|
||||||
|
|
||||||
// The estimated shipment date lives in the Route step now; for general
|
// 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
|
// 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,
|
(y) => y.id === destinationYard,
|
||||||
);
|
);
|
||||||
|
|
||||||
const route = getRouteDirection(origin, destination);
|
// Country-based derivation is authoritative when both yards are tagged, but
|
||||||
return route;
|
// returns null if a yard is unselected or lacks a country (e.g. intercity
|
||||||
}, [originYard, destinationYard]);
|
// 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
|
// The company's onboarded profile types — drives which operations are offered
|
||||||
// and which profile each operation stamps the booking to.
|
// and which profile each operation stamps the booking to.
|
||||||
@@ -489,30 +498,23 @@ export default function NewBookingPage() {
|
|||||||
}
|
}
|
||||||
: { customsClearingEnabled: false }),
|
: { customsClearingEnabled: false }),
|
||||||
...(cargoFreeText ? { cargoFreeText } : {}),
|
...(cargoFreeText ? { cargoFreeText } : {}),
|
||||||
// Multi-route general contracts: route #1 is the primary origin/destination
|
// Multi-route general contracts: routes are pure origin→destination lanes
|
||||||
// carrying the full contracted quantity (from the cargo step). Extra routes
|
// the contract covers — they carry NO quantity. Route #1 is the primary
|
||||||
// are just additional origin/destination pairs the contract covers — no
|
// origin/destination; the rest come from the extra-routes step. The
|
||||||
// per-route quantity is collected, so they are sent with quantity 0.
|
// contracted quantity lives in a single shared pool (the container
|
||||||
|
// quantities / bulk total), drawn down per order against a chosen lane.
|
||||||
...(isContract
|
...(isContract
|
||||||
? {
|
? {
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
originYardId: data.originYard,
|
originYardId: data.originYard,
|
||||||
destinationYardId: data.destinationYard,
|
destinationYardId: data.destinationYard,
|
||||||
quantity:
|
|
||||||
data.cargoType === "container"
|
|
||||||
? data.containers.reduce(
|
|
||||||
(sum, c) => sum + Number(c.qty || 0),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
: totalWeight,
|
|
||||||
},
|
},
|
||||||
...(data.extraRoutes ?? [])
|
...(data.extraRoutes ?? [])
|
||||||
.filter((r) => r.originYard && r.destinationYard)
|
.filter((r) => r.originYard && r.destinationYard)
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
originYardId: r.originYard,
|
originYardId: r.originYard,
|
||||||
destinationYardId: r.destinationYard,
|
destinationYardId: r.destinationYard,
|
||||||
quantity: 0,
|
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,15 @@ export default function ContractDetailPage() {
|
|||||||
enabled: !!id && contract?.status !== "DRAFT",
|
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 ?? [];
|
const poolLines = pool ?? [];
|
||||||
|
|
||||||
// Overall utilization across every pool line — drives the header ring + stat.
|
// Overall utilization across every pool line — drives the header ring + stat.
|
||||||
@@ -220,6 +229,53 @@ export default function ContractDetailPage() {
|
|||||||
</Group>
|
</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 */}
|
{/* Drawdown pool */}
|
||||||
{showPool && (
|
{showPool && (
|
||||||
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||||
|
|||||||
@@ -59,9 +59,10 @@ export function PlaceOrderDialog({
|
|||||||
const isMultiRoute = routeLines.length > 0;
|
const isMultiRoute = routeLines.length > 0;
|
||||||
const selectedRoute = routeLines.find((r) => r.routeLineId === routeLineId);
|
const selectedRoute = routeLines.find((r) => r.routeLineId === routeLineId);
|
||||||
|
|
||||||
// The route the order ships on drives both the available-days query and the
|
// The route the order ships on drives ONLY the available-days (schedule) query:
|
||||||
// remaining-quantity check: the chosen route line for multi-route contracts,
|
// the chosen lane for multi-route contracts, else the contract's own
|
||||||
// else the contract's own origin/destination.
|
// origin/destination. Quantity is always drawn from the shared pool below —
|
||||||
|
// routes are pure lanes and carry no quantity.
|
||||||
const originYardId = isMultiRoute
|
const originYardId = isMultiRoute
|
||||||
? selectedRoute?.originYardId
|
? selectedRoute?.originYardId
|
||||||
: contract.originYard?.id;
|
: contract.originYard?.id;
|
||||||
@@ -129,15 +130,12 @@ export function PlaceOrderDialog({
|
|||||||
setReeferQty("");
|
setReeferQty("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Total quantity across the order; haz/reefer counts cannot exceed it.
|
// Total quantity across the order; haz/reefer counts cannot exceed it. Always
|
||||||
const orderTotalQty = isMultiRoute
|
// summed from the shared pool lines, regardless of routing.
|
||||||
? typeof quantities["__route__"] === "number"
|
const orderTotalQty = pool.reduce((sum, l) => {
|
||||||
? (quantities["__route__"] as number)
|
const raw = quantities[lineKey(l)];
|
||||||
: 0
|
return sum + (typeof raw === "number" ? raw : 0);
|
||||||
: pool.reduce((sum, l) => {
|
}, 0);
|
||||||
const raw = quantities[lineKey(l)];
|
|
||||||
return sum + (typeof raw === "number" ? raw : 0);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const hazValue = hazardousOn && typeof hazardousQty === "number" ? hazardousQty : 0;
|
const hazValue = hazardousOn && typeof hazardousQty === "number" ? hazardousQty : 0;
|
||||||
const reeferValue = reeferOn && typeof reeferQty === "number" ? reeferQty : 0;
|
const reeferValue = reeferOn && typeof reeferQty === "number" ? reeferQty : 0;
|
||||||
@@ -153,30 +151,8 @@ export function PlaceOrderDialog({
|
|||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (!scheduledDate) return;
|
if (!scheduledDate) return;
|
||||||
|
// Multi-route contracts require a chosen lane (drives scheduling/routing).
|
||||||
if (isMultiRoute) {
|
if (isMultiRoute && !selectedRoute) return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: Freight.CreateBookingOrderLineDto[] = pool
|
const lines: Freight.CreateBookingOrderLineDto[] = pool
|
||||||
.map((line) => {
|
.map((line) => {
|
||||||
@@ -201,19 +177,20 @@ export function PlaceOrderDialog({
|
|||||||
|
|
||||||
createMutation.mutate({
|
createMutation.mutate({
|
||||||
contractBookingId: contract.id,
|
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(),
|
scheduledDate: new Date(scheduledDate).toISOString(),
|
||||||
lines,
|
lines,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderableLines = pool.filter((l) => l.remainingQuantity > 0);
|
const orderableLines = pool.filter((l) => l.remainingQuantity > 0);
|
||||||
const routeQtyRaw = quantities["__route__"];
|
const hasQuantity = pool.some((l) => {
|
||||||
const hasQuantity = isMultiRoute
|
const raw = quantities[lineKey(l)];
|
||||||
? typeof routeQtyRaw === "number" && routeQtyRaw > 0
|
return typeof raw === "number" && raw > 0;
|
||||||
: pool.some((l) => {
|
});
|
||||||
const raw = quantities[lineKey(l)];
|
|
||||||
return typeof raw === "number" && raw > 0;
|
|
||||||
});
|
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
!!scheduledDate &&
|
!!scheduledDate &&
|
||||||
hasQuantity &&
|
hasQuantity &&
|
||||||
@@ -221,13 +198,10 @@ export function PlaceOrderDialog({
|
|||||||
(!isMultiRoute || !!selectedRoute) &&
|
(!isMultiRoute || !!selectedRoute) &&
|
||||||
!createMutation.isPending;
|
!createMutation.isPending;
|
||||||
|
|
||||||
|
// Routes are pure lanes — the label shows origin → destination only.
|
||||||
const routeOptions = routeLines.map((r) => ({
|
const routeOptions = routeLines.map((r) => ({
|
||||||
value: r.routeLineId,
|
value: r.routeLineId,
|
||||||
label: `${r.originYardName ?? r.originYardId} → ${r.destinationYardName ?? r.destinationYardId} · ${formatQuantity(
|
label: `${r.originYardName ?? r.originYardId} → ${r.destinationYardName ?? r.destinationYardId}`,
|
||||||
r.remainingQuantity,
|
|
||||||
null,
|
|
||||||
isContainer,
|
|
||||||
)} remaining`,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -285,104 +259,67 @@ export function PlaceOrderDialog({
|
|||||||
styles={{ input: { height: 44 } }}
|
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">
|
<Stack gap="sm">
|
||||||
<Text fz={13} fw={600} style={{ color: INK }}>
|
<Text fz={13} fw={600} style={{ color: INK }}>
|
||||||
Quantity
|
Quantity
|
||||||
</Text>
|
</Text>
|
||||||
{orderableLines.length === 0 && (
|
{isMultiRoute && !selectedRoute ? (
|
||||||
<Alert color="gray" radius="md" icon={<AlertCircle size={16} />}>
|
<Text fz={13} c="dimmed">
|
||||||
This contract is fully drawn down — no quantity remains.
|
Select a route first, then enter how much to ship on it.
|
||||||
</Alert>
|
</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>
|
||||||
)}
|
|
||||||
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text fz={13} fw={600} style={{ color: INK }}>
|
<Text fz={13} fw={600} style={{ color: INK }}>
|
||||||
|
|||||||
@@ -65,6 +65,21 @@ export const CONTRACT_STATUS_CONFIG: Record<
|
|||||||
FULLY_EXECUTED: { label: "Awaiting Payment", color: "#9A6700", bg: "#FFF6E5" },
|
FULLY_EXECUTED: { label: "Awaiting Payment", color: "#9A6700", bg: "#FFF6E5" },
|
||||||
CONTRACT_ACTIVE: { label: "Active", color: "#0A6F4D", bg: "#E7F6EE" },
|
CONTRACT_ACTIVE: { label: "Active", color: "#0A6F4D", bg: "#E7F6EE" },
|
||||||
CONTRACT_CLOSED: { label: "Closed", color: "#6B7C8E", bg: "#EEF2F6" },
|
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" },
|
EXPIRED: { label: "Expired", color: "#B42318", bg: "#FEECEB" },
|
||||||
CANCELLED: { label: "Cancelled", color: "#B42318", bg: "#FEECEB" },
|
CANCELLED: { label: "Cancelled", color: "#B42318", bg: "#FEECEB" },
|
||||||
REJECTED: { label: "Rejected", color: "#B42318", bg: "#FEECEB" },
|
REJECTED: { label: "Rejected", color: "#B42318", bg: "#FEECEB" },
|
||||||
|
|||||||
@@ -631,11 +631,15 @@ export interface CreateBookingContainerDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A contracted route+quantity line for a GENERAL contract. */
|
/** 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 {
|
export interface CreateContractRouteDto {
|
||||||
originYardId: string;
|
originYardId: string;
|
||||||
destinationYardId: string;
|
destinationYardId: string;
|
||||||
containerTypeId?: string | undefined;
|
/** Road distance (km) for this route; used to bill road orders. */
|
||||||
quantity: number;
|
km?: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateBookingDto {
|
export interface CreateBookingDto {
|
||||||
@@ -728,17 +732,18 @@ export interface CreateBookingOrderLineDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Per-route contracted / ordered / remaining pool line (multi-route contracts). */
|
/** 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 {
|
export interface ContractRouteLine {
|
||||||
routeLineId: string;
|
routeLineId: string;
|
||||||
originYardId: string;
|
originYardId: string;
|
||||||
originYardName?: string | null;
|
originYardName?: string | null;
|
||||||
destinationYardId: string;
|
destinationYardId: string;
|
||||||
destinationYardName?: string | null;
|
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. */
|
/** Road distance (km) for this route; used to bill road orders. Null for rail-only. */
|
||||||
km?: number | null;
|
km?: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user