mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export class CargoTypesService {
|
||||
showFreeTextBox: dto.showFreeTextBox ?? false,
|
||||
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
||||
isActive: dto.isActive ?? true,
|
||||
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||
displayOrder,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user