mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add quantity cap for GENERAL contracts and implement capacity tracking
- Updated ContractClearanceService and ContractsController to remove region parameter from queue method. - Enhanced ContractsRepository to attach contract files for download and added attachContractFiles method. - Modified ContractsService to persist cargo scope with quantity cap based on contract kind. - Introduced quantityCap field in CreateContractCargoScopeDto and ContractCargoScope entity. - Implemented capacity tracking in the frontend with ContractCapacityNotice component to display remaining bookable quantities. - Updated various components and services to support new capacity features, including hooks and API calls. - Added migration to include quantity_cap column in contract_cargo_scope table.
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GENERAL contracts can be booked repeatedly until a total cargo quantity cap is
|
||||||
|
* reached (e.g. 100 containers across many shipments). `quantity_cap` on each
|
||||||
|
* cargo-scope line holds that ceiling (containers per size, or tons/items for
|
||||||
|
* bulk). NULL = uncapped; always NULL for ONE_TIME (single booking).
|
||||||
|
*/
|
||||||
|
export class AddCargoScopeQuantityCap1826000000000 implements MigrationInterface {
|
||||||
|
name = 'AddCargoScopeQuantityCap1826000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contract_cargo_scope ADD COLUMN IF NOT EXISTS quantity_cap NUMERIC(12,2);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contract_cargo_scope DROP COLUMN IF EXISTS quantity_cap;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,7 +77,9 @@ export class ContractBookingService {
|
|||||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ONE_TIME: only one active booking at a time (also enforced by partial unique index).
|
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
|
||||||
|
// booking reached a terminal state (e.g. payment expired without shipping),
|
||||||
|
// letting the customer re-book within contract validity (doc §10.4).
|
||||||
if (contract.contractKind === 'ONE_TIME') {
|
if (contract.contractKind === 'ONE_TIME') {
|
||||||
const active = await this.countActiveBookings(contractId);
|
const active = await this.countActiveBookings(contractId);
|
||||||
if (active > 0) {
|
if (active > 0) {
|
||||||
@@ -85,6 +87,9 @@ export class ContractBookingService {
|
|||||||
'This one-time contract already has an active booking.',
|
'This one-time contract already has an active booking.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// GENERAL: draw down against the cargo quantity cap until it is full.
|
||||||
|
await this.assertWithinQuantityCap(contract, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||||
@@ -219,6 +224,115 @@ export class ContractBookingService {
|
|||||||
.getCount();
|
.getCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── GENERAL contract quantity cap (draw-down) ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a GENERAL booking whose cargo would exceed the contract's quantity
|
||||||
|
* cap. Container caps are per size; bulk is a single tons/items cap. Bookings
|
||||||
|
* that never shipped (CANCELLED / REJECTED / EXPIRED) release their hold.
|
||||||
|
*/
|
||||||
|
private async assertWithinQuantityCap(
|
||||||
|
contract: Contract,
|
||||||
|
dto: CreateBookingUnderContractDto,
|
||||||
|
): Promise<void> {
|
||||||
|
const capacity = await this.computeCapacity(contract);
|
||||||
|
if (capacity.length === 0) return; // uncapped contract
|
||||||
|
|
||||||
|
if (contract.freightType === 'CONTAINER') {
|
||||||
|
for (const line of dto.containers ?? []) {
|
||||||
|
const cap = capacity.find((c) => c.containerSize === line.containerSize);
|
||||||
|
if (!cap || cap.remaining == null) continue; // size uncapped
|
||||||
|
if (line.quantity > cap.remaining) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Only ${cap.remaining} of ${cap.cap} ${line.containerSize} containers remain on this contract.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const requested =
|
||||||
|
(dto.bulkLines ?? []).reduce(
|
||||||
|
(sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
|
||||||
|
0,
|
||||||
|
) || this.resolveBulkTons(dto) || 0;
|
||||||
|
const cap = capacity.find((c) => c.cap != null);
|
||||||
|
if (cap && cap.remaining != null && requested > cap.remaining) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Only ${cap.remaining} of ${cap.cap} remain on this contract.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remaining bookable quantity per cargo-scope line: cap minus what prior
|
||||||
|
* bookings already consumed. Returns [] when the contract has no caps.
|
||||||
|
*/
|
||||||
|
async computeCapacity(
|
||||||
|
contract: Contract,
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
containerSize?: string | null;
|
||||||
|
cargoTypeId?: string | null;
|
||||||
|
cap: number | null;
|
||||||
|
booked: number;
|
||||||
|
remaining: number | null;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const scope = contract.cargoScope ?? [];
|
||||||
|
const capped = scope.filter((s) => s.quantityCap != null);
|
||||||
|
if (capped.length === 0) return [];
|
||||||
|
|
||||||
|
const booked = await this.bookedQuantities(contract);
|
||||||
|
return capped.map((s) => {
|
||||||
|
const cap = Number(s.quantityCap);
|
||||||
|
const used =
|
||||||
|
contract.freightType === 'CONTAINER'
|
||||||
|
? (booked.bySize.get(s.containerSize ?? '') ?? 0)
|
||||||
|
: booked.bulk;
|
||||||
|
return {
|
||||||
|
containerSize: s.containerSize,
|
||||||
|
cargoTypeId: s.cargoTypeId,
|
||||||
|
cap,
|
||||||
|
booked: used,
|
||||||
|
remaining: Math.max(0, cap - used),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quantities already booked under a contract that still hold capacity. Excludes
|
||||||
|
* bookings that never shipped (CANCELLED / REJECTED / EXPIRED).
|
||||||
|
*/
|
||||||
|
private async bookedQuantities(
|
||||||
|
contract: Contract,
|
||||||
|
): Promise<{ bySize: Map<string, number>; bulk: number }> {
|
||||||
|
const releasing = ['CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||||
|
if (contract.freightType === 'CONTAINER') {
|
||||||
|
const rows = await this.dataSource
|
||||||
|
.getRepository(BookingContainer)
|
||||||
|
.createQueryBuilder('bc')
|
||||||
|
.innerJoin(Booking, 'b', 'b.id = bc.booking_id')
|
||||||
|
.select('bc.container_size', 'size')
|
||||||
|
.addSelect('COALESCE(SUM(bc.quantity), 0)', 'qty')
|
||||||
|
.where('b.contract_id = :contractId', { contractId: contract.id })
|
||||||
|
.andWhere('b.status NOT IN (:...releasing)', { releasing })
|
||||||
|
.groupBy('bc.container_size')
|
||||||
|
.getRawMany<{ size: string | null; qty: string }>();
|
||||||
|
const bySize = new Map<string, number>();
|
||||||
|
for (const r of rows) bySize.set(r.size ?? '', Number(r.qty));
|
||||||
|
return { bySize, bulk: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'tons')
|
||||||
|
.where('b.contract_id = :contractId', { contractId: contract.id })
|
||||||
|
.andWhere('b.status NOT IN (:...releasing)', { releasing })
|
||||||
|
.getRawOne<{ tons: string }>();
|
||||||
|
return { bySize: new Map(), bulk: Number(row?.tons ?? 0) };
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveRoute(
|
private async resolveRoute(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
contractRouteId?: string,
|
contractRouteId?: string,
|
||||||
|
|||||||
@@ -481,11 +481,7 @@ export class ContractClearanceService {
|
|||||||
/**
|
/**
|
||||||
* GL ET queue: customs (Path B) contracts awaiting pre-booking document review.
|
* GL ET queue: customs (Path B) contracts awaiting pre-booking document review.
|
||||||
*/
|
*/
|
||||||
async queue(
|
async queue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||||
filter: FilterContractDto,
|
|
||||||
region?: string,
|
|
||||||
): Promise<PaginatedContracts> {
|
|
||||||
void region; // single ET pre-booking queue today; region reserved for split
|
|
||||||
return this.contractsRepository.findAllPaginated({
|
return this.contractsRepository.findAllPaginated({
|
||||||
page: filter.page ?? 1,
|
page: filter.page ?? 1,
|
||||||
pageSize: filter.pageSize ?? 100,
|
pageSize: filter.pageSize ?? 100,
|
||||||
|
|||||||
@@ -166,11 +166,8 @@ export class ContractsController {
|
|||||||
@Get('clearance/queue')
|
@Get('clearance/queue')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||||
@ApiOperation({ summary: 'GL ET queue: contracts awaiting pre-booking document review' })
|
@ApiOperation({ summary: 'GL ET queue: contracts awaiting pre-booking document review' })
|
||||||
clearanceQueue(
|
clearanceQueue(@Query() filter: FilterContractDto) {
|
||||||
@Query() filter: FilterContractDto,
|
return this.clearanceService.queue(filter);
|
||||||
@Query('region') region?: string,
|
|
||||||
) {
|
|
||||||
return this.clearanceService.queue(filter, region);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@@ -485,6 +482,15 @@ export class ContractsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/capacity')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)',
|
||||||
|
})
|
||||||
|
async capacity(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const contract = await this.contractsService.findById(id);
|
||||||
|
return this.contractBookingService.computeCapacity(contract);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────
|
// ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────
|
||||||
|
|
||||||
@Get(':id/milestones')
|
@Get(':id/milestones')
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository } from '@edr/api-common';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
|
import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
@@ -132,6 +132,10 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
.take(pageSize)
|
.take(pageSize)
|
||||||
.getManyAndCount();
|
.getManyAndCount();
|
||||||
|
|
||||||
|
// Attach the generated contract PDF to each row so list/home can offer a
|
||||||
|
// direct download. Loaded separately to keep pagination counts correct.
|
||||||
|
await this.attachContractFiles(items);
|
||||||
|
|
||||||
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
@@ -147,6 +151,28 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load contract-resource files for the given contracts and attach them to
|
||||||
|
* `contract.files`. Kept separate from the paginated query so the one-to-many
|
||||||
|
* join doesn't inflate the page count.
|
||||||
|
*/
|
||||||
|
private async attachContractFiles(contracts: Contract[]): Promise<void> {
|
||||||
|
if (contracts.length === 0) return;
|
||||||
|
const ids = contracts.map((c) => c.id);
|
||||||
|
const files = await this.dataSource.getRepository(FileRecord).find({
|
||||||
|
where: { resource: 'contracts', resourceId: In(ids), deletedAt: IsNull() },
|
||||||
|
});
|
||||||
|
const byContract = new Map<string, FileRecord[]>();
|
||||||
|
for (const file of files) {
|
||||||
|
const list = byContract.get(file.resourceId) ?? [];
|
||||||
|
list.push(file);
|
||||||
|
byContract.set(file.resourceId, list);
|
||||||
|
}
|
||||||
|
for (const contract of contracts) {
|
||||||
|
contract.files = byContract.get(contract.id) ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getStatusCounts(): Promise<Record<string, number>> {
|
async getStatusCounts(): Promise<Record<string, number>> {
|
||||||
const rows = await this.repository
|
const rows = await this.repository
|
||||||
.createQueryBuilder('contract')
|
.createQueryBuilder('contract')
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export class ContractsService {
|
|||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
await this.persistRoutes(contract.id, dto.routes);
|
await this.persistRoutes(contract.id, dto.routes);
|
||||||
await this.persistCargoScope(contract.id, dto.cargoScope);
|
await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind);
|
||||||
|
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -244,8 +244,12 @@ export class ContractsService {
|
|||||||
private async persistCargoScope(
|
private async persistCargoScope(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
cargoScope: CreateContractDto['cargoScope'],
|
cargoScope: CreateContractDto['cargoScope'],
|
||||||
|
contractKind: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const repo = this.dataSource.getRepository(ContractCargoScope);
|
const repo = this.dataSource.getRepository(ContractCargoScope);
|
||||||
|
// A quantity cap only governs GENERAL contracts (multi-shipment draw-down).
|
||||||
|
// ONE_TIME allows a single booking, so any cap on it is meaningless → null.
|
||||||
|
const isGeneral = contractKind === 'GENERAL';
|
||||||
await repo.save(
|
await repo.save(
|
||||||
cargoScope.map((c) =>
|
cargoScope.map((c) =>
|
||||||
repo.create({
|
repo.create({
|
||||||
@@ -253,6 +257,7 @@ export class ContractsService {
|
|||||||
containerSize: c.containerSize ?? null,
|
containerSize: c.containerSize ?? null,
|
||||||
cargoTypeId: c.cargoTypeId ?? null,
|
cargoTypeId: c.cargoTypeId ?? null,
|
||||||
cargoFreeText: c.cargoFreeText ?? null,
|
cargoFreeText: c.cargoFreeText ?? null,
|
||||||
|
quantityCap: isGeneral ? (c.quantityCap ?? null) : null,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -318,7 +323,7 @@ export class ContractsService {
|
|||||||
}
|
}
|
||||||
if (dto.cargoScope) {
|
if (dto.cargoScope) {
|
||||||
await this.dataSource.getRepository(ContractCargoScope).delete({ contractId: id });
|
await this.dataSource.getRepository(ContractCargoScope).delete({ contractId: id });
|
||||||
await this.persistCargoScope(id, dto.cargoScope);
|
await this.persistCargoScope(id, dto.cargoScope, existing.contractKind);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
|
|||||||
@@ -51,6 +51,17 @@ export class CreateContractCargoScopeDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
cargoFreeText?: string | null;
|
cargoFreeText?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'GENERAL only: total bookable quantity for this line (containers per size, or tons/items for bulk). Omit for uncapped.',
|
||||||
|
minimum: 1,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Transform(({ value }) => (value == null || value === '' ? null : Number(value)))
|
||||||
|
quantityCap?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A contracted lane (origin → destination). Routes carry NO quantity. */
|
/** A contracted lane (origin → destination). Routes carry NO quantity. */
|
||||||
|
|||||||
@@ -31,4 +31,14 @@ export class ContractCargoScope extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'cargo_free_text', type: 'varchar', length: 200, nullable: true })
|
@Column({ name: 'cargo_free_text', type: 'varchar', length: 200, nullable: true })
|
||||||
cargoFreeText?: string | null;
|
cargoFreeText?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GENERAL contracts: total cargo quantity allowed across ALL shipments on this
|
||||||
|
* scope line over the validity window. Container → number of containers of
|
||||||
|
* this size; bulk → tons (or items for per-item commodities). Bookings draw
|
||||||
|
* down against it until the cap is reached. NULL = uncapped (always NULL for
|
||||||
|
* ONE_TIME, which allows a single booking).
|
||||||
|
*/
|
||||||
|
@Column({ name: 'quantity_cap', type: 'numeric', precision: 12, scale: 2, nullable: true })
|
||||||
|
quantityCap?: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useMemo, useState } from "react";
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
@@ -29,9 +31,11 @@ import { PageContainer } from "@/components/page";
|
|||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
import {
|
import {
|
||||||
|
useContractCapacity,
|
||||||
useContractDetail,
|
useContractDetail,
|
||||||
useContractMutations,
|
useContractMutations,
|
||||||
} from "@/hooks/contracts/useContracts";
|
} from "@/hooks/contracts/useContracts";
|
||||||
|
import { Boxes } from "lucide-react";
|
||||||
|
|
||||||
interface UnitDraft {
|
interface UnitDraft {
|
||||||
containerNumber: string;
|
containerNumber: string;
|
||||||
@@ -61,6 +65,7 @@ export default function GlCreateBookingForm() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: contract, isLoading } = useContractDetail(id);
|
const { data: contract, isLoading } = useContractDetail(id);
|
||||||
|
const { data: capacity = [] } = useContractCapacity(id);
|
||||||
const mutations = useContractMutations(id ?? "");
|
const mutations = useContractMutations(id ?? "");
|
||||||
|
|
||||||
const [scheduledDate, setScheduledDate] = useState("");
|
const [scheduledDate, setScheduledDate] = useState("");
|
||||||
@@ -221,6 +226,28 @@ export default function GlCreateBookingForm() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
{capacity.length > 0 && (
|
||||||
|
<Alert
|
||||||
|
color={capacity.every((c) => c.remaining === 0) ? "red" : "blue"}
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<Boxes size={16} />}
|
||||||
|
title="Contract draw-down capacity"
|
||||||
|
>
|
||||||
|
<Group gap={8} wrap="wrap">
|
||||||
|
{capacity.map((c, i) => (
|
||||||
|
<Badge
|
||||||
|
key={i}
|
||||||
|
color={c.remaining === 0 ? "red" : "blue"}
|
||||||
|
variant="light"
|
||||||
|
radius="sm"
|
||||||
|
>
|
||||||
|
{c.containerSize ?? "Bulk"}: {c.remaining} of {c.cap} left
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<SectionCard icon={FileText} title="Schedule">
|
<SectionCard icon={FileText} title="Schedule">
|
||||||
<Grid gap="md">
|
<Grid gap="md">
|
||||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const QUERY_KEYS = {
|
|||||||
clearanceQueue: (region?: string) =>
|
clearanceQueue: (region?: string) =>
|
||||||
["contracts", "clearance-queue", region ?? "ET"] as const,
|
["contracts", "clearance-queue", region ?? "ET"] as const,
|
||||||
milestones: (id: string) => ["contracts", "milestones", id] as const,
|
milestones: (id: string) => ["contracts", "milestones", id] as const,
|
||||||
|
capacity: (id: string) => ["contracts", "capacity", id] as const,
|
||||||
bookingMilestones: (bookingId: string) =>
|
bookingMilestones: (bookingId: string) =>
|
||||||
["contracts", "booking-milestones", bookingId] as const,
|
["contracts", "booking-milestones", bookingId] as const,
|
||||||
bookingIncidents: (bookingId: string) =>
|
bookingIncidents: (bookingId: string) =>
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ export const URL_CONSTANTS = {
|
|||||||
OPS_CLEARANCE_FINALIZE: (id: string) =>
|
OPS_CLEARANCE_FINALIZE: (id: string) =>
|
||||||
`/contracts/${id}/clearance/ops-finalize`,
|
`/contracts/${id}/clearance/ops-finalize`,
|
||||||
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
|
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
|
||||||
|
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
|
||||||
MILESTONES: (id: string) => `/contracts/${id}/milestones`,
|
MILESTONES: (id: string) => `/contracts/${id}/milestones`,
|
||||||
BOOKING_MILESTONES: (bookingId: string) =>
|
BOOKING_MILESTONES: (bookingId: string) =>
|
||||||
`/contracts/bookings/${bookingId}/milestones`,
|
`/contracts/bookings/${bookingId}/milestones`,
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ export function useContractDetail(id: string | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useContractClearanceQueue(region = "ET", enabled = true) {
|
export function useContractClearanceQueue(enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue(region),
|
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("GL"),
|
||||||
queryFn: () => contractsService.getClearanceQueue(region),
|
queryFn: () => contractsService.getClearanceQueue(),
|
||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -69,6 +69,14 @@ export function useContractMilestones(id: string | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useContractCapacity(id: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEYS.CONTRACTS.capacity(id ?? ""),
|
||||||
|
queryFn: () => contractsService.getCapacity(id!),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useBookingMilestones(bookingId: string | undefined) {
|
export function useBookingMilestones(bookingId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId ?? ""),
|
queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId ?? ""),
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ import {
|
|||||||
} from "@/hooks/contracts/useContracts";
|
} from "@/hooks/contracts/useContracts";
|
||||||
|
|
||||||
type ViewMode = "table" | "cards";
|
type ViewMode = "table" | "cards";
|
||||||
type Region = "ET" | "DJ";
|
|
||||||
|
|
||||||
interface ClearanceRow {
|
interface ClearanceRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -118,12 +117,11 @@ export default function ContractClearanceListPage({
|
|||||||
opsMode?: boolean;
|
opsMode?: boolean;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [region, setRegion] = useState<Region>("ET");
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [view, setView] = useState<ViewMode>("table");
|
const [view, setView] = useState<ViewMode>("table");
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
|
||||||
const glQueue = useContractClearanceQueue(region, !opsMode);
|
const glQueue = useContractClearanceQueue(!opsMode);
|
||||||
const opsQueue = useOpsClearanceQueue(opsMode);
|
const opsQueue = useOpsClearanceQueue(opsMode);
|
||||||
const { data, isLoading, isError, isFetching, refetch } = opsMode
|
const { data, isLoading, isError, isFetching, refetch } = opsMode
|
||||||
? opsQueue
|
? opsQueue
|
||||||
@@ -340,24 +338,6 @@ export default function ContractClearanceListPage({
|
|||||||
style={{ flex: 1, minWidth: 220 }}
|
style={{ flex: 1, minWidth: 220 }}
|
||||||
/>
|
/>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
{!opsMode && (
|
|
||||||
<SegmentedControl
|
|
||||||
size="sm"
|
|
||||||
radius="md"
|
|
||||||
value={region}
|
|
||||||
onChange={(v) => {
|
|
||||||
setRegion(v as Region);
|
|
||||||
setPagination({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
data={[
|
|
||||||
{ value: "ET", label: "Ethiopia" },
|
|
||||||
{ value: "DJ", label: "Djibouti" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{total} record{total !== 1 ? "s" : ""}
|
{total} record{total !== 1 ? "s" : ""}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -163,12 +163,8 @@ export const contractsService = {
|
|||||||
postContract<Freight.IContract>(C.CONTRACT_SIGN(id), payload),
|
postContract<Freight.IContract>(C.CONTRACT_SIGN(id), payload),
|
||||||
|
|
||||||
// ── Pre-booking clearance (Path B — GL ET) ──
|
// ── Pre-booking clearance (Path B — GL ET) ──
|
||||||
getClearanceQueue: async (
|
getClearanceQueue: async (): Promise<PaginatedContracts> => {
|
||||||
region = "ET",
|
const response = await client.get<PaginatedContracts>(C.CLEARANCE_QUEUE);
|
||||||
): Promise<PaginatedContracts> => {
|
|
||||||
const response = await client.get<PaginatedContracts>(C.CLEARANCE_QUEUE, {
|
|
||||||
params: { region },
|
|
||||||
});
|
|
||||||
const data = unwrap(response.data);
|
const data = unwrap(response.data);
|
||||||
return {
|
return {
|
||||||
items: (data.items ?? []) as Freight.IContract[],
|
items: (data.items ?? []) as Freight.IContract[],
|
||||||
@@ -230,6 +226,12 @@ export const contractsService = {
|
|||||||
payload: Freight.CreateBookingUnderContractDto,
|
payload: Freight.CreateBookingUnderContractDto,
|
||||||
) => postContract<{ id: string; reference: string }>(C.BOOKINGS(id), payload),
|
) => postContract<{ id: string; reference: string }>(C.BOOKINGS(id), payload),
|
||||||
|
|
||||||
|
/** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */
|
||||||
|
getCapacity: async (id: string): Promise<Freight.ContractCapacityLine[]> => {
|
||||||
|
const response = await client.get(C.CAPACITY(id));
|
||||||
|
return (unwrap(response.data) ?? []) as Freight.ContractCapacityLine[];
|
||||||
|
},
|
||||||
|
|
||||||
// ── Clearance milestones ──
|
// ── Clearance milestones ──
|
||||||
listMilestonesForContract: async (
|
listMilestonesForContract: async (
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export const URL_CONSTANTS = {
|
|||||||
`/api/contracts/bookings/${bookingId}/milestones`,
|
`/api/contracts/bookings/${bookingId}/milestones`,
|
||||||
BOOKING_DUTY_SLIP: (bookingId: string) =>
|
BOOKING_DUTY_SLIP: (bookingId: string) =>
|
||||||
`/api/contracts/bookings/${bookingId}/duty-slip`,
|
`/api/contracts/bookings/${bookingId}/duty-slip`,
|
||||||
|
CAPACITY: (id: string) => `/api/contracts/${id}/capacity`,
|
||||||
},
|
},
|
||||||
|
|
||||||
TRAIN_SCHEDULING: {
|
TRAIN_SCHEDULING: {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||||
import {
|
import {
|
||||||
ActionNeededSection,
|
|
||||||
FreightVolumeSection,
|
FreightVolumeSection,
|
||||||
HelloSection,
|
HelloSection,
|
||||||
InvoicesSection,
|
InvoicesSection,
|
||||||
@@ -14,7 +13,6 @@ import {
|
|||||||
ShipmentsSection,
|
ShipmentsSection,
|
||||||
StatsSection,
|
StatsSection,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
import { deriveActionItems } from "./actions";
|
|
||||||
import { useMyPortalData } from "./hooks";
|
import { useMyPortalData } from "./hooks";
|
||||||
|
|
||||||
export default function MyPortalPage() {
|
export default function MyPortalPage() {
|
||||||
@@ -27,7 +25,6 @@ export default function MyPortalPage() {
|
|||||||
bookingsQuery,
|
bookingsQuery,
|
||||||
dashboardQuery,
|
dashboardQuery,
|
||||||
contractsQuery,
|
contractsQuery,
|
||||||
allContracts,
|
|
||||||
recentContracts,
|
recentContracts,
|
||||||
activeContractsCount,
|
activeContractsCount,
|
||||||
allBookings,
|
allBookings,
|
||||||
@@ -48,8 +45,6 @@ export default function MyPortalPage() {
|
|||||||
label: `${PROFILE_TYPE_LABELS[p.type] ?? p.type} · ${p.reference}`,
|
label: `${PROFILE_TYPE_LABELS[p.type] ?? p.type} · ${p.reference}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const actionItems = deriveActionItems(allContracts, allBookings);
|
|
||||||
|
|
||||||
const handleBookingClick = (id: string) => {
|
const handleBookingClick = (id: string) => {
|
||||||
navigate(`/bookings/${id}`);
|
navigate(`/bookings/${id}`);
|
||||||
};
|
};
|
||||||
@@ -58,8 +53,6 @@ export default function MyPortalPage() {
|
|||||||
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
||||||
<HelloSection greeting={greeting} companyName={companyName} />
|
<HelloSection greeting={greeting} companyName={companyName} />
|
||||||
|
|
||||||
<ActionNeededSection items={actionItems} contracts={allContracts} />
|
|
||||||
|
|
||||||
{serviceOptions.length > 1 && (
|
{serviceOptions.length > 1 && (
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Select
|
<Select
|
||||||
@@ -98,36 +91,36 @@ export default function MyPortalPage() {
|
|||||||
dashboardLoading={dashboardQuery.isPending}
|
dashboardLoading={dashboardQuery.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Contracts lead the dashboard; bookings live under their contract. */}
|
{/* Contracts + shipments side by side — the two primary tables. */}
|
||||||
<Grid align="stretch">
|
<Grid align="stretch">
|
||||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||||
<RecentContractsSection
|
<RecentContractsSection
|
||||||
contracts={recentContracts}
|
contracts={recentContracts}
|
||||||
isLoading={contractsQuery.isPending}
|
isLoading={contractsQuery.isPending}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||||
<InvoicesSection invoices={recentInvoices} />
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid align="stretch">
|
|
||||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
|
||||||
<ShipmentsSection
|
<ShipmentsSection
|
||||||
bookings={allBookings.slice(0, 6)}
|
bookings={allBookings.slice(0, 6)}
|
||||||
isLoading={bookingsQuery.isPending}
|
isLoading={bookingsQuery.isPending}
|
||||||
onBookingClick={handleBookingClick}
|
onBookingClick={handleBookingClick}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
<Grid align="stretch">
|
||||||
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
<RecentActivitySection
|
<RecentActivitySection
|
||||||
bookings={allBookings}
|
bookings={allBookings}
|
||||||
isLoading={bookingsQuery.isPending}
|
isLoading={bookingsQuery.isPending}
|
||||||
onBookingClick={handleBookingClick}
|
onBookingClick={handleBookingClick}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
|
<InvoicesSection invoices={recentInvoices} />
|
||||||
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid align="stretch">
|
<Grid align="stretch">
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { memo } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ArrowRight, FileSignature, Package, Plus } from "lucide-react";
|
import { ArrowRight, FileSignature, Package, Plus } from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { ContractStatusBadge } from "@/pages/contracts/contract-ui";
|
import {
|
||||||
|
ContractDocButton,
|
||||||
|
ContractStatusBadge,
|
||||||
|
} from "@/pages/contracts/contract-ui";
|
||||||
import { Card } from "./Card";
|
import { Card } from "./Card";
|
||||||
import { EmptyState } from "./EmptyState";
|
import { EmptyState } from "./EmptyState";
|
||||||
|
|
||||||
@@ -87,7 +90,11 @@ export const RecentContractsSection = memo(function RecentContractsSection({
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Group gap={10} wrap="nowrap" style={{ flexShrink: 0 }}>
|
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||||
|
<ContractDocButton
|
||||||
|
contract={c}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
<ContractStatusBadge status={c.status} />
|
<ContractStatusBadge status={c.status} />
|
||||||
{canSign ? (
|
{canSign ? (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -97,18 +97,16 @@ interface DocGroup {
|
|||||||
* groups are dropped so the tab only renders sections that have files.
|
* groups are dropped so the tab only renders sections that have files.
|
||||||
*/
|
*/
|
||||||
function groupContractDocuments(files: ContractFile[]): DocGroup[] {
|
function groupContractDocuments(files: ContractFile[]): DocGroup[] {
|
||||||
const contract: ContractFile[] = [];
|
|
||||||
const profile: ContractFile[] = [];
|
const profile: ContractFile[] = [];
|
||||||
const clearance: ContractFile[] = [];
|
const clearance: ContractFile[] = [];
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
// Signature images are baked into the contract PDF — don't list them here.
|
// The generated contract PDF lives in the contract list / home rows, not
|
||||||
if (f.code === "contract") contract.push(f);
|
// here. Signature images are baked into that PDF — skip both.
|
||||||
else if (f.code.startsWith("signature_")) continue;
|
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
|
||||||
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
|
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
|
||||||
else clearance.push(f);
|
else clearance.push(f);
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ key: "contract", title: "Contract document", files: contract },
|
|
||||||
{ key: "profile", title: "Profile documents", files: profile },
|
{ key: "profile", title: "Profile documents", files: profile },
|
||||||
{ key: "clearance", title: "Clearance documents", files: clearance },
|
{ key: "clearance", title: "Clearance documents", files: clearance },
|
||||||
].filter((g) => g.files.length > 0);
|
].filter((g) => g.files.length > 0);
|
||||||
@@ -935,12 +933,10 @@ const KEY_FACT_ACCENT: Record<string, string> = {
|
|||||||
|
|
||||||
// Per-section accent + icon for the Documents tab groups.
|
// Per-section accent + icon for the Documents tab groups.
|
||||||
const DOC_GROUP_ACCENT: Record<string, string> = {
|
const DOC_GROUP_ACCENT: Record<string, string> = {
|
||||||
contract: GREEN,
|
|
||||||
profile: "#2B6CB0",
|
profile: "#2B6CB0",
|
||||||
clearance: "#C77F09",
|
clearance: "#C77F09",
|
||||||
};
|
};
|
||||||
const DOC_GROUP_ICON: Record<string, LucideIcon> = {
|
const DOC_GROUP_ICON: Record<string, LucideIcon> = {
|
||||||
contract: FileSignature,
|
|
||||||
profile: FileText,
|
profile: FileText,
|
||||||
clearance: Upload,
|
clearance: Upload,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import type { Freight } from "@edr/types";
|
|||||||
import { usePagination } from "@edr/ui-common";
|
import { usePagination } from "@edr/ui-common";
|
||||||
import {
|
import {
|
||||||
BORDER,
|
BORDER,
|
||||||
|
ContractDocButton,
|
||||||
ContractStatusBadge,
|
ContractStatusBadge,
|
||||||
GREEN,
|
GREEN,
|
||||||
INK,
|
INK,
|
||||||
@@ -493,7 +494,11 @@ export default function ContractsList() {
|
|||||||
<ContractStatusBadge status={c.status} />
|
<ContractStatusBadge status={c.status} />
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end" gap={8} wrap="nowrap">
|
||||||
|
<ContractDocButton
|
||||||
|
contract={c}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
@@ -295,21 +295,28 @@ export default function NewContractPage() {
|
|||||||
|
|
||||||
// Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled
|
// Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled
|
||||||
// size (+ optional commodity); bulk: a single commodity row.
|
// size (+ optional commodity); bulk: a single commodity row.
|
||||||
|
// GENERAL contracts carry a quantity cap (draw-down); ONE_TIME does not.
|
||||||
|
const isGeneral = data.contractKind === "general_contract";
|
||||||
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
|
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
|
||||||
? data.enabledContainerSizes.map((size) => ({
|
? data.enabledContainerSizes.map((size) => ({
|
||||||
containerSize: size,
|
containerSize: size,
|
||||||
cargoTypeId: data.cargoCommodityId || undefined,
|
cargoTypeId: data.cargoCommodityId || undefined,
|
||||||
|
quantityCap:
|
||||||
|
isGeneral && data.containerSizeCaps[size]
|
||||||
|
? data.containerSizeCaps[size]
|
||||||
|
: undefined,
|
||||||
}))
|
}))
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
cargoTypeId: data.cargoTypePath?.[1] || undefined,
|
cargoTypeId: data.cargoTypePath?.[1] || undefined,
|
||||||
cargoFreeText: data.cargoFreeText || undefined,
|
cargoFreeText: data.cargoFreeText || undefined,
|
||||||
|
quantityCap:
|
||||||
|
isGeneral && data.bulkQuantityCap ? data.bulkQuantityCap : undefined,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Routes — pure origin→destination lanes, no quantity. Route #1 is primary;
|
// Routes — pure origin→destination lanes, no quantity. Route #1 is primary;
|
||||||
// extras only apply to GENERAL contracts.
|
// extras only apply to GENERAL contracts.
|
||||||
const isGeneral = data.contractKind === "general_contract";
|
|
||||||
const routes: Freight.CreateContractRouteInputDto[] = [
|
const routes: Freight.CreateContractRouteInputDto[] = [
|
||||||
{
|
{
|
||||||
originYardId: data.originYard,
|
originYardId: data.originYard,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
shipmentStepFields,
|
shipmentStepFields,
|
||||||
} from "./new-shipment-form/schema";
|
} from "./new-shipment-form/schema";
|
||||||
import { computeShipmentTotal } from "./new-shipment-form/total";
|
import { computeShipmentTotal } from "./new-shipment-form/total";
|
||||||
|
import { ContractCapacityNotice } from "./new-shipment-form/ContractCapacityNotice";
|
||||||
|
|
||||||
type ShipmentForm = ReturnType<
|
type ShipmentForm = ReturnType<
|
||||||
typeof useForm<ShipmentFormInputValues, any, ShipmentFormValues>
|
typeof useForm<ShipmentFormInputValues, any, ShipmentFormValues>
|
||||||
@@ -458,6 +459,7 @@ function CargoStep({
|
|||||||
description="Enter the quantity and per-container details for each size in your contract scope."
|
description="Enter the quantity and per-container details for each size in your contract scope."
|
||||||
/>
|
/>
|
||||||
<Stack gap={18}>
|
<Stack gap={18}>
|
||||||
|
<ContractCapacityNotice contractId={contract.id} isContainer />
|
||||||
{lines.map((line, index) => (
|
{lines.map((line, index) => (
|
||||||
<ContainerLineEditor
|
<ContainerLineEditor
|
||||||
key={line.containerSize}
|
key={line.containerSize}
|
||||||
@@ -486,6 +488,7 @@ function CargoStep({
|
|||||||
description="Enter the amount you are shipping for this booking."
|
description="Enter the amount you are shipping for this booking."
|
||||||
/>
|
/>
|
||||||
<Stack gap={14}>
|
<Stack gap={14}>
|
||||||
|
<ContractCapacityNotice contractId={contract.id} isContainer={false} />
|
||||||
<Controller
|
<Controller
|
||||||
name="cargoWeightTons"
|
name="cargoWeightTons"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Box, Group, Paper, Text } from "@mantine/core";
|
import { Box, Group, Paper, Text, Tooltip } from "@mantine/core";
|
||||||
|
import { FileText } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
|
|
||||||
// Brand palette (mirrors the booking form's shared constants).
|
// Brand palette (mirrors the booking form's shared constants).
|
||||||
export const INK = "#10202F";
|
export const INK = "#10202F";
|
||||||
@@ -226,3 +230,52 @@ export function formatQuantity(
|
|||||||
if (unit === "PER_ITEM") return `${rounded} items`;
|
if (unit === "PER_ITEM") return `${rounded} items`;
|
||||||
return `${rounded} tons`;
|
return `${rounded} tons`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The generated contract PDF (file with code "contract"), if present. */
|
||||||
|
export function contractPdfFile(
|
||||||
|
contract: Pick<Freight.IContract, "files">,
|
||||||
|
): NonNullable<Freight.IContract["files"]>[number] | undefined {
|
||||||
|
return (contract.files ?? []).find((f) => f.code === "contract");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon button that opens the generated contract PDF in a new tab. Renders
|
||||||
|
* nothing when the contract has not been generated yet, so it's safe to drop
|
||||||
|
* into any contract row (list table, home recents, etc).
|
||||||
|
*/
|
||||||
|
export function ContractDocButton({
|
||||||
|
contract,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
contract: Pick<Freight.IContract, "files">;
|
||||||
|
/** Stop row-click propagation when the button lives inside a clickable row. */
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
}) {
|
||||||
|
const file = contractPdfFile(contract);
|
||||||
|
if (!file) return null;
|
||||||
|
return (
|
||||||
|
<Tooltip label="Contract document">
|
||||||
|
<Box
|
||||||
|
component="a"
|
||||||
|
href={fileViewUrl(file.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label="Open contract document"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${BORDER}`,
|
||||||
|
color: GREEN_DARK,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText size={16} />
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -151,9 +151,14 @@ export const contractFormSchema = z
|
|||||||
enabledContainerSizes: z.array(z.enum(CONTAINER_SIZES)).default([]),
|
enabledContainerSizes: z.array(z.enum(CONTAINER_SIZES)).default([]),
|
||||||
// Optional commodity label for the contract PDF (container scope).
|
// Optional commodity label for the contract PDF (container scope).
|
||||||
cargoCommodityId: z.string().default(""),
|
cargoCommodityId: z.string().default(""),
|
||||||
|
// GENERAL only: per-size container quantity cap (total bookable over the
|
||||||
|
// validity window). Keyed by size; 0/undefined = uncapped.
|
||||||
|
containerSizeCaps: z.record(z.string(), z.number().nonnegative()).default({}),
|
||||||
// Bulk scope: the cargo type path (group → commodity).
|
// Bulk scope: the cargo type path (group → commodity).
|
||||||
cargoTypePath: z.array(z.string()).default([]),
|
cargoTypePath: z.array(z.string()).default([]),
|
||||||
cargoFreeText: z.string().default(""),
|
cargoFreeText: z.string().default(""),
|
||||||
|
// GENERAL only: total bulk tons/items bookable. 0 = uncapped.
|
||||||
|
bulkQuantityCap: z.number().nonnegative().default(0),
|
||||||
// Contract-level billing flags.
|
// Contract-level billing flags.
|
||||||
isHazardous: z.boolean().default(false),
|
isHazardous: z.boolean().default(false),
|
||||||
isRefrigerated: z.boolean().default(false),
|
isRefrigerated: z.boolean().default(false),
|
||||||
@@ -261,8 +266,10 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
|
|||||||
cargoType: "container",
|
cargoType: "container",
|
||||||
enabledContainerSizes: ["20ft"],
|
enabledContainerSizes: ["20ft"],
|
||||||
cargoCommodityId: "",
|
cargoCommodityId: "",
|
||||||
|
containerSizeCaps: {},
|
||||||
cargoTypePath: [],
|
cargoTypePath: [],
|
||||||
cargoFreeText: "",
|
cargoFreeText: "",
|
||||||
|
bulkQuantityCap: 0,
|
||||||
isHazardous: false,
|
isHazardous: false,
|
||||||
isRefrigerated: false,
|
isRefrigerated: false,
|
||||||
|
|
||||||
@@ -298,8 +305,10 @@ export const contractStepFields: Record<
|
|||||||
"cargoType",
|
"cargoType",
|
||||||
"enabledContainerSizes",
|
"enabledContainerSizes",
|
||||||
"cargoCommodityId",
|
"cargoCommodityId",
|
||||||
|
"containerSizeCaps",
|
||||||
"cargoTypePath",
|
"cargoTypePath",
|
||||||
"cargoFreeText",
|
"cargoFreeText",
|
||||||
|
"bulkQuantityCap",
|
||||||
"isHazardous",
|
"isHazardous",
|
||||||
"isRefrigerated",
|
"isRefrigerated",
|
||||||
"originYard",
|
"originYard",
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import { Flame, Snowflake } from "lucide-react";
|
import { Flame, Snowflake } from "lucide-react";
|
||||||
import { Box, Group, MultiSelect, Select, Skeleton, Stack, Switch, Text } from "@mantine/core";
|
import {
|
||||||
|
Box,
|
||||||
|
Group,
|
||||||
|
MultiSelect,
|
||||||
|
NumberInput,
|
||||||
|
Select,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
ContractFormInputValues,
|
ContractFormInputValues,
|
||||||
@@ -46,6 +56,8 @@ export function Step3CargoScope({
|
|||||||
const cargoType = form.watch("cargoType");
|
const cargoType = form.watch("cargoType");
|
||||||
const cargoTypePath = form.watch("cargoTypePath") ?? [];
|
const cargoTypePath = form.watch("cargoTypePath") ?? [];
|
||||||
const parentId = cargoTypePath[0];
|
const parentId = cargoTypePath[0];
|
||||||
|
const isGeneral = form.watch("contractKind") === "general_contract";
|
||||||
|
const enabledSizes = form.watch("enabledContainerSizes") ?? [];
|
||||||
|
|
||||||
// Reset the commodity child only when the parent group really changes.
|
// Reset the commodity child only when the parent group really changes.
|
||||||
const prevParentIdRef = useRef<string | undefined>(parentId);
|
const prevParentIdRef = useRef<string | undefined>(parentId);
|
||||||
@@ -233,6 +245,62 @@ export function Step3CargoScope({
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* GENERAL contract quantity cap (draw-down ceiling). */}
|
||||||
|
{isGeneral && (
|
||||||
|
<Box>
|
||||||
|
<StepLabel>Booking quantity cap (optional)</StepLabel>
|
||||||
|
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
|
||||||
|
Total quantity bookable across all shipments under this contract.
|
||||||
|
Customers / GL can book repeatedly until it is reached. Leave 0 for
|
||||||
|
unlimited.
|
||||||
|
</Text>
|
||||||
|
{cargoType === "container" ? (
|
||||||
|
<Group gap={12} grow>
|
||||||
|
{enabledSizes.length === 0 ? (
|
||||||
|
<Text fz={13} c="dimmed">
|
||||||
|
Select container sizes above to set their caps.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
enabledSizes.map((size) => (
|
||||||
|
<Controller
|
||||||
|
key={size}
|
||||||
|
name={`containerSizeCaps.${size}`}
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<NumberInput
|
||||||
|
label={`${size} cap (containers)`}
|
||||||
|
placeholder="0 = unlimited"
|
||||||
|
min={0}
|
||||||
|
value={field.value ?? 0}
|
||||||
|
onChange={(v) => field.onChange(Number(v) || 0)}
|
||||||
|
radius={10}
|
||||||
|
styles={fieldStyles}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Controller
|
||||||
|
name="bulkQuantityCap"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<NumberInput
|
||||||
|
label="Total cap (tons / items)"
|
||||||
|
placeholder="0 = unlimited"
|
||||||
|
min={0}
|
||||||
|
value={field.value ?? 0}
|
||||||
|
onChange={(v) => field.onChange(Number(v) || 0)}
|
||||||
|
radius={10}
|
||||||
|
styles={fieldStyles}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Shared billing flags. */}
|
{/* Shared billing flags. */}
|
||||||
<Box>
|
<Box>
|
||||||
<StepLabel>Cargo handling</StepLabel>
|
<StepLabel>Cargo handling</StepLabel>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Alert, Badge, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { Boxes } from "lucide-react";
|
||||||
|
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remaining draw-down capacity for a GENERAL contract — how many more
|
||||||
|
* containers / tons may still be booked. Renders nothing for uncapped or
|
||||||
|
* ONE_TIME contracts. When any line is full, shows a red "no capacity" alert.
|
||||||
|
*/
|
||||||
|
export function ContractCapacityNotice({
|
||||||
|
contractId,
|
||||||
|
isContainer,
|
||||||
|
}: {
|
||||||
|
contractId: string;
|
||||||
|
isContainer: boolean;
|
||||||
|
}) {
|
||||||
|
const { data: lines = [] } = useQuery({
|
||||||
|
queryKey: ["contract-capacity", contractId],
|
||||||
|
queryFn: () => contractsService.getCapacity(contractId),
|
||||||
|
enabled: !!contractId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lines.length === 0) return null;
|
||||||
|
|
||||||
|
const allFull = lines.every((l) => l.remaining === 0);
|
||||||
|
const unit = isContainer ? "" : " tons";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
color={allFull ? "red" : "edr-green"}
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<Boxes size={16} />}
|
||||||
|
title={allFull ? "Contract capacity reached" : "Remaining contract capacity"}
|
||||||
|
>
|
||||||
|
{allFull ? (
|
||||||
|
<Text fz={13}>
|
||||||
|
This contract has been fully booked. No further shipments can be
|
||||||
|
created against it.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap={6} mt={4}>
|
||||||
|
{lines.map((l, i) => (
|
||||||
|
<Group key={i} justify="space-between" wrap="nowrap">
|
||||||
|
<Text fz={13}>
|
||||||
|
{l.containerSize ?? "Bulk"}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
color={l.remaining === 0 ? "red" : "edr-green"}
|
||||||
|
variant="light"
|
||||||
|
radius="sm"
|
||||||
|
>
|
||||||
|
{l.remaining}
|
||||||
|
{unit} of {l.cap} left
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -263,6 +263,12 @@ export const contractsService = {
|
|||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */
|
||||||
|
getCapacity: async (id: string): Promise<Freight.ContractCapacityLine[]> => {
|
||||||
|
const { data } = await client.get(C.CAPACITY(id));
|
||||||
|
return (data.data ?? data) as Freight.ContractCapacityLine[];
|
||||||
|
},
|
||||||
|
|
||||||
/** Customer uploads the duty/tax payment slip (doc-triggers DUTY_TAX_PAID). */
|
/** Customer uploads the duty/tax payment slip (doc-triggers DUTY_TAX_PAID). */
|
||||||
uploadDutySlip: async (
|
uploadDutySlip: async (
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
|
|||||||
@@ -133,6 +133,20 @@ export interface IContractCargoScope {
|
|||||||
containerSize?: string | null;
|
containerSize?: string | null;
|
||||||
cargoTypeId?: string | null;
|
cargoTypeId?: string | null;
|
||||||
cargoFreeText?: string | null;
|
cargoFreeText?: string | null;
|
||||||
|
/**
|
||||||
|
* GENERAL contracts: total quantity bookable across all shipments on this line
|
||||||
|
* (containers per size, or tons/items for bulk). null = uncapped / ONE_TIME.
|
||||||
|
*/
|
||||||
|
quantityCap?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remaining bookable quantity per cargo-scope line (GENERAL contracts). */
|
||||||
|
export interface ContractCapacityLine {
|
||||||
|
containerSize?: string | null;
|
||||||
|
cargoTypeId?: string | null;
|
||||||
|
cap: number | null;
|
||||||
|
booked: number;
|
||||||
|
remaining: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IContractRateSnapshot {
|
export interface IContractRateSnapshot {
|
||||||
@@ -464,6 +478,8 @@ export interface CreateContractCargoScopeDto {
|
|||||||
containerSize?: string | null;
|
containerSize?: string | null;
|
||||||
cargoTypeId?: string | null;
|
cargoTypeId?: string | null;
|
||||||
cargoFreeText?: string | null;
|
cargoFreeText?: string | null;
|
||||||
|
/** GENERAL only: total bookable quantity for this line. Omit for uncapped. */
|
||||||
|
quantityCap?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateContractRouteInputDto {
|
export interface CreateContractRouteInputDto {
|
||||||
|
|||||||
Reference in New Issue
Block a user