feat: Implement general contract booking orders functionality

- Add DTOs for creating booking orders and viewing contract quantities.
- Create entities for booking orders and booking order lines.
- Implement service for managing general contract operations, including activation after payment and retrieving quantity lines.
- Develop UI components for contract detail and list pages, including order placement dialog.
- Integrate API service for booking orders, enabling listing and creating orders against contracts.
- Enhance contract status display and quantity pool visualization in the UI.
This commit is contained in:
Marshal
2026-06-20 19:31:51 +00:00
parent cc62482d4e
commit b6d5047d27
43 changed files with 2256 additions and 37 deletions

View File

@@ -13,6 +13,7 @@ import telebirrConfig from "./config/telebirr.config";
import rabbitmqConfig from "./config/rabbitmq.config";
import { BookingsModule } from "./modules/bookings/bookings.module";
import { BookingOrdersModule } from "./modules/booking-orders/booking-orders.module";
import { SignaturesModule } from "./modules/signatures/signatures.module";
import { FilesModule } from "./modules/files/files.module";
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
@@ -87,6 +88,7 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module';
permissions: EDR_FREIGHT_PERMISSIONS,
}),
BookingsModule,
BookingOrdersModule,
SignaturesModule,
FilesModule,
ConsignmentsModule,

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUnitOfMeasureToCargoTypes1792000000000
implements MigrationInterface
{
name = 'AddUnitOfMeasureToCargoTypes1792000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.cargo_types ADD COLUMN IF NOT EXISTS unit_of_measure VARCHAR(16);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS unit_of_measure;`,
);
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBookingTypeAndContractFields1792000000001
implements MigrationInterface
{
name = 'AddBookingTypeAndContractFields1792000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS booking_type VARCHAR(20) NOT NULL DEFAULT 'ONE_TIME';`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;`,
);
// General contracts have no shipment date at creation — relax the NOT NULL.
await queryRunner.query(
`ALTER TABLE freight.bookings ALTER COLUMN scheduled_date DROP NOT NULL;`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_bookings_booking_type ON freight.bookings (booking_type);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_bookings_booking_type;`,
);
// Reinstate NOT NULL only if no null rows exist (general contracts would block it).
await queryRunner.query(
`ALTER TABLE freight.bookings ALTER COLUMN scheduled_date SET NOT NULL;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS expires_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS booking_type;`,
);
}
}

View File

@@ -0,0 +1,74 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
export class CreateBookingOrders1792000000002 implements MigrationInterface {
name = 'CreateBookingOrders1792000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'booking_orders',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'reference', type: 'varchar', length: '64', isUnique: true },
{ name: 'contract_booking_id', type: 'uuid' },
{ name: 'booking_id', type: 'uuid', isNullable: true },
{ name: 'company_id', type: 'uuid', isNullable: true },
{ name: 'scheduled_date', type: 'timestamptz' },
{ name: 'status', type: 'varchar', length: '40', default: "'PAID'" },
{ name: 'scheduling_status', type: 'varchar', length: '30', default: "'NOT_SCHEDULED'" },
{ name: 'train_schedule_id', type: 'uuid', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex(
'freight.booking_orders',
new TableIndex({ name: 'idx_booking_orders_contract', columnNames: ['contract_booking_id'] }),
);
await queryRunner.createIndex(
'freight.booking_orders',
new TableIndex({ name: 'idx_booking_orders_company', columnNames: ['company_id'] }),
);
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'booking_order_lines',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'order_id', type: 'uuid' },
{ name: 'container_type_id', type: 'uuid', isNullable: true },
{ name: 'quantity', type: 'numeric', precision: 12, scale: 3 },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
foreignKeys: [
{
columnNames: ['order_id'],
referencedSchema: 'freight',
referencedTableName: 'booking_orders',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
},
],
}),
true,
);
await queryRunner.createIndex(
'freight.booking_order_lines',
new TableIndex({ name: 'idx_booking_order_lines_order', columnNames: ['order_id'] }),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.booking_order_lines', true);
await queryRunner.dropTable('freight.booking_orders', true);
}
}

View File

@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Seeds the global "general contract period" setting (months). Stored as a
* dropdown_settings row with a single option whose `value` holds the month count
* so backoffice can manage it through the existing settings UI later.
*/
export class SeedGeneralContractPeriod1792000000003
implements MigrationInterface
{
name = 'SeedGeneralContractPeriod1792000000003';
private readonly code = 'general_contract_period';
public async up(queryRunner: QueryRunner): Promise<void> {
const existing = await queryRunner.query(
`SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`,
[this.code],
);
if (existing.length > 0) return;
const inserted = await queryRunner.query(
`INSERT INTO freight.dropdown_settings (code, label, description, multiple)
VALUES ($1, $2, $3, false)
RETURNING id;`,
[
this.code,
'General Contract Period (months)',
'How many months a general contract stays open for ordering after activation.',
],
);
const settingId = inserted[0].id;
await queryRunner.query(
`INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
VALUES ($1, $2, $3, 0);`,
[settingId, '3', '3 months'],
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM freight.dropdown_settings WHERE code = $1;`,
[this.code],
);
}
}

View File

@@ -0,0 +1,53 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingOrdersService } from './booking-orders.service';
import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
import { GeneralContractService } from './general-contract.service';
@ApiTags('Booking Orders')
@Controller('booking-orders')
export class BookingOrdersController {
constructor(
private readonly ordersService: BookingOrdersService,
private readonly generalContractService: GeneralContractService,
) {}
@Post()
@ApiOperation({ summary: 'Place a drawdown order against a general contract' })
async create(
@Body() dto: CreateBookingOrderDto,
@CurrentUser() user: TCurrentUser,
) {
return this.ordersService.create(dto, user?.id);
}
@Get()
@ApiOperation({ summary: 'List orders placed against a contract' })
async list(@Query('contractBookingId', ParseUUIDPipe) contractBookingId: string) {
return this.ordersService.listByContract(contractBookingId);
}
@Get('contract/:id/pool')
@ApiOperation({
summary: 'Contracted / ordered / remaining quantities for a general contract',
})
async pool(@Param('id', ParseUUIDPipe) id: string) {
return this.generalContractService.getQuantityLines(id);
}
@Get(':id')
@ApiOperation({ summary: 'Get a single booking order' })
async findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.ordersService.findById(id);
}
}

View File

@@ -0,0 +1,30 @@
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { CompaniesModule } from '../companies/companies.module';
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { BookingOrdersController } from './booking-orders.controller';
import { BookingOrdersRepository } from './booking-orders.repository';
import { BookingOrdersService } from './booking-orders.service';
import { BookingOrder } from './entities/booking-order.entity';
import { BookingOrderLine } from './entities/booking-order-line.entity';
import { GeneralContractService } from './general-contract.service';
@Module({
imports: [
TypeOrmModule.forFeature([BookingOrder, BookingOrderLine]),
BookingsModule,
CompaniesModule,
DropdownSettingsModule,
forwardRef(() => TrainSchedulingModule),
],
controllers: [BookingOrdersController],
providers: [
BookingOrdersService,
BookingOrdersRepository,
GeneralContractService,
],
exports: [BookingOrdersService, GeneralContractService],
})
export class BookingOrdersModule {}

View File

@@ -0,0 +1,41 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BookingOrder } from './entities/booking-order.entity';
@Injectable()
export class BookingOrdersRepository extends BaseRepository<BookingOrder> {
constructor(
@InjectRepository(BookingOrder)
repository: Repository<BookingOrder>,
) {
super(repository);
}
/** Orders placed against a given contract, newest first, with their lines. */
findByContract(contractBookingId: string): Promise<BookingOrder[]> {
return this.repository.find({
where: { contractBookingId },
relations: { lines: { containerType: true }, booking: true },
order: { createdAt: 'DESC' },
});
}
override findById(id: string): Promise<BookingOrder | null> {
return this.repository.findOne({
where: { id },
relations: { lines: { containerType: true }, booking: true, contractBooking: true },
});
}
/** Count this calendar year's orders, for reference generation. */
async countByYear(year: number): Promise<number> {
const start = new Date(Date.UTC(year, 0, 1));
const end = new Date(Date.UTC(year + 1, 0, 1));
return this.repository
.createQueryBuilder('o')
.where('o.createdAt >= :start AND o.createdAt < :end', { start, end })
.getCount();
}
}

View File

@@ -0,0 +1,293 @@
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { CompaniesService } from '../companies/companies.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { BookingOrdersRepository } from './booking-orders.repository';
import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
import { BookingOrder } from './entities/booking-order.entity';
import { BookingOrderLine } from './entities/booking-order-line.entity';
import { GeneralContractService } from './general-contract.service';
@Injectable()
export class BookingOrdersService {
private readonly logger = new Logger(BookingOrdersService.name);
constructor(
private readonly dataSource: DataSource,
private readonly ordersRepository: BookingOrdersRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly companiesService: CompaniesService,
private readonly generalContractService: GeneralContractService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
) {}
/** Orders placed against a contract, with their lines and child booking. */
listByContract(contractBookingId: string): Promise<BookingOrder[]> {
return this.ordersRepository.findByContract(contractBookingId);
}
findById(id: string): Promise<BookingOrder | null> {
return this.ordersRepository.findById(id);
}
/**
* Place a drawdown order against an ACTIVE general contract.
*
* Validates the requested quantities against the remaining pool, then spawns a
* ONE_TIME child Booking (PAID + FULLY_EXECUTED, inheriting the contract's
* route/cargo/service) so it flows through the existing train-scheduling
* pipeline. The order row is the ledger entry linking contract → child booking.
*/
async create(
dto: CreateBookingOrderDto,
userId?: string,
): Promise<BookingOrder> {
const contract = await this.bookingsRepository.findById(dto.contractBookingId);
if (!contract) {
throw new NotFoundException(`Contract ${dto.contractBookingId} not found`);
}
if (!this.generalContractService.isGeneralContract(contract)) {
throw new BadRequestException('Booking is not a general contract');
}
if (contract.status !== 'CONTRACT_ACTIVE') {
throw new BadRequestException(
`Contract is ${contract.status} — orders can only be placed against an ACTIVE contract`,
);
}
if (contract.expiresAt && contract.expiresAt.getTime() <= Date.now()) {
throw new BadRequestException('Contract ordering window has expired');
}
// The customer placing the order must own the contract.
if (userId && !(await this.userOwnsContract(userId, contract))) {
throw new BadRequestException('You do not have access to this contract');
}
// Validate the route has a departure on the chosen day.
const day = eatDay(new Date(dto.scheduledDate));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
contract.originYardId,
contract.destinationYardId,
day,
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
// Validate each line against the remaining pool.
const poolLines = await this.generalContractService.getQuantityLines(
contract.id,
);
const isContainer = contract.freightType === 'CONTAINER';
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}` : ''),
);
}
}
// Persist the order + its child shipment booking atomically.
const order = await this.dataSource.transaction(async (manager) => {
const childBooking = await this.spawnChildBooking(contract, dto, manager);
const reference = await this.generateReference();
const orderRow = manager.create(BookingOrder, {
reference,
contractBookingId: contract.id,
bookingId: childBooking.id,
companyId: contract.companyId ?? null,
scheduledDate: new Date(dto.scheduledDate),
status: 'PAID',
schedulingStatus: 'NOT_SCHEDULED',
});
const savedOrder = await manager.save(orderRow);
const lines = dto.lines.map((l) =>
manager.create(BookingOrderLine, {
orderId: savedOrder.id,
containerTypeId: isContainer ? (l.containerTypeId ?? null) : null,
quantity: l.quantity,
}),
);
await manager.save(lines);
savedOrder.lines = lines;
return savedOrder;
});
// Feed the child booking into the day-pool batch so it allocates to a train.
try {
await this.bookingBatchService.processRouteDay({
originYardId: contract.originYardId,
destinationYardId: contract.destinationYardId,
day,
});
} catch (err) {
this.logger.error(
`Batch fill after order ${order.reference} failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
// Close the contract once its pool is exhausted.
if (await this.generalContractService.isExhausted(contract.id)) {
await this.dataSource
.getRepository(Booking)
.update(contract.id, { status: 'CONTRACT_CLOSED' });
this.logger.log(
`Contract ${contract.reference} CLOSED — quantity exhausted`,
);
}
return (await this.ordersRepository.findById(order.id)) ?? order;
}
/**
* Create the ONE_TIME child booking for an order, inheriting the contract's
* shipment context and entering the queue already PAID + FULLY_EXECUTED.
*/
private async spawnChildBooking(
contract: Booking,
dto: CreateBookingOrderDto,
manager: import('typeorm').EntityManager,
): Promise<Booking> {
const reference = await this.generateChildBookingReference();
const now = new Date();
const isContainer = contract.freightType === 'CONTAINER';
// Sum line quantities × the contract's per-unit weight for the child total.
const containerByType = new Map(
(contract.bookingContainers ?? []).map((c) => [c.containerTypeId, c]),
);
let totalWeight = 0;
if (isContainer) {
for (const line of dto.lines) {
const src = containerByType.get(line.containerTypeId ?? '');
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
totalWeight += vgmPerUnit * line.quantity;
}
} else {
totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
}
const child = manager.create(Booking, {
reference,
companyId: contract.companyId ?? null,
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
contractType: contract.contractType,
previousContractId: contract.id,
serviceTypeId: contract.serviceTypeId,
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
equipmentReturn: contract.equipmentReturn,
originYardId: contract.originYardId,
destinationYardId: contract.destinationYardId,
tradeDirection: contract.tradeDirection,
freightType: contract.freightType,
cargoTypeId: contract.cargoTypeId ?? null,
cargoFreeText: contract.cargoFreeText ?? null,
shippingLineId: contract.shippingLineId ?? null,
cargoTotalWeightVgm: totalWeight,
isHazardous: contract.isHazardous,
paymentCurrency: contract.paymentCurrency,
bookingType: 'ONE_TIME',
scheduledDate: new Date(dto.scheduledDate),
// Already covered by the contract's one-time payment: enter the pool ready
// and paid so the batch engine reserves → allocates it immediately.
status: 'FULLY_EXECUTED',
paymentStatus: 'PAID',
fullyExecutedAt: now,
customerSignedAt: now,
priorityScore: contract.priorityScore,
totalAmount: 0,
allowConsolidation: false,
schedulingStatus: 'NOT_SCHEDULED',
});
const savedChild = await manager.save(child);
if (isContainer) {
for (const line of dto.lines) {
const src = containerByType.get(line.containerTypeId ?? '');
const ct = line.containerTypeId
? await manager.getRepository(ContainerType).findOne({
where: { id: line.containerTypeId },
})
: null;
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
const row = manager.create(BookingContainer, {
bookingId: savedChild.id,
containerTypeId: line.containerTypeId ?? null,
quantity: line.quantity,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: vgmPerUnit * line.quantity,
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnit),
isOverweight: false,
});
await manager.save(row);
}
}
return savedChild;
}
private async userOwnsContract(
userId: string,
contract: Booking,
): Promise<boolean> {
if (!contract.companyId) return true; // government / staff-created
try {
const { company } = await this.companiesService.getCompanyInfoByUserId(
userId,
);
return company.id === contract.companyId;
} catch {
return false;
}
}
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.ordersRepository.countByYear(year);
return `ORD-${year}-${String(count + 1).padStart(6, '0')}`;
}
private async generateChildBookingReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.bookingsRepository.countByYear(year);
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
}
}

View File

@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { CargoUnitOfMeasure } from '@edr/types';
/** A single contracted/ordered/remaining pool line for a general contract. */
export class ContractQuantityLineView {
@ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' })
containerTypeId!: string | null;
@ApiProperty({ nullable: true })
containerTypeName!: string | null;
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true })
unitOfMeasure!: CargoUnitOfMeasure | null;
@ApiProperty()
contractedQuantity!: number;
@ApiProperty()
orderedQuantity!: number;
@ApiProperty()
remainingQuantity!: number;
}

View File

@@ -0,0 +1,45 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsDateString,
IsNumber,
IsOptional,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
export class CreateBookingOrderLineDto {
@ApiPropertyOptional({
format: 'uuid',
description: 'Container type for this line (CONTAINER contracts). Omit for bulk/break-bulk.',
})
@IsOptional()
@IsUUID()
containerTypeId?: string;
@ApiProperty({ description: 'Quantity to draw down (containers, tons, or items)', minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
quantity!: number;
}
export class CreateBookingOrderDto {
@ApiProperty({ format: 'uuid', description: 'The general contract to draw down from' })
@IsUUID()
contractBookingId!: string;
@ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' })
@IsDateString()
scheduledDate!: string;
@ApiProperty({ type: [CreateBookingOrderLineDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateBookingOrderLineDto)
lines!: CreateBookingOrderLineDto[];
}

View File

@@ -0,0 +1,30 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { BookingOrder } from './booking-order.entity';
/**
* One drawn-down quantity line of an order. For CONTAINER contracts there is one
* line per container type (matching the contract's pools); for BULK/BREAK_BULK a
* single line with a null containerTypeId carries the tons/items.
*/
@Entity({ schema: 'freight', name: 'booking_order_lines' })
export class BookingOrderLine extends BaseEntity {
@Column({ name: 'order_id', type: 'uuid' })
orderId!: string;
@ManyToOne(() => BookingOrder, (order) => order.lines, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'order_id' })
order?: BookingOrder;
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
containerTypeId?: string | null;
@ManyToOne(() => ContainerType, { nullable: true })
@JoinColumn({ name: 'container_type_id' })
containerType?: ContainerType | null;
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
quantity!: number;
}

View File

@@ -0,0 +1,62 @@
import { BaseEntity } from '@edr/api-common';
import { SchedulingStatus } from '@edr/types';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Company } from '../../companies/entities/company.entity';
import { BookingOrderLine } from './booking-order-line.entity';
/**
* A single drawdown against a general contract. Each order spawns its own
* ONE_TIME child Booking (the shipment that enters the train scheduling
* pipeline); this row is the ledger entry linking the contract to that
* shipment and recording the drawn-down quantities.
*/
@Entity({ schema: 'freight', name: 'booking_orders' })
export class BookingOrder extends BaseEntity {
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
reference!: string;
/** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */
@Column({ name: 'contract_booking_id', type: 'uuid' })
contractBookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'contract_booking_id' })
contractBooking?: Booking;
/** The ONE_TIME child shipment booking spawned for this order. */
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@ManyToOne(() => Booking, { nullable: true })
@JoinColumn({ name: 'booking_id' })
booking?: Booking | null;
/** Denormalized from the contract for fast company-scoped filtering. */
@Column({ name: 'company_id', type: 'uuid', nullable: true })
companyId?: string | null;
@ManyToOne(() => Company, { nullable: true })
@JoinColumn({ name: 'company_id' })
company?: Company | null;
@Column({ name: 'scheduled_date', type: 'timestamptz' })
scheduledDate!: Date;
@Column({ name: 'status', type: 'varchar', length: 40, default: 'PAID' })
status!: string;
@Column({
name: 'scheduling_status',
type: 'varchar',
length: 30,
default: SchedulingStatus.NotScheduled,
})
schedulingStatus!: string;
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;
@OneToMany(() => BookingOrderLine, (line) => line.order, { cascade: true })
lines?: BookingOrderLine[];
}

View File

@@ -0,0 +1,161 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { BookingType, CargoUnitOfMeasure } from '@edr/types';
import { DataSource } from 'typeorm';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingOrder } from './entities/booking-order.entity';
import { ContractQuantityLineView } from './dto/contract-view.dto';
/** Setting code holding the global ordering window (in months) for general contracts. */
export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period';
/** Fallback when the setting is missing or unparseable. */
export const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
/**
* Owns general-contract concerns that sit alongside the generic booking flow:
* the configurable ordering period, post-payment activation, and computing the
* remaining drawdown pool per contract.
*/
@Injectable()
export class GeneralContractService {
private readonly logger = new Logger(GeneralContractService.name);
constructor(
private readonly dataSource: DataSource,
private readonly dropdownSettings: DropdownSettingsService,
) {}
isGeneralContract(booking: Pick<Booking, 'bookingType'>): boolean {
return booking.bookingType === BookingType.GeneralContract;
}
/** The configured ordering window in months (defaults to 3). */
async getPeriodMonths(): Promise<number> {
try {
const setting = await this.dropdownSettings.getByCode(
CONTRACT_PERIOD_SETTING_CODE,
);
const raw = setting.children?.[0]?.value;
const months = Number(raw);
if (Number.isFinite(months) && months > 0) return months;
} catch {
// Setting not seeded yet — fall back to the default.
}
return DEFAULT_CONTRACT_PERIOD_MONTHS;
}
/**
* Called when a general contract's payment succeeds: mark it ACTIVE (instead of
* entering the train queue like a one-time booking) and stamp the ordering
* window. Idempotent.
*/
async activateAfterPayment(bookingId: string): Promise<void> {
const repo = this.dataSource.getRepository(Booking);
const booking = await repo.findOne({ where: { id: bookingId } });
if (!booking || !this.isGeneralContract(booking)) return;
if (booking.status === 'CONTRACT_ACTIVE' || booking.status === 'CONTRACT_CLOSED') {
return;
}
const months = await this.getPeriodMonths();
const expiresAt = new Date();
expiresAt.setMonth(expiresAt.getMonth() + months);
await repo.update(bookingId, {
status: 'CONTRACT_ACTIVE',
paymentStatus: 'PAID',
expiresAt,
});
this.logger.log(
`General contract ${booking.reference} ACTIVE — ordering window ${months} month(s) (expires ${expiresAt.toISOString()})`,
);
}
/**
* The drawdown pool for a contract: contracted vs. ordered vs. remaining,
* per container type for CONTAINER contracts, or a single total line for
* BULK/BREAK_BULK (keyed on a null container type).
*/
async getQuantityLines(
contractBookingId: string,
): Promise<ContractQuantityLineView[]> {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: contractBookingId },
relations: { bookingContainers: { containerType: true }, cargoType: true },
});
if (!booking) throw new NotFoundException(`Contract ${contractBookingId} not found`);
const ordered = await this.orderedByContainerType(contractBookingId);
if (booking.freightType === 'CONTAINER') {
return (booking.bookingContainers ?? []).map((c) => {
const orderedQty = ordered.get(c.containerTypeId ?? '') ?? 0;
const contracted = Number(c.quantity);
return {
containerTypeId: c.containerTypeId ?? null,
containerTypeName: c.containerType?.label ?? null,
unitOfMeasure: null,
contractedQuantity: contracted,
orderedQuantity: orderedQty,
remainingQuantity: Math.max(0, contracted - orderedQty),
};
});
}
// BULK / BREAK_BULK — a single pool keyed on the contracted total weight/items.
const orderedQty = ordered.get('') ?? 0;
const contracted = Number(booking.cargoTotalWeightVgm);
const uom: CargoUnitOfMeasure | null =
(booking.cargoType?.unitOfMeasure as CargoUnitOfMeasure | undefined) ??
CargoUnitOfMeasure.PerTon;
return [
{
containerTypeId: null,
containerTypeName: null,
unitOfMeasure: uom,
contractedQuantity: contracted,
orderedQuantity: orderedQty,
remainingQuantity: Math.max(0, contracted - orderedQty),
},
];
}
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
private async orderedByContainerType(
contractBookingId: string,
): Promise<Map<string, number>> {
const rows = await this.dataSource
.getRepository(BookingOrder)
.createQueryBuilder('o')
.innerJoin('o.lines', 'line')
.select('COALESCE(line.container_type_id::text, :empty)', 'key')
.addSelect('SUM(line.quantity)', 'total')
.where('o.contract_booking_id = :contractBookingId', { contractBookingId })
.andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`)
.setParameter('empty', '')
.groupBy('key')
.getRawMany<{ key: string; total: string }>();
const map = new Map<string, number>();
for (const row of rows) map.set(row.key ?? '', Number(row.total));
return map;
}
/** Convenience: how many units remain for a given container type ('' = bulk). */
async remainingFor(
contractBookingId: string,
containerTypeKey: string,
): Promise<number> {
const lines = await this.getQuantityLines(contractBookingId);
const line = lines.find(
(l) => (l.containerTypeId ?? '') === containerTypeKey,
);
return line?.remainingQuantity ?? 0;
}
/** True once every contracted line is fully drawn down. */
async isExhausted(contractBookingId: string): Promise<boolean> {
const lines = await this.getQuantityLines(contractBookingId);
return lines.every((l) => l.remainingQuantity <= 0);
}
}

View File

@@ -59,6 +59,7 @@ export function buildCargoTypeTree(
name: child.cargoTypeName,
code: child.code,
show_free_text_box: child.showFreeTextBox,
unit_of_measure: child.unitOfMeasure ?? null,
}),
);

View File

@@ -30,6 +30,7 @@ export interface BookingListFilterOptions {
serviceTypeId?: string;
cargoTypeId?: string;
freightType?: string;
bookingType?: string;
tradeDirection?: string;
paymentCurrency?: string;
paymentStatus?: string;
@@ -585,6 +586,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
freightType: options.freightType,
});
}
if (options.bookingType) {
qb.andWhere('booking.booking_type = :bookingType', {
bookingType: options.bookingType,
});
}
if (options.tradeDirection) {
qb.andWhere('booking.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection,

View File

@@ -258,6 +258,7 @@ export class BookingsService {
// }
const isGovernment = dto.isGovernment === true;
const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT';
let companyId: string | null | undefined = dto.companyId;
if (isGovernment) {
@@ -292,11 +293,12 @@ export class BookingsService {
) {
throw new BadRequestException('Selected schedule is not on the booking route');
}
} else {
} else if (!isGeneralContract) {
// Day-level pool: the customer picked a DAY — require that the route has at
// least one OPEN departure on that EAT day. The batch engine assigns the
// train later.
const day = eatDay(new Date(dto.scheduledDate));
// train later. General contracts skip this — they have no shipment date at
// creation; each drawdown order validates its own day.
const day = eatDay(new Date(dto.scheduledDate!));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
dto.originYardId,
@@ -395,7 +397,8 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency,
pnrCode: dto.pnrCode,
financialTerms: dto.financialTerms,
scheduledDate: new Date(dto.scheduledDate),
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
status: 'DRAFT',
@@ -647,6 +650,7 @@ export class BookingsService {
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,
@@ -815,6 +819,7 @@ export class BookingsService {
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,

View File

@@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { CargoUnitOfMeasure } from '@edr/types';
export class BookingReferenceYardDto {
@ApiProperty({ format: 'uuid' })
@@ -73,6 +74,9 @@ export class BookingReferenceCargoTypeChildDto {
@ApiProperty()
show_free_text_box!: boolean;
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false })
unit_of_measure?: CargoUnitOfMeasure | null;
}
export class BookingReferenceCargoTypeGroupDto {

View File

@@ -17,7 +17,7 @@ import {
ValidateIf,
ValidateNested,
} from 'class-validator';
import { BOOKING_STATUSES, FREIGHT_TYPES } from '../entities/booking.entity';
import { BOOKING_STATUSES, BOOKING_TYPES, FREIGHT_TYPES } from '../entities/booking.entity';
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
@@ -27,6 +27,7 @@ const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
export {
BOOKING_STATUSES,
BOOKING_TYPES,
CONTRACT_TYPES,
EQUIPMENT_RETURNS,
FREIGHT_TYPES,
@@ -105,10 +106,24 @@ export class CreateBookingDto {
@IsUUID()
trainScheduleId?: string;
/** The day the customer wants to ship (the pool day key). */
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
@ApiPropertyOptional({
enum: BOOKING_TYPES,
default: 'ONE_TIME',
description:
'ONE_TIME (default) for a normal booking; GENERAL_CONTRACT for an umbrella contract drawn down by orders.',
})
@IsOptional()
@IsIn([...BOOKING_TYPES])
bookingType?: string;
/**
* The day the customer wants to ship (the pool day key). Required for one-time
* bookings; omitted for general contracts, which pick the date per order.
*/
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
@ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT')
@IsDateString()
scheduledDate!: string;
scheduledDate?: string;
@ApiProperty({ enum: CONTRACT_TYPES })
@IsIn([...CONTRACT_TYPES])

View File

@@ -3,6 +3,7 @@ import { Transform } from 'class-transformer';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
import {
BOOKING_STATUSES,
BOOKING_TYPES,
FREIGHT_TYPES,
PAYMENT_CURRENCIES,
TRADE_DIRECTIONS,
@@ -56,6 +57,11 @@ export class FilterBookingDto {
@IsIn([...FREIGHT_TYPES])
freightType?: string;
@ApiPropertyOptional({ enum: BOOKING_TYPES, description: 'ONE_TIME or GENERAL_CONTRACT' })
@IsOptional()
@IsIn([...BOOKING_TYPES])
bookingType?: string;
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
@IsOptional()
@IsIn([...TRADE_DIRECTIONS])

View File

@@ -41,10 +41,15 @@ export const BOOKING_STATUSES = [
'CANCELLED',
'PENDING_CONSOLIDATION',
'CONSOLIDATED',
'CONTRACT_ACTIVE',
'CONTRACT_CLOSED',
] as const;
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
export const BOOKING_TYPES = ['ONE_TIME', 'GENERAL_CONTRACT'] as const;
export type BookingTypeValue = (typeof BOOKING_TYPES)[number];
export const PAYMENT_STATUSES = [
'PENDING',
'PNR_GENERATED',
@@ -125,8 +130,28 @@ export class Booking extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
status!: string;
@Column({ name: 'scheduled_date', type: 'timestamptz' })
scheduledDate!: Date;
/**
* ONE_TIME for a normal single-shipment booking; GENERAL_CONTRACT for an
* umbrella contract that is signed/paid once and then drawn down by many
* orders (each order spawns its own ONE_TIME child booking).
*/
@Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' })
bookingType!: string;
/**
* Nullable: general contracts have no shipment date at creation — the date is
* chosen per drawdown order. One-time bookings always set this (the pool day key).
*/
@Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true })
scheduledDate?: Date | null;
/**
* General contracts only: when the ordering window closes, computed from the
* global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time
* bookings and for contracts that are not yet active.
*/
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt?: Date | null;
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
totalAmount!: number;

View File

@@ -18,6 +18,7 @@ import { PaymentEventsConsumer } from "./payment-events.consumer";
import { InternalPaymentController } from "./internal-payment.controller";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
@@ -27,6 +28,7 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
imports: [
HttpModule.register({ timeout: 10_000 }),
ConfigModule,
DropdownSettingsModule,
forwardRef(() => TrainSchedulingModule),
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
RabbitMQModule.forRootAsync({

View File

@@ -34,6 +34,11 @@ import {
RefundDto,
} from "./payments.dto";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service";
/** Setting code holding the global ordering window (months) for general contracts. */
const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period";
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
@@ -54,8 +59,23 @@ export class PaymentService {
private readonly paymentClient: PaymentClientService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
private readonly dropdownSettings: DropdownSettingsService,
) { }
/** Configured general-contract ordering window in months (defaults to 3). */
private async contractPeriodMonths(): Promise<number> {
try {
const setting = await this.dropdownSettings.getByCode(
CONTRACT_PERIOD_SETTING_CODE,
);
const months = Number(setting.children?.[0]?.value);
if (Number.isFinite(months) && months > 0) return months;
} catch {
// Setting not seeded — fall back to the default.
}
return DEFAULT_CONTRACT_PERIOD_MONTHS;
}
async getAll(filters: {
search?: string;
status?: string;
@@ -293,15 +313,44 @@ export class PaymentService {
const paidAt = input.paidAt ?? new Date();
// A general contract is paid once, up front; it does NOT enter the train
// queue (nothing has been ordered yet). Instead it becomes ACTIVE and
// opens its ordering window. Orders placed later spawn their own paid
// child bookings that go through the normal pipeline.
const booking = await this.datasource
.getRepository(Booking)
.findOne({ where: { id: input.bookingId } });
const isGeneralContract = booking?.bookingType === "GENERAL_CONTRACT";
let contractExpiresAt: Date | null = null;
if (isGeneralContract) {
const months = await this.contractPeriodMonths();
contractExpiresAt = new Date(paidAt);
contractExpiresAt.setMonth(contractExpiresAt.getMonth() + months);
}
await this.datasource.transaction(async (mg) => {
await mg.update(
PaymentEntity,
{ id: intent.id },
{ status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
);
await mg.update(Booking, { id: input.bookingId }, { paymentStatus: "PAID" ,status:"PAID"});
await mg.update(
Booking,
{ id: input.bookingId },
isGeneralContract
? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
: { paymentStatus: "PAID", status: "PAID" },
);
});
if (isGeneralContract) {
this.logger.log(
`General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`,
);
return { alreadyFinalized: false };
}
try {
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
} catch (err) {

View File

@@ -1,5 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { CargoUnitOfMeasure } from '@edr/types';
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateCargoTypeDto {
@ApiProperty({ description: 'Cargo type display name', maxLength: 255 })
@@ -7,6 +8,14 @@ export class CreateCargoTypeDto {
@MaxLength(255)
cargoTypeName!: string;
@ApiPropertyOptional({
enum: CargoUnitOfMeasure,
description: 'How this cargo is measured (PER_TON for bulk, PER_ITEM for break-bulk)',
})
@IsOptional()
@IsEnum(CargoUnitOfMeasure)
unitOfMeasure?: CargoUnitOfMeasure;
@ApiPropertyOptional({ description: 'Parent group ID for hierarchical cargo types' })
@IsOptional()
@IsUUID()

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common';
import { CargoUnitOfMeasure } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
@Entity({ schema: 'freight', name: 'cargo_types' })
@@ -19,6 +20,14 @@ export class CargoType extends BaseEntity {
@Column({ name: 'show_free_text_box', type: 'boolean', default: false })
showFreeTextBox!: boolean;
/**
* How this cargo's quantity is measured: PER_TON (bulk) or PER_ITEM
* (break-bulk). Nullable for container/legacy cargo, which is counted by
* container. Drives the unit shown when ordering against a general contract.
*/
@Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true })
unitOfMeasure?: CargoUnitOfMeasure | null;
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
requiresDirectorApproval!: boolean;

View File

@@ -82,6 +82,7 @@ export class CargoTypesService {
showFreeTextBox: dto.showFreeTextBox ?? false,
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
isActive: dto.isActive ?? true,
unitOfMeasure: dto.unitOfMeasure ?? null,
displayOrder,
});
}

View File

@@ -1,7 +1,9 @@
export interface SchedulingPriorityBooking {
isGovernment?: boolean;
priorityScore?: number | null;
scheduledDate: Date | string;
// One-time bookings always carry a date; general contracts (never scheduled)
// may be null — treated as epoch 0 so they sort last.
scheduledDate?: Date | string | null;
}
/** Government first, then priority score, then earliest scheduled date. */
@@ -15,5 +17,7 @@ export function compareSchedulingPriority(
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
if (priorityDiff !== 0) return priorityDiff;
return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime();
const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0;
const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0;
return aTime - bTime;
}

View File

@@ -30,7 +30,9 @@ export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
if (priorityDiff !== 0) return priorityDiff;
return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime();
const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0;
const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0;
return aTime - bTime;
});
}

View File

@@ -1880,7 +1880,7 @@ export class TrainSchedulingService {
origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
destination:
booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
preferredDepartureDate: booking.scheduledDate.toISOString(),
preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null,
status: booking.status,
};
}

View File

@@ -2,6 +2,7 @@ import { AppLayout, type SidebarItem } from "@/components/AppLayout";
import {
CalendarCheck,
Home,
Layers,
Loader2,
MapPin,
Receipt,
@@ -35,6 +36,8 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import EditBookingPage from "./pages/bookings/EditBookingPage";
import MyBookings from "./pages/bookings/MyBookings";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import ContractsList from "./pages/contracts/ContractsList";
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
@@ -199,6 +202,11 @@ const sidebarItems: SidebarItem[] = [
href: "/bookings",
icon: <CalendarCheck size={18} />,
},
{
label: "General Contracts",
href: "/contracts",
icon: <Layers size={18} />,
},
{
label: "Tracking",
href: "/tracking",
@@ -287,6 +295,8 @@ const App = () => {
path="/bookings/:id/contract"
element={<BookingContractPage />}
/>
<Route path="/contracts" element={<ContractsList />} />
<Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<BillingPage />} />
{/* Profile was merged into Settings — keep old links working. */}

View File

@@ -1,4 +1,5 @@
import { api } from "@/services/api";
import { Freight } from "@edr/types";
import { hasAllRequiredDocuments } from "@/services/booking-form-data";
import type {
CreateBookingPayload,
@@ -175,6 +176,30 @@ export default function NewBookingPage() {
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const bookingType = form.watch("bookingType");
const isGeneralContract = bookingType === "general_contract";
// General contracts have no shipment date at creation — the Schedule step
// (id 5) is skipped; the date is chosen per order against the contract later.
const visibleSteps = useMemo(
() => STEPS.filter((s) => !(isGeneralContract && s.id === 5)),
[isGeneralContract],
);
const visibleStepIds = useMemo<number[]>(
() => visibleSteps.map((s) => s.id),
[visibleSteps],
);
const currentStepIndex = visibleStepIds.indexOf(step);
const isLastStep = currentStepIndex === visibleStepIds.length - 1;
const isFirstStep = currentStepIndex <= 0;
const goToStep = (delta: number) => {
const idx = visibleStepIds.indexOf(step);
const nextIdx = Math.min(
visibleStepIds.length - 1,
Math.max(0, idx + delta),
);
setStep(visibleStepIds[nextIdx]);
};
const direction = useMemo(() => {
const origin = referenceData?.yard.find((y) => y.id === originYard);
@@ -210,7 +235,7 @@ export default function NewBookingPage() {
return;
}
setStep((currentStep) => Math.min(STEPS.length, currentStep + 1));
goToStep(1);
}
function buildApiPayload(data: BookingFormValues): CreateBookingPayload {
@@ -259,10 +284,20 @@ export default function NewBookingPage() {
(s) => s.id === data.serviceTypeId,
)!;
const isContract = data.bookingType === "general_contract";
return {
scheduledDate: data.scheduledDate
? new Date(data.scheduledDate).toISOString()
: new Date().toISOString(),
bookingType: isContract
? Freight.BookingType.GeneralContract
: Freight.BookingType.OneTime,
// General contracts omit the shipment date — chosen per order later.
...(isContract
? {}
: {
scheduledDate: data.scheduledDate
? new Date(data.scheduledDate).toISOString()
: new Date().toISOString(),
}),
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
serviceTypeId: data.serviceTypeId,
@@ -408,7 +443,7 @@ export default function NewBookingPage() {
>
<Box flex={1} p="24px">
<Box mb="lg">
<StepIndicator step={step} />
<StepIndicator step={step} steps={visibleSteps} />
</Box>
{persistAndPriceMutation.isError && (
@@ -494,13 +529,13 @@ export default function NewBookingPage() {
variant="default"
radius="md"
leftSection={<ChevronLeft size={16} />}
onClick={() => setStep((s) => Math.max(1, s - 1))}
disabled={step === 1}
onClick={() => goToStep(-1)}
disabled={isFirstStep}
>
Back
</Button>
{step < STEPS.length ? (
{!isLastStep ? (
<Button
type="button"
color="edr-green"

View File

@@ -8,10 +8,18 @@ const BORDER = "var(--mantine-color-edr-border-0)";
const MUTED = "var(--mantine-color-edr-muted-0)";
const INK = "var(--mantine-color-edr-text-0)";
export function StepIndicator({ step }: { step: number }) {
type StepItem = (typeof STEPS)[number];
export function StepIndicator({
step,
steps = STEPS as readonly StepItem[],
}: {
step: number;
steps?: readonly StepItem[];
}) {
return (
<div className="flex items-start">
{STEPS.map((item, index) => {
{steps.map((item, index) => {
const done = step > item.id;
const active = step === item.id;
return (
@@ -67,7 +75,7 @@ export function StepIndicator({ step }: { step: number }) {
{item.short}
</span>
</div>
{index < STEPS.length - 1 && (
{index < steps.length - 1 && (
<div
style={{
flex: 1,

View File

@@ -81,8 +81,13 @@ export const PAYMENT_CURRENCY_OPTIONS: Array<{
},
];
export const BOOKING_TYPES = ["one_time", "general_contract"] as const;
export type BookingTypeOption = (typeof BOOKING_TYPES)[number];
export const bookingFormSchema = z
.object({
// One-time booking vs. a general contract (umbrella, drawn down by orders).
bookingType: z.enum(BOOKING_TYPES).default("one_time"),
contractType: z.enum(["new", "renewal"], "Select a contract type."),
previousContractRef: z.string(),
serviceTypeId: z.string("Select a service type."),
@@ -115,7 +120,9 @@ export const bookingFormSchema = z
shippingLine: z.string(),
// Day-level pool: the customer selects only a DAY. The batch engine assigns
// the specific train later, so no trainScheduleId is collected here.
scheduledDate: z.string().min(1, "Select a shipment date."),
// Optional in the base schema — required for one-time bookings via the
// superRefine below; general contracts pick the date per order instead.
scheduledDate: z.string().default(""),
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(),
cargoTypePath: z.array(z.string()).default([]),
@@ -188,6 +195,14 @@ export const bookingFormSchema = z
{ message: "Add at least one container.", path: ["containers"] },
)
.superRefine((data, ctx) => {
// One-time bookings must pick a shipment date; general contracts must not.
if (data.bookingType !== "general_contract" && !data.scheduledDate.trim()) {
ctx.addIssue({
code: "custom",
path: ["scheduledDate"],
message: "Select a shipment date.",
});
}
if (data.cargoType === "bulk") {
if (!data.cargoTypePath[0]) {
ctx.addIssue({
@@ -222,6 +237,7 @@ export type BookingFormValues = z.infer<typeof bookingFormSchema>;
export type BookingFormInputValues = z.input<typeof bookingFormSchema>;
export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
bookingType: "one_time",
previousContractRef: "",
serviceTypeId: "",
@@ -252,7 +268,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
};
export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
1: ["contractType", "previousContractRef"],
1: ["bookingType", "contractType", "previousContractRef"],
2: [
"serviceTypeId",
"paymentCurrency",

View File

@@ -1,7 +1,7 @@
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
import { useQuery } from "@tanstack/react-query";
import { FileText, RefreshCw } from "lucide-react";
import { CalendarClock, FileText, Layers, RefreshCw } from "lucide-react";
import { useMemo, useState } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { BookingFormInputValues, type BookingFormValues } from "./schema";
@@ -14,7 +14,7 @@ import {
StepHeader,
} from "./shared";
import { FileSignature } from "lucide-react";
import { Stack } from "@mantine/core";
import { Divider, Stack, Text } from "@mantine/core";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -187,10 +187,46 @@ export function Step1ContractType({
<StepCard>
<StepHeader
icon={<FileSignature size={22} />}
title="Contract Type"
description="Start a new contract or renew an existing one to reuse its details."
title="Booking Type"
description="Choose a one-time shipment or a general contract you can draw down from over time."
/>
<Controller
name="bookingType"
control={form.control}
render={({ field }) => (
<div className="grid gap-4 md:grid-cols-2">
<OptionCard
selected={field.value !== "general_contract"}
icon={<CalendarClock className="h-5 w-5" />}
iconBg="#ECF6F1"
iconColor="#0A6F4D"
title="One-Time Booking"
description="A single shipment with a chosen ship date — the standard flow."
onClick={() => field.onChange("one_time")}
/>
<OptionCard
selected={field.value === "general_contract"}
icon={<Layers className="h-5 w-5" />}
iconBg="#F1ECFB"
iconColor="#6A40B8"
title="General Contract"
description="Reserve a total quantity once, then place multiple orders against it until it runs out."
onClick={() => field.onChange("general_contract")}
/>
</div>
)}
/>
<Divider my={24} />
<Text fw={700} fz={15} mb={4} style={{ color: "#10202F" }}>
Contract Type
</Text>
<Text fz={13} c="edr-muted" mb={16}>
Start a fresh contract or renew an existing one to reuse its details.
</Text>
<Controller
name="contractType"
control={form.control}

View File

@@ -72,6 +72,11 @@ export function Step5CargoDetails({
return group?.children?.find((c) => c.id === childId) ?? null;
}, [referenceData, parentId, childId]);
// Unit of measure for bulk/break-bulk cargo: PER_ITEM → "Items", else "Tons".
// Drives the weight/quantity label so customers enter the right unit.
const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM";
const bulkUnitLabel = isPerItem ? "Items" : "Tons";
const freightTypeGroups = useMemo(() => {
if (!referenceData?.cargo_type) return [];
return referenceData.cargo_type.filter(
@@ -194,14 +199,18 @@ export function Step5CargoDetails({
{...field}
id="cargoWeight"
type="number"
label="Total Cargo Weight (Tons) *"
placeholder="0.00"
label={
cargoType === "bulk"
? `Total Cargo Quantity (${bulkUnitLabel}) *`
: "Total Cargo Weight (Tons) *"
}
placeholder={isPerItem ? "0" : "0.00"}
leftSection={<Weight className="h-4 w-4" />}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
min={0}
step={0.01}
step={isPerItem ? 1 : 0.01}
/>
)}
/>

View File

@@ -0,0 +1,291 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Card,
Center,
Group,
Loader,
Paper,
Progress,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import {
ArrowLeft,
CalendarClock,
Layers,
MapPin,
PackagePlus,
Ship,
} from "lucide-react";
import { api } from "@/services/api";
import { PayNowButton } from "../bookings/payments/PayNowButton";
import {
BORDER,
ContractStatusBadge,
formatQuantity,
GREEN,
INK,
MetaItem,
MUTED,
} from "./contract-ui";
import { PlaceOrderDialog } from "./PlaceOrderDialog";
export default function ContractDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [orderOpen, setOrderOpen] = useState(false);
const {
data: contract,
isLoading,
isError,
} = useQuery(api.bookings.get.queryOptions({ input: { id: id! }, enabled: !!id }));
const { data: pool } = useQuery({
...api.bookingOrders.pool.queryOptions({
input: { contractBookingId: id! },
}),
enabled: !!id && contract?.status !== "DRAFT",
});
const { data: orders } = useQuery({
...api.bookingOrders.listByContract.queryOptions({
input: { contractBookingId: id! },
}),
enabled: !!id && contract?.status !== "DRAFT",
});
if (isLoading) {
return (
<Center mih={400} p="xl">
<Stack align="center" gap="md">
<Loader color="edr-green" />
<Text size="sm" c="dimmed">
Loading contract
</Text>
</Stack>
</Center>
);
}
if (isError || !contract) {
return (
<Box p="xl">
<Paper withBorder radius="lg" p="xl" style={{ borderColor: BORDER }}>
<Text fw={700} mb="xs">
Contract not found
</Text>
<Button variant="default" onClick={() => navigate("/contracts")}>
Back to contracts
</Button>
</Paper>
</Box>
);
}
const isContainer = contract.freightType === "CONTAINER";
const isActive = contract.status === "CONTRACT_ACTIVE";
const awaitingPayment = contract.status === "FULLY_EXECUTED";
const poolLines = pool ?? [];
return (
<Box style={{ padding: "28px 32px 40px" }}>
<Stack gap="lg">
{/* Header */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="center" wrap="nowrap">
<Button
variant="subtle"
color="gray"
radius="md"
px={8}
onClick={() => navigate("/contracts")}
>
<ArrowLeft size={18} />
</Button>
<Group gap={12} wrap="nowrap" align="center">
<ThemeIcon size={46} radius="md" variant="light" color="violet">
<Layers size={22} />
</ThemeIcon>
<div>
<Group gap={10} align="center">
<Title order={2} fw={800} fz={22} style={{ color: INK }}>
{contract.reference}
</Title>
<ContractStatusBadge status={contract.status} />
</Group>
<Text size="sm" c="dimmed" mt={2}>
General contract · {isContainer ? "Containerised" : "Bulk"}
</Text>
</div>
</Group>
</Group>
<Group gap="sm">
{awaitingPayment && <PayNowButton booking={contract} label="Pay & activate" size="sm" />}
{isActive && (
<Button
color="edr-green"
radius="md"
leftSection={<PackagePlus size={16} />}
onClick={() => setOrderOpen(true)}
>
Place order
</Button>
)}
</Group>
</Group>
{/* Summary */}
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Group gap={48} wrap="wrap">
<MetaItem
label="Route"
icon={<MapPin size={15} color={MUTED} />}
value={`${contract.originYard?.label ?? "—"}${contract.destinationYard?.label ?? "—"}`}
/>
<MetaItem
label="Ordering until"
icon={<CalendarClock size={15} color={MUTED} />}
value={
contract.expiresAt
? new Date(contract.expiresAt).toLocaleDateString()
: "Not active yet"
}
/>
<MetaItem
label="Trade direction"
icon={<Ship size={15} color={MUTED} />}
value={contract.tradeDirection ?? "—"}
/>
</Group>
</Paper>
{/* Drawdown pool */}
{contract.status !== "DRAFT" && (
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Text fw={700} fz={16} mb={4} style={{ color: INK }}>
Contracted quantity
</Text>
<Text fz={13} c="dimmed" mb="lg">
How much of this contract has been ordered versus what remains.
</Text>
<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";
return (
<div key={line.containerTypeId ?? `bulk-${i}`}>
<Group justify="space-between" mb={6}>
<Text fz={14} fw={600} style={{ color: INK }}>
{label}
</Text>
<Text fz={13} c="dimmed">
<Text span fw={700} style={{ color: GREEN }}>
{formatQuantity(
line.remainingQuantity,
line.unitOfMeasure,
isContainer,
)}
</Text>{" "}
remaining of{" "}
{formatQuantity(
line.contractedQuantity,
line.unitOfMeasure,
isContainer,
)}
</Text>
</Group>
<Progress
value={pct}
color="edr-green"
size="md"
radius="xl"
/>
</div>
);
})}
</Stack>
</Card>
)}
{/* Orders */}
<Card withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Text fw={700} fz={16} mb="md" style={{ color: INK }}>
Orders ({orders?.length ?? 0})
</Text>
{!orders || orders.length === 0 ? (
<Text fz={13} c="dimmed">
{isActive
? "No orders yet. Use “Place order” to draw down from this contract."
: "Orders can be placed once the contract is active (paid)."}
</Text>
) : (
<Stack gap={0}>
{orders.map((order, idx) => (
<Box
key={order.id}
py="sm"
style={{
borderTop: idx === 0 ? undefined : `1px solid ${BORDER}`,
}}
>
<Group justify="space-between" wrap="nowrap">
<div>
<Text fz={14} fw={700} style={{ color: INK }}>
{order.reference}
</Text>
<Text fz={12} c="dimmed">
Ship {new Date(order.scheduledDate).toLocaleDateString()}
{" · "}
{order.lines
.map(
(l) =>
`${Number.isInteger(l.quantity) ? l.quantity : l.quantity.toFixed(2)}${
l.containerTypeName ? ` ${l.containerTypeName}` : ""
}`,
)
.join(", ")}
</Text>
</div>
<ContractStatusBadge status={order.status} />
</Group>
</Box>
))}
</Stack>
)}
</Card>
</Stack>
<PlaceOrderDialog
opened={orderOpen}
onClose={() => setOrderOpen(false)}
contract={contract}
pool={poolLines}
onPlaced={() => setOrderOpen(false)}
/>
</Box>
);
}

View File

@@ -0,0 +1,247 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Box,
Button,
Card,
Group,
Paper,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { Layers, Plus, Search } from "lucide-react";
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
import type { Freight } from "@edr/types";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
} from "@edr/ui-common";
import { ContractStatusBadge, GREEN, INK, MUTED } from "./contract-ui";
export default function ContractsList() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const filter: BookingListFilter = useMemo(
() => ({
bookingType: "GENERAL_CONTRACT",
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy: "createdAt",
sortOrder: "DESC",
}),
[pagination.pageIndex, pagination.pageSize],
);
const { data, isLoading, isError } = useQuery(
api.bookings.list.queryOptions({ input: filter }),
);
const rows = useMemo(() => {
const items = data?.items ?? [];
if (!query.trim()) return items;
const q = query.toLowerCase();
return items.filter(
(b) =>
b.reference?.toLowerCase().includes(q) ||
b.originYard?.label?.toLowerCase().includes(q) ||
b.destinationYard?.label?.toLowerCase().includes(q),
);
}, [data, query]);
const activeCount = useMemo(
() =>
(data?.items ?? []).filter((b) => b.status === "CONTRACT_ACTIVE").length,
[data],
);
const columns: ColumnDef<Freight.IBooking>[] = [
{
id: "reference",
header: () => <ColHeader label="Contract" />,
cell: ({ row }) => {
const b = row.original;
return (
<Group gap={12} wrap="nowrap" align="center">
<ThemeIcon
size={38}
radius="md"
variant="light"
color="violet"
style={{ flexShrink: 0 }}
>
<Layers size={18} />
</ThemeIcon>
<div>
<Text fz={14} fw={700} style={{ color: INK }}>
{b.reference}
</Text>
<Text fz={12} c="dimmed">
{b.freightType === "CONTAINER" ? "Containerised" : "Bulk"}
</Text>
</div>
</Group>
);
},
},
{
id: "route",
header: () => <ColHeader label="Route" />,
cell: ({ row }) => {
const b = row.original;
return (
<Text fz={13} style={{ color: INK }}>
{b.originYard?.label ?? "—"}{" "}
<Text span c="dimmed">
</Text>{" "}
{b.destinationYard?.label ?? "—"}
</Text>
);
},
},
{
id: "expires",
header: () => <ColHeader label="Ordering Until" />,
cell: ({ row }) => {
const exp = row.original.expiresAt;
return (
<Text fz={13} c={exp ? undefined : "dimmed"} style={{ color: exp ? INK : undefined }}>
{exp ? new Date(exp).toLocaleDateString() : "—"}
</Text>
);
},
},
{
id: "status",
header: () => <ColHeader label="Status" />,
cell: ({ row }) => <ContractStatusBadge status={row.original.status} />,
},
];
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
const total = data?.meta?.total ?? (data?.items?.length ?? 0);
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
return (
<Box style={{ padding: "28px 32px 32px" }}>
<Stack gap="lg">
{/* Header */}
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
<Box>
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
General Contracts
</Title>
<Text size="sm" c="edr-muted" mt={4}>
Reserve a quantity once, then place orders against it until the
contract runs out or its window closes.
</Text>
</Box>
<Button
color="edr-green"
radius="md"
leftSection={<Plus size={16} />}
onClick={() => navigate("/bookings/new")}
>
New Contract
</Button>
</Group>
{/* Summary */}
<SimpleStat
label="Active contracts"
value={activeCount}
hint="accepting orders"
/>
{/* Search */}
<TextInput
placeholder="Search by reference or route…"
leftSection={<Search size={16} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
radius="md"
styles={{ input: { height: 44 } }}
maw={420}
/>
{/* Table */}
<Card p={0} style={{ overflow: "hidden" }}>
<DataTable
columns={columns}
data={rows}
status={dataTableStatus}
onRowClick={(row) =>
navigate(`/contracts/${(row as Freight.IBooking).id}`)
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
footer={DataTableFooter}
emptyMessage="No general contracts yet. Create one from New Booking → General Contract."
/>
</Card>
</Stack>
</Box>
);
}
function ColHeader({ label }: { label: string }) {
return (
<Text fz={12} fw={700} c="dimmed" style={{ letterSpacing: 0.3 }}>
{label}
</Text>
);
}
function SimpleStat({
label,
value,
hint,
}: {
label: string;
value: number | string;
hint?: string;
}) {
return (
<Paper
withBorder
radius="lg"
p="md"
maw={260}
style={{ borderColor: "#E6ECF2" }}
>
<Text fz={12} fw={600} c="dimmed">
{label}
</Text>
<Group gap={8} align="baseline" mt={2}>
<Text fz={28} fw={800} style={{ color: GREEN }}>
{value}
</Text>
{hint && (
<Text fz={12} style={{ color: MUTED }}>
{hint}
</Text>
)}
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,254 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Button,
Group,
Modal,
NumberInput,
Select,
Stack,
Text,
} from "@mantine/core";
import { AlertCircle, CalendarDays, PackagePlus } from "lucide-react";
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
import { formatQuantity, GREEN, INK } from "./contract-ui";
interface PlaceOrderDialogProps {
opened: boolean;
onClose: () => void;
contract: Freight.IBooking;
pool: Freight.ContractQuantityLine[];
onPlaced: () => void;
}
/**
* Place a drawdown order against an ACTIVE general contract. The customer picks
* a shipment day (constrained to days with a departure on the contract's route)
* and a quantity per pool line, validated against the remaining quantity.
*/
export function PlaceOrderDialog({
opened,
onClose,
contract,
pool,
onPlaced,
}: PlaceOrderDialogProps) {
const queryClient = useQueryClient();
const isContainer = contract.freightType === "CONTAINER";
const [scheduledDate, setScheduledDate] = useState<string | null>(null);
const [quantities, setQuantities] = useState<Record<string, number | "">>({});
const { data: availableDays, isLoading: daysLoading } = useQuery({
...api.bookings.getAvailableDays.queryOptions({
input: {
originYardId: contract.originYard?.id,
destinationYardId: contract.destinationYard?.id,
},
}),
enabled: opened && !!contract.originYard?.id && !!contract.destinationYard?.id,
});
const dayOptions = useMemo(
() =>
(availableDays ?? []).map((d) => ({
value: d,
label: new Date(d).toLocaleDateString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
}),
})),
[availableDays],
);
const lineKey = (line: Freight.ContractQuantityLine) =>
line.containerTypeId ?? "__bulk__";
const createMutation = useMutation({
...api.bookingOrders.create.mutationOptions(),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: api.bookingOrders.listByContract.queryKey({
contractBookingId: contract.id,
}),
});
queryClient.invalidateQueries({
queryKey: api.bookingOrders.pool.queryKey({
contractBookingId: contract.id,
}),
});
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: contract.id }),
});
reset();
onPlaced();
},
});
function reset() {
setScheduledDate(null);
setQuantities({});
}
function handleClose() {
if (createMutation.isPending) return;
reset();
onClose();
}
function handleSubmit() {
if (!scheduledDate) return;
const lines: Freight.CreateBookingOrderLineDto[] = pool
.map((line) => {
const raw = quantities[lineKey(line)];
const qty = typeof raw === "number" ? raw : 0;
return {
containerTypeId: isContainer ? line.containerTypeId : null,
quantity: qty,
};
})
.filter((l) => l.quantity > 0);
if (lines.length === 0) return;
createMutation.mutate({
contractBookingId: contract.id,
scheduledDate: new Date(scheduledDate).toISOString(),
lines,
});
}
const orderableLines = pool.filter((l) => l.remainingQuantity > 0);
const hasQuantity = pool.some((l) => {
const raw = quantities[lineKey(l)];
return typeof raw === "number" && raw > 0;
});
const canSubmit = !!scheduledDate && hasQuantity && !createMutation.isPending;
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Group gap={8}>
<PackagePlus size={18} color={GREEN} />
<Text fw={700} style={{ color: INK }}>
Place an order
</Text>
</Group>
}
radius="lg"
centered
size="md"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Draw down from contract <strong>{contract.reference}</strong>. Route,
cargo and service are inherited just pick a shipment date and
quantity.
</Text>
<Select
label="Shipment date"
placeholder={daysLoading ? "Loading available days…" : "Select a day"}
data={dayOptions}
value={scheduledDate}
onChange={setScheduledDate}
disabled={daysLoading}
radius="md"
leftSection={<CalendarDays size={16} />}
nothingFoundMessage="No departures on this route"
searchable
comboboxProps={{ withinPortal: true }}
styles={{ input: { height: 44 } }}
/>
<Stack gap="sm">
<Text fz={13} fw={600} style={{ color: INK }}>
Quantity
</Text>
{orderableLines.length === 0 && (
<Alert color="gray" radius="md" icon={<AlertCircle size={16} />}>
This contract is fully drawn down no quantity remains.
</Alert>
)}
{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>
{createMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
{createMutation.error instanceof Error
? createMutation.error.message
: "Failed to place the order. Please try again."}
</Alert>
)}
<Group justify="flex-end" gap="sm" mt="xs">
<Button
variant="default"
radius="md"
onClick={handleClose}
disabled={createMutation.isPending}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<PackagePlus size={16} />}
onClick={handleSubmit}
disabled={!canSubmit}
loading={createMutation.isPending}
>
Place order
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,91 @@
import { Badge, Group, Text } from "@mantine/core";
import type { ReactNode } from "react";
// Brand palette (mirrors the booking form's shared constants).
export const INK = "#10202F";
export const MUTED = "#6B7C8E";
export const GREEN = "#0EA371";
export const GREEN_DARK = "#0A6F4D";
export const BORDER = "#E6ECF2";
/** Visual config for a general-contract status. */
export const CONTRACT_STATUS_CONFIG: Record<
string,
{ label: string; color: string; bg: string }
> = {
DRAFT: { label: "Draft", color: "#6B7C8E", bg: "#EEF2F6" },
SUBMITTED: { label: "Submitted", color: "#2E5B96", bg: "#EAF1FB" },
PENDING_APPROVAL: { label: "Pending Approval", color: "#9A6700", bg: "#FFF6E5" },
APPROVED_PENDING_SIGNATURE: { label: "Awaiting Signature", color: "#9A6700", bg: "#FFF6E5" },
CONTRACT_READY: { label: "Ready to Sign", color: "#2E5B96", bg: "#EAF1FB" },
SIGNED_CUSTOMER: { label: "Signed", color: "#2E5B96", bg: "#EAF1FB" },
FULLY_EXECUTED: { label: "Awaiting Payment", color: "#9A6700", bg: "#FFF6E5" },
CONTRACT_ACTIVE: { label: "Active", color: "#0A6F4D", bg: "#E7F6EE" },
CONTRACT_CLOSED: { label: "Closed", color: "#6B7C8E", bg: "#EEF2F6" },
EXPIRED: { label: "Expired", color: "#B42318", bg: "#FEECEB" },
CANCELLED: { label: "Cancelled", color: "#B42318", bg: "#FEECEB" },
REJECTED: { label: "Rejected", color: "#B42318", bg: "#FEECEB" },
};
export function ContractStatusBadge({ status }: { status: string }) {
const cfg =
CONTRACT_STATUS_CONFIG[status] ?? {
label: status,
color: MUTED,
bg: "#EEF2F6",
};
return (
<Badge
variant="light"
radius="sm"
styles={{
root: {
backgroundColor: cfg.bg,
color: cfg.color,
fontWeight: 600,
textTransform: "none",
letterSpacing: 0,
},
}}
>
{cfg.label}
</Badge>
);
}
/** A labelled value used across the contract detail summary cards. */
export function MetaItem({
label,
value,
icon,
}: {
label: string;
value: ReactNode;
icon?: ReactNode;
}) {
return (
<div>
<Text fz={12} fw={600} c="dimmed" mb={4} style={{ letterSpacing: 0.2 }}>
{label}
</Text>
<Group gap={6} wrap="nowrap" align="center">
{icon}
<Text fz={14} fw={600} style={{ color: INK }}>
{value}
</Text>
</Group>
</div>
);
}
/** Format a contracted/remaining quantity with its unit. */
export function formatQuantity(
qty: number,
unit?: string | null,
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`;
}

View File

@@ -13,6 +13,10 @@ import {
GeneratePriceResponse,
SubmitBookingResponse,
} from "./bookings.service";
import {
bookingOrdersService,
CreateBookingOrderPayload,
} from "./booking-orders.service";
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
import {
paymentsService,
@@ -273,6 +277,28 @@ export const api = {
),
},
bookingOrders: {
listByContract: endpoint<
{ contractBookingId: string },
Freight.IBookingOrder[]
>("booking-orders", "listByContract", ({ contractBookingId }) =>
bookingOrdersService.listByContract(contractBookingId),
),
pool: endpoint<
{ contractBookingId: string },
Freight.ContractQuantityLine[]
>("booking-orders", "pool", ({ contractBookingId }) =>
bookingOrdersService.pool(contractBookingId),
),
create: endpoint<CreateBookingOrderPayload, Freight.IBookingOrder>(
"booking-orders",
"create",
(payload) => bookingOrdersService.create(payload),
),
},
payments: {
initiate: endpoint<InitiatePaymentPayload, InitiateResponse>(
"payments",

View File

@@ -0,0 +1,34 @@
import type { Freight } from "@edr/types";
import { client } from "../utils/api";
export type CreateBookingOrderPayload = Freight.CreateBookingOrderDto;
export const bookingOrdersService = {
/** Orders placed against a general contract. */
listByContract: async (
contractBookingId: string,
): Promise<Freight.IBookingOrder[]> => {
const { data } = await client.get("/api/booking-orders", {
params: { contractBookingId },
});
return data.data ?? data;
},
/** Contracted / ordered / remaining quantities for a general contract. */
pool: async (
contractBookingId: string,
): Promise<Freight.ContractQuantityLine[]> => {
const { data } = await client.get(
`/api/booking-orders/contract/${contractBookingId}/pool`,
);
return data.data ?? data;
},
/** Place a drawdown order against a contract. */
create: async (
payload: CreateBookingOrderPayload,
): Promise<Freight.IBookingOrder> => {
const { data } = await client.post("/api/booking-orders", payload);
return data.data ?? data;
},
};

View File

@@ -69,6 +69,8 @@ export interface BookingListFilter {
status?: string;
/** Comma-separated statuses (overrides `status` when set). */
statuses?: string;
/** ONE_TIME or GENERAL_CONTRACT. */
bookingType?: string;
page?: number;
pageSize?: number;
sortBy?: string;

View File

@@ -42,6 +42,26 @@ export enum FreightType {
Bulk = "BULK",
}
/**
* Distinguishes a normal one-time booking from a general contract — an umbrella
* commitment that is signed and paid once, then drawn down by many orders over
* its period. Stored on the booking row.
*/
export enum BookingType {
OneTime = "ONE_TIME",
GeneralContract = "GENERAL_CONTRACT",
}
/**
* How a cargo type's quantity is measured. Bulk cargo is weighed in tons,
* break-bulk is counted per item. Containerised freight is always counted by
* container and carries no unit-of-measure.
*/
export enum CargoUnitOfMeasure {
PerTon = "PER_TON",
PerItem = "PER_ITEM",
}
export enum BookingStatus {
Draft = "DRAFT",
Submitted = "SUBMITTED",
@@ -68,6 +88,10 @@ export enum BookingStatus {
Cancelled = "CANCELLED",
PendingConsolidation = "PENDING_CONSOLIDATION",
Consolidated = "CONSOLIDATED",
/** General contract: paid umbrella contract that is accepting drawdown orders. */
ContractActive = "CONTRACT_ACTIVE",
/** General contract: closed because its quantity was exhausted (or period elapsed). */
ContractClosed = "CONTRACT_CLOSED",
}
export enum ConsignmentStatus {
@@ -329,6 +353,11 @@ export interface IBooking extends BaseEntity {
customerId: string;
trainId?: string | null;
status: BookingStatus;
/** ONE_TIME for normal bookings; GENERAL_CONTRACT for umbrella contracts. */
bookingType?: BookingType;
/** General contracts only: when ordering closes (null until active / for one-time). */
expiresAt?: string | null;
/** Null for general contracts at creation — the date is chosen per order. */
scheduledDate: string;
totalAmount: number;
paymentStatus: PaymentStatus;
@@ -474,6 +503,8 @@ export interface BookingReferenceCargoTypeChild {
name: string;
code: string;
show_free_text_box: boolean;
/** How this cargo is measured (PER_TON / PER_ITEM); null when unset. */
unit_of_measure?: CargoUnitOfMeasure | null;
}
export interface BookingReferenceCargoTypeGroup {
@@ -554,7 +585,10 @@ export interface CreateBookingDto {
companyId?: string | undefined;
trainId?: string | undefined;
trainScheduleId?: string | undefined;
scheduledDate: string;
/** Optional for general contracts — they pick the date per order, not at creation. */
scheduledDate?: string | undefined;
/** Defaults to ONE_TIME. GENERAL_CONTRACT creates an umbrella contract. */
bookingType?: BookingType | undefined;
contractType: string;
previousContractId?: string | undefined;
serviceTypeId: string;
@@ -578,3 +612,73 @@ export interface CreateBookingDto {
containers?: CreateBookingContainerDto[];
allowConsolidation?: boolean;
}
// ── General Contracts & Booking Orders ──────────────────────────────────────────
/**
* A line of contracted quantity. For CONTAINER contracts there is one line per
* container type (each its own drawdown pool); for BULK/BREAK_BULK a single line
* with a null containerTypeId carries the total tons/items.
*/
export interface ContractQuantityLine {
containerTypeId: string | null;
containerTypeName?: string | null;
/** PER_TON / PER_ITEM for bulk-style lines; null for container lines. */
unitOfMeasure?: CargoUnitOfMeasure | null;
/** Total contracted units on this line (containers, tons, or items). */
contractedQuantity: number;
/** Units already drawn down by non-cancelled orders. */
orderedQuantity: number;
/** contractedQuantity orderedQuantity. */
remainingQuantity: number;
}
/**
* Customer-facing view of a general contract (a Booking with
* bookingType = GENERAL_CONTRACT) and its remaining drawdown pool.
*/
export interface IGeneralContract extends IBooking {
bookingType: BookingType;
/** When the contract becomes ACTIVE; when ordering closes. Null until active. */
expiresAt?: string | null;
/** Per-line contracted / ordered / remaining quantities. */
quantityLines: ContractQuantityLine[];
}
export interface CreateBookingOrderLineDto {
/** Null for bulk/break-bulk; the container type id for container contracts. */
containerTypeId?: string | null;
quantity: number;
}
export interface CreateBookingOrderDto {
/** The general contract (booking) this order draws down from. */
contractBookingId: string;
/** The shipment day the customer wants for this order. */
scheduledDate: string;
lines: CreateBookingOrderLineDto[];
}
export interface IBookingOrderLine {
id: string;
containerTypeId?: string | null;
containerTypeName?: string | null;
quantity: number;
}
export interface IBookingOrder {
id: string;
reference: string;
contractBookingId: string;
/** The child shipment booking spawned for this order (enters scheduling). */
bookingId?: string | null;
bookingReference?: string | null;
companyId?: string | null;
scheduledDate: string;
status: BookingStatus;
schedulingStatus: SchedulingStatus;
trainScheduleId?: string | null;
lines: IBookingOrderLine[];
createdAt: string;
updatedAt: string;
}