mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* General-contract drawdown order fields:
|
||||
* - booking_order_lines.hazardous_quantity / reefer_quantity — per-order counts
|
||||
* the customer enters when toggling hazardous/reefer; drive the surcharge
|
||||
* rates on the spawned child booking.
|
||||
* - bookings.is_reefer — booking-level refrigerated flag so REEFER_SURCHARGE
|
||||
* applies to a contract order even when the container type is not a reefer.
|
||||
* - contract_route_lines.km — road distance configured with the route; road
|
||||
* orders bill KM × the PER_KM rate.
|
||||
*
|
||||
* NOTE: the shared dev DB has no applied migration history, so these columns
|
||||
* are also hand-applied there. ADD COLUMN IF NOT EXISTS keeps that idempotent.
|
||||
*/
|
||||
export class AddGeneralContractOrderFields1820000000010
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddGeneralContractOrderFields1820000000010';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_order_lines ADD COLUMN IF NOT EXISTS hazardous_quantity numeric(12,3) NOT NULL DEFAULT 0;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_order_lines ADD COLUMN IF NOT EXISTS reefer_quantity numeric(12,3) NOT NULL DEFAULT 0;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS is_reefer boolean NOT NULL DEFAULT false;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_route_lines ADD COLUMN IF NOT EXISTS km numeric(10,2);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_route_lines DROP COLUMN IF EXISTS km;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS is_reefer;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_order_lines DROP COLUMN IF EXISTS reefer_quantity;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_order_lines DROP COLUMN IF EXISTS hazardous_quantity;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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 { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { BookingOrdersController } from './booking-orders.controller';
|
||||
import { BookingOrdersRepository } from './booking-orders.repository';
|
||||
@@ -18,6 +19,7 @@ import { GeneralContractService } from './general-contract.service';
|
||||
BookingsModule,
|
||||
CompaniesModule,
|
||||
DropdownSettingsModule,
|
||||
RuleEngineModule,
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
],
|
||||
controllers: [BookingOrdersController],
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { BookingOrdersService } from './booking-orders.service';
|
||||
|
||||
/**
|
||||
* Phase-0 spine: a drawdown order spawns a PRICED, UNPAID child booking that
|
||||
* waits for Marketing review (or the customs clearance gate first) — it does
|
||||
* NOT auto-enter the train batch pool, and the contract is not charged.
|
||||
*/
|
||||
describe('BookingOrdersService — child spawn on order create', () => {
|
||||
function makeService(opts: { includesCustoms: boolean; roadKm?: number | null }) {
|
||||
const contract = {
|
||||
id: 'c-1',
|
||||
bookingType: 'GENERAL_CONTRACT',
|
||||
status: 'CONTRACT_ACTIVE',
|
||||
expiresAt: new Date('2030-01-01T00:00:00.000Z'),
|
||||
freightType: 'BULK',
|
||||
originYardId: 'o-1',
|
||||
destinationYardId: 'd-1',
|
||||
companyId: null,
|
||||
paymentCurrency: 'ETB',
|
||||
serviceType: { includesCustoms: opts.includesCustoms, code: 'RAIL_BULK' },
|
||||
bookingContainers: [],
|
||||
};
|
||||
|
||||
// Capture what status the child is created with.
|
||||
const created: Record<string, unknown>[] = [];
|
||||
const managerUpdates: Record<string, unknown>[] = [];
|
||||
const fakeManager = {
|
||||
create: (_entity: unknown, data: Record<string, unknown>) => {
|
||||
created.push(data);
|
||||
return { id: 'child-1', ...data };
|
||||
},
|
||||
save: async (row: Record<string, unknown>) => ({ id: 'child-1', ...row }),
|
||||
getRepository: () => ({
|
||||
findOne: async () => ({ id: 'child-1', paymentCurrency: 'ETB', bookingContainers: [] }),
|
||||
update: async (_id: string, data: Record<string, unknown>) => {
|
||||
managerUpdates.push(data);
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
transaction: async (cb: (m: unknown) => Promise<unknown>) => cb(fakeManager),
|
||||
getRepository: () => ({ update: jest.fn() }),
|
||||
};
|
||||
const ordersRepository = {
|
||||
countByYear: jest.fn().mockResolvedValue(0),
|
||||
findById: jest.fn().mockResolvedValue({ id: 'order-1', lines: [] }),
|
||||
};
|
||||
const bookingsRepository = {
|
||||
findById: jest.fn().mockResolvedValue(contract),
|
||||
countByYear: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
const generalContractService = {
|
||||
isGeneralContract: () => true,
|
||||
getRouteLines: jest.fn().mockResolvedValue([]),
|
||||
getQuantityLines: jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ containerTypeId: null, remainingQuantity: 100, containerTypeName: null },
|
||||
]),
|
||||
isExhausted: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
const pricingService = {
|
||||
computePriceForBooking: jest.fn().mockResolvedValue({
|
||||
totalAmount: 500,
|
||||
priorityScore: 10,
|
||||
lineItems: [],
|
||||
currency: 'ETB',
|
||||
}),
|
||||
};
|
||||
const ratesService = { findLiveRates: jest.fn().mockResolvedValue([]) };
|
||||
const trainSchedulingService = {
|
||||
existsOpenScheduleOnRouteDay: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const companiesService = {};
|
||||
|
||||
const service = new BookingOrdersService(
|
||||
dataSource as never,
|
||||
ordersRepository as never,
|
||||
bookingsRepository as never,
|
||||
companiesService as never,
|
||||
generalContractService as never,
|
||||
pricingService as never,
|
||||
ratesService as never,
|
||||
trainSchedulingService as never,
|
||||
);
|
||||
return { service, created, managerUpdates, pricingService };
|
||||
}
|
||||
|
||||
const dto = {
|
||||
contractBookingId: 'c-1',
|
||||
scheduledDate: '2026-07-01T00:00:00.000Z',
|
||||
lines: [{ quantity: 10, hazardousQuantity: 4, reeferQuantity: 0 }],
|
||||
};
|
||||
|
||||
it('spawns the child at OPERATION_REQUEST_PENDING (no customs), priced + unpaid', async () => {
|
||||
const { service, created, managerUpdates, pricingService } = makeService({
|
||||
includesCustoms: false,
|
||||
});
|
||||
await service.create(dto as never);
|
||||
|
||||
const child = created.find((c) => c.bookingType === 'ONE_TIME')!;
|
||||
expect(child.status).toBe('OPERATION_REQUEST_PENDING');
|
||||
expect(child.paymentStatus).toBe('PENDING');
|
||||
expect(child.isHazardous).toBe(true); // line has hazardousQuantity > 0
|
||||
expect(pricingService.computePriceForBooking).toHaveBeenCalled();
|
||||
// The computed price is persisted onto the child.
|
||||
expect(managerUpdates.some((u) => u.totalAmount === 500)).toBe(true);
|
||||
});
|
||||
|
||||
it('spawns the child at AWAITING_DOCUMENTS when the service includes customs', async () => {
|
||||
const { service, created } = makeService({ includesCustoms: true });
|
||||
await service.create(dto as never);
|
||||
const child = created.find((c) => c.bookingType === 'ONE_TIME')!;
|
||||
expect(child.status).toBe('AWAITING_DOCUMENTS');
|
||||
});
|
||||
|
||||
it('rejects when hazardous quantity exceeds the line quantity', async () => {
|
||||
const { service } = makeService({ includesCustoms: false });
|
||||
await expect(
|
||||
service.create({
|
||||
...dto,
|
||||
lines: [{ quantity: 5, hazardousQuantity: 9, reeferQuantity: 0 }],
|
||||
} as never),
|
||||
).rejects.toThrow(/exceed the line quantity/);
|
||||
});
|
||||
});
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||
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 { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { BookingOrdersRepository } from './booking-orders.repository';
|
||||
@@ -20,6 +22,7 @@ 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';
|
||||
import { isRoadService, roadKmPrice } from './road.util';
|
||||
|
||||
@Injectable()
|
||||
export class BookingOrdersService {
|
||||
@@ -31,8 +34,8 @@ export class BookingOrdersService {
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly generalContractService: GeneralContractService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly pricingService: BookingPricingService,
|
||||
private readonly ratesService: RatesService,
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
) {}
|
||||
@@ -87,6 +90,7 @@ export class BookingOrdersService {
|
||||
let originYardId = contract.originYardId;
|
||||
let destinationYardId = contract.destinationYardId;
|
||||
let routeLineId: string | null = null;
|
||||
let routeKm: number | null = null;
|
||||
|
||||
if (routeLines.length > 0) {
|
||||
if (!dto.routeLineId) {
|
||||
@@ -103,6 +107,7 @@ export class BookingOrdersService {
|
||||
originYardId = chosen.originYardId;
|
||||
destinationYardId = chosen.destinationYardId;
|
||||
routeLineId = chosen.routeLineId;
|
||||
routeKm = chosen.km ?? null;
|
||||
}
|
||||
|
||||
// Validate the route has a departure on the chosen day.
|
||||
@@ -122,6 +127,21 @@ export class BookingOrdersService {
|
||||
const isContainer = contract.freightType === 'CONTAINER';
|
||||
const orderTotal = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
|
||||
|
||||
// Hazardous/reefer counts the customer entered cannot exceed the line they
|
||||
// belong to. Validated for every order regardless of routing.
|
||||
for (const line of dto.lines) {
|
||||
const haz = line.hazardousQuantity ?? 0;
|
||||
const reefer = line.reeferQuantity ?? 0;
|
||||
if (haz < 0 || reefer < 0) {
|
||||
throw new BadRequestException('Hazardous/reefer quantities cannot be negative');
|
||||
}
|
||||
if (haz > line.quantity || reefer > line.quantity) {
|
||||
throw new BadRequestException(
|
||||
'Hazardous/reefer quantity cannot exceed the line quantity',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (routeLineId) {
|
||||
// Multi-route: validate against the chosen route line's remaining pool.
|
||||
for (const line of dto.lines) {
|
||||
@@ -167,7 +187,7 @@ export class BookingOrdersService {
|
||||
const childBooking = await this.spawnChildBooking(
|
||||
contract,
|
||||
dto,
|
||||
{ originYardId, destinationYardId },
|
||||
{ originYardId, destinationYardId, km: routeKm },
|
||||
manager,
|
||||
);
|
||||
|
||||
@@ -179,7 +199,9 @@ export class BookingOrdersService {
|
||||
routeLineId,
|
||||
companyId: contract.companyId ?? null,
|
||||
scheduledDate: new Date(dto.scheduledDate),
|
||||
status: 'PAID',
|
||||
// The order is a ledger row; the child booking drives the workflow
|
||||
// (review → pay → allocate), so the order tracks PENDING until done.
|
||||
status: 'PENDING',
|
||||
schedulingStatus: 'NOT_SCHEDULED',
|
||||
});
|
||||
const savedOrder = await manager.save(orderRow);
|
||||
@@ -189,6 +211,8 @@ export class BookingOrdersService {
|
||||
orderId: savedOrder.id,
|
||||
containerTypeId: isContainer ? (l.containerTypeId ?? null) : null,
|
||||
quantity: l.quantity,
|
||||
hazardousQuantity: l.hazardousQuantity ?? 0,
|
||||
reeferQuantity: l.reeferQuantity ?? 0,
|
||||
}),
|
||||
);
|
||||
await manager.save(lines);
|
||||
@@ -196,20 +220,12 @@ export class BookingOrdersService {
|
||||
return savedOrder;
|
||||
});
|
||||
|
||||
// Feed the child booking into the day-pool batch so it allocates to a train.
|
||||
try {
|
||||
await this.bookingBatchService.processRouteDay({
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Batch fill after order ${order.reference} failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
// The child does NOT enter the train batch pool here. It is priced and
|
||||
// unpaid, awaiting Marketing review (OPERATION_REQUEST_PENDING) or customs
|
||||
// clearance first; the batch enqueue happens only on accept.
|
||||
|
||||
// Close the contract once its pool is exhausted.
|
||||
// Close the contract once its pool is exhausted (pending orders count, so
|
||||
// the pool reserves quantity as soon as an order is placed).
|
||||
if (await this.generalContractService.isExhausted(contract.id)) {
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
@@ -224,16 +240,18 @@ export class BookingOrdersService {
|
||||
|
||||
/**
|
||||
* Create the ONE_TIME child booking for an order, inheriting the contract's
|
||||
* shipment context and entering the queue already PAID + FULLY_EXECUTED.
|
||||
* shipment context. Unlike the contract (which is no longer paid up front),
|
||||
* the child is PRICED and UNPAID and waits for Marketing review — going
|
||||
* through the customs clearance gate first when the service includes customs,
|
||||
* mirroring a one-time booking. It only enters the train pool on accept.
|
||||
*/
|
||||
private async spawnChildBooking(
|
||||
contract: Booking,
|
||||
dto: CreateBookingOrderDto,
|
||||
route: { originYardId: string; destinationYardId: string },
|
||||
route: { originYardId: string; destinationYardId: string; km: number | null },
|
||||
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.
|
||||
@@ -251,6 +269,18 @@ export class BookingOrdersService {
|
||||
totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
|
||||
}
|
||||
|
||||
// Per-order hazardous/reefer: set the child flags from the order's line
|
||||
// counts so the HAZARD_SURCHARGE / REEFER_SURCHARGE rates apply.
|
||||
const hasHazardous = dto.lines.some((l) => (l.hazardousQuantity ?? 0) > 0);
|
||||
const hasReefer = dto.lines.some((l) => (l.reeferQuantity ?? 0) > 0);
|
||||
|
||||
// Customs orders flow through the one-time clearance gate first; others go
|
||||
// straight to operations review with the chosen shipment day.
|
||||
const { includesCustoms } = clearanceCodesForBooking(contract);
|
||||
const spawnStatus = includesCustoms
|
||||
? 'AWAITING_DOCUMENTS'
|
||||
: 'OPERATION_REQUEST_PENDING';
|
||||
|
||||
const child = manager.create(Booking, {
|
||||
reference,
|
||||
companyId: contract.companyId ?? null,
|
||||
@@ -271,16 +301,14 @@ export class BookingOrdersService {
|
||||
cargoFreeText: contract.cargoFreeText ?? null,
|
||||
shippingLineId: contract.shippingLineId ?? null,
|
||||
cargoTotalWeightVgm: totalWeight,
|
||||
isHazardous: contract.isHazardous,
|
||||
isHazardous: hasHazardous,
|
||||
isReefer: hasReefer,
|
||||
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,
|
||||
// Priced + unpaid: the customer pays this order on its own.
|
||||
status: spawnStatus,
|
||||
paymentStatus: 'PENDING',
|
||||
priorityScore: contract.priorityScore,
|
||||
totalAmount: 0,
|
||||
schedulingStatus: 'NOT_SCHEDULED',
|
||||
@@ -310,9 +338,79 @@ export class BookingOrdersService {
|
||||
}
|
||||
}
|
||||
|
||||
// Price the order: base freight for the drawn quantity + haz/reefer
|
||||
// surcharges, plus a road KM charge when the service ships by road.
|
||||
const roadKm = isRoadService(contract.serviceType) ? route.km : null;
|
||||
await this.priceChildBooking(savedChild.id, roadKm, manager);
|
||||
|
||||
return savedChild;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute and persist the child order's price (base + surcharges) inside the
|
||||
* order transaction. The contract is no longer paid up front, so each order
|
||||
* carries its own total that the customer pays.
|
||||
*/
|
||||
private async priceChildBooking(
|
||||
childId: string,
|
||||
roadKm: number | null,
|
||||
manager: import('typeorm').EntityManager,
|
||||
): Promise<void> {
|
||||
const child = await manager.getRepository(Booking).findOne({
|
||||
where: { id: childId },
|
||||
relations: { bookingContainers: true },
|
||||
});
|
||||
if (!child) return;
|
||||
|
||||
try {
|
||||
const computed = await this.pricingService.computePriceForBooking(child);
|
||||
const lineItems = [...computed.lineItems];
|
||||
let total = computed.totalAmount;
|
||||
|
||||
// Road KM charge: distance × the live PER_KM rate, added as its own line.
|
||||
if (roadKm && roadKm > 0) {
|
||||
const perKmRate = await this.findPerKmRate(child.paymentCurrency);
|
||||
const kmAmount = roadKmPrice(roadKm, perKmRate);
|
||||
if (kmAmount > 0) {
|
||||
lineItems.push({
|
||||
code: 'ROAD_KM',
|
||||
description: `Road transport (${roadKm} km)`,
|
||||
amount: kmAmount,
|
||||
unitAmount: perKmRate!,
|
||||
unit: 'PER_KM',
|
||||
quantity: roadKm,
|
||||
currency: child.paymentCurrency,
|
||||
});
|
||||
total += kmAmount;
|
||||
}
|
||||
}
|
||||
|
||||
await manager.getRepository(Booking).update(childId, {
|
||||
totalAmount: total,
|
||||
priorityScore: computed.priorityScore,
|
||||
pricingBreakdown: {
|
||||
lineItems,
|
||||
totalAmount: total,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Pricing child order ${childId} failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The live PER_KM rate value for road billing, in the given currency. */
|
||||
private async findPerKmRate(currency: string): Promise<number | null> {
|
||||
const rates = await this.ratesService.findLiveRates();
|
||||
const rate = rates.find(
|
||||
(r) => r.rateUnit === 'PER_KM' && r.currency === currency,
|
||||
);
|
||||
return rate ? Number(rate.rateValue) : null;
|
||||
}
|
||||
|
||||
private async userOwnsContract(
|
||||
userId: string,
|
||||
contract: Booking,
|
||||
|
||||
@@ -53,4 +53,7 @@ export class ContractRouteLineView {
|
||||
|
||||
@ApiProperty()
|
||||
remainingQuantity!: number;
|
||||
|
||||
@ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' })
|
||||
km!: number | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'reflect-metadata';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { CreateBookingOrderLineDto } from './create-booking-order.dto';
|
||||
|
||||
/**
|
||||
* Order line haz/reefer quantities arrive as JSON numbers but must default to 0
|
||||
* when omitted and coerce string inputs (defensive) to numbers.
|
||||
*/
|
||||
describe('CreateBookingOrderLineDto — haz/reefer coercion', () => {
|
||||
const toDto = (plain: Record<string, unknown>) =>
|
||||
plainToInstance(CreateBookingOrderLineDto, plain, {
|
||||
enableImplicitConversion: false,
|
||||
exposeDefaultValues: true,
|
||||
}) as unknown as CreateBookingOrderLineDto;
|
||||
|
||||
it('defaults hazardous/reefer quantities to 0 when omitted', () => {
|
||||
const dto = toDto({ quantity: 5 });
|
||||
expect(dto.hazardousQuantity).toBe(0);
|
||||
expect(dto.reeferQuantity).toBe(0);
|
||||
});
|
||||
|
||||
it('coerces provided string quantities to numbers', () => {
|
||||
const dto = toDto({ quantity: '5', hazardousQuantity: '2', reeferQuantity: '3' });
|
||||
expect(dto.quantity).toBe(5);
|
||||
expect(dto.hazardousQuantity).toBe(2);
|
||||
expect(dto.reeferQuantity).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,26 @@ export class CreateBookingOrderLineDto {
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'How much of this line is hazardous (≤ quantity). Defaults to 0.',
|
||||
minimum: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
hazardousQuantity?: number = 0;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'How much of this line is refrigerated (≤ quantity). Defaults to 0.',
|
||||
minimum: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
reeferQuantity?: number = 0;
|
||||
}
|
||||
|
||||
export class CreateBookingOrderDto {
|
||||
|
||||
@@ -27,4 +27,15 @@ export class BookingOrderLine extends BaseEntity {
|
||||
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
|
||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
|
||||
quantity!: number;
|
||||
|
||||
/**
|
||||
* How much of this line is hazardous / refrigerated, entered per order by the
|
||||
* customer when they toggle the flag. Drives the HAZARD_SURCHARGE /
|
||||
* REEFER_SURCHARGE rates on the spawned child booking. Both ≤ quantity.
|
||||
*/
|
||||
@Column({ name: 'hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
hazardousQuantity!: number;
|
||||
|
||||
@Column({ name: 'reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
reeferQuantity!: number;
|
||||
}
|
||||
|
||||
@@ -50,4 +50,12 @@ export class ContractRouteLine extends BaseEntity {
|
||||
/** Contracted quantity for this (route, container type): containers, tons, or items. */
|
||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
|
||||
quantity!: number;
|
||||
|
||||
/**
|
||||
* Road distance for this route, configured with the route. Road (truck)
|
||||
* drawdown orders bill KM × the PER_KM rate from this value. Null for
|
||||
* rail-only routes where KM is not billed.
|
||||
*/
|
||||
@Column({ name: 'km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
km?: number | null;
|
||||
}
|
||||
|
||||
@@ -162,6 +162,7 @@ export class GeneralContractService {
|
||||
contractedQuantity: contracted,
|
||||
orderedQuantity: orderedQty,
|
||||
remainingQuantity: Math.max(0, contracted - orderedQty),
|
||||
km: rl.km != null ? Number(rl.km) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { isRoadService, roadKmPrice } from './road.util';
|
||||
|
||||
describe('road.util', () => {
|
||||
describe('isRoadService', () => {
|
||||
it('treats ROAD/TRUCK codes (and prefixes) as road', () => {
|
||||
expect(isRoadService({ code: 'ROAD' })).toBe(true);
|
||||
expect(isRoadService({ code: 'TRUCK' })).toBe(true);
|
||||
expect(isRoadService({ code: 'ROAD_CONTAINER' })).toBe(true);
|
||||
expect(isRoadService({ code: 'truck_forwarding' })).toBe(true);
|
||||
});
|
||||
|
||||
it('treats rail / unknown / missing services as not road', () => {
|
||||
expect(isRoadService({ code: 'RAIL_CONTAINER' })).toBe(false);
|
||||
expect(isRoadService({ code: 'OFFROADING' })).toBe(false);
|
||||
expect(isRoadService(null)).toBe(false);
|
||||
expect(isRoadService(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roadKmPrice', () => {
|
||||
it('multiplies distance by the per-km rate', () => {
|
||||
expect(roadKmPrice(120, 5)).toBe(600);
|
||||
});
|
||||
|
||||
it('returns 0 when km or rate is missing/non-positive', () => {
|
||||
expect(roadKmPrice(null, 5)).toBe(0);
|
||||
expect(roadKmPrice(120, null)).toBe(0);
|
||||
expect(roadKmPrice(0, 5)).toBe(0);
|
||||
expect(roadKmPrice(120, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
35
apps/edr-freight-api/src/modules/booking-orders/road.util.ts
Normal file
35
apps/edr-freight-api/src/modules/booking-orders/road.util.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
|
||||
/**
|
||||
* Road (truck) services are distinguished by their ServiceType.code. Rail
|
||||
* services are seeded as RAIL_* and go through the train batch pool; a road
|
||||
* service (code starting ROAD_ or TRUCK_, or exactly ROAD/TRUCK) instead bills
|
||||
* by distance and dispatches a truck. Prefix-matching keeps this resilient to
|
||||
* the exact seeded code (e.g. ROAD_CONTAINER, TRUCK_FORWARDING).
|
||||
*/
|
||||
export function isRoadService(
|
||||
serviceType?: Pick<ServiceType, 'code'> | null,
|
||||
): boolean {
|
||||
const code = serviceType?.code?.toUpperCase() ?? '';
|
||||
return (
|
||||
code === 'ROAD' ||
|
||||
code === 'TRUCK' ||
|
||||
code.startsWith('ROAD_') ||
|
||||
code.startsWith('TRUCK_')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Road freight charge for an order: distance (km, from the route line) × the
|
||||
* per-km rate. Returns 0 when either input is missing so callers can add it to
|
||||
* a total without guarding.
|
||||
*/
|
||||
export function roadKmPrice(
|
||||
km: number | null | undefined,
|
||||
perKmRate: number | null | undefined,
|
||||
): number {
|
||||
const distance = Number(km ?? 0);
|
||||
const rate = Number(perKmRate ?? 0);
|
||||
if (!(distance > 0) || !(rate > 0)) return 0;
|
||||
return distance * rate;
|
||||
}
|
||||
@@ -23,6 +23,13 @@ import { clearanceSettingCode } from './clearance.util';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
||||
|
||||
/**
|
||||
* Default ordering window (months) for a general contract activated on
|
||||
* counter-sign. Mirrors GeneralContractService.DEFAULT_CONTRACT_PERIOD_MONTHS;
|
||||
* defined locally to avoid a circular module dependency on booking-orders.
|
||||
*/
|
||||
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
|
||||
@@ -233,9 +240,23 @@ export class BookingContractService {
|
||||
includesCustoms,
|
||||
);
|
||||
|
||||
const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT';
|
||||
|
||||
if (role === 'CUSTOMER') {
|
||||
updates.status = 'SIGNED_CUSTOMER';
|
||||
updates.customerSignedAt = now;
|
||||
} else if (isGeneralContract) {
|
||||
// A general contract is NOT paid up front — each drawdown order is priced
|
||||
// and paid on its own. So on counter-sign it becomes ACTIVE directly and
|
||||
// opens its ordering window; orders spawn their own priced child bookings.
|
||||
const expiresAt = new Date(now);
|
||||
expiresAt.setMonth(expiresAt.getMonth() + DEFAULT_CONTRACT_PERIOD_MONTHS);
|
||||
updates.fullyExecutedAt = now;
|
||||
updates.marketingApprovedAt = now;
|
||||
updates.marketingApprovedById = options.signerUserId ?? null;
|
||||
updates.lockedAt = now;
|
||||
updates.status = 'CONTRACT_ACTIVE';
|
||||
updates.expiresAt = expiresAt;
|
||||
} else {
|
||||
updates.fullyExecutedAt = now;
|
||||
updates.marketingApprovedAt = now;
|
||||
|
||||
@@ -268,6 +268,10 @@ export class BookingPricingService {
|
||||
tradeDirection: booking.tradeDirection,
|
||||
// Coerce defensively in case the stored flag is a string ("true"/"false").
|
||||
isHazardous: booking.isHazardous === true || (booking.isHazardous as unknown) === 'true',
|
||||
// Booking-level reefer flag (set by contract drawdown orders that carry a
|
||||
// reefer quantity) applies the REEFER surcharge even for non-reefer
|
||||
// container types. ORed with per-container reefer in the engine.
|
||||
isReefer: booking.isReefer === true || (booking.isReefer as unknown) === 'true',
|
||||
isGovernment: booking.isGovernment,
|
||||
allowConsolidation,
|
||||
shippingLineId: booking.shippingLineId,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
|
||||
/**
|
||||
* Operation-request review for general-contract drawdown orders:
|
||||
* - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool.
|
||||
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
|
||||
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
|
||||
* - ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM.
|
||||
*/
|
||||
describe('BookingTransitionService — operation review', () => {
|
||||
function makeService(serviceTypeCode: string) {
|
||||
const booking = {
|
||||
id: 'b-1',
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
originYardId: 'o-1',
|
||||
destinationYardId: 'd-1',
|
||||
scheduledDate: new Date('2026-07-01T00:00:00.000Z'),
|
||||
serviceType: { code: serviceTypeCode },
|
||||
};
|
||||
const bookingsRepository = {
|
||||
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
|
||||
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn().mockResolvedValue(booking),
|
||||
};
|
||||
const bookingBatchService = {
|
||||
enqueueRouteDayProcessing: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new BookingTransitionService(
|
||||
bookingsRepository as never,
|
||||
{} as never, // ruleEngineService
|
||||
{} as never, // pricingService
|
||||
{} as never, // contractService
|
||||
{} as never, // filesService
|
||||
{} as never, // fileUploadSettingsService
|
||||
bookingBatchService as never,
|
||||
bookingsService as never,
|
||||
);
|
||||
return { service, bookingsRepository, bookingBatchService };
|
||||
}
|
||||
|
||||
it('ACCEPT of a train order → FULLY_EXECUTED and enqueues the batch pool', async () => {
|
||||
const { service, bookingsRepository, bookingBatchService } =
|
||||
makeService('RAIL_CONTAINER');
|
||||
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ status: 'FULLY_EXECUTED' }),
|
||||
);
|
||||
expect(bookingBatchService.enqueueRouteDayProcessing).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enqueue', async () => {
|
||||
const { service, bookingsRepository, bookingBatchService } =
|
||||
makeService('ROAD_CONTAINER');
|
||||
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }),
|
||||
);
|
||||
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED', async () => {
|
||||
const { service, bookingsRepository } = makeService('RAIL_CONTAINER');
|
||||
await expect(
|
||||
service.reviewOperationRequest('b-1', 'REQUEST_CHANGES', 'staff-1', {}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
await service.reviewOperationRequest('b-1', 'REQUEST_CHANGES', 'staff-1', {
|
||||
note: 'Fix the schedule',
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ status: 'OPERATION_CHANGES_REQUESTED' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM', async () => {
|
||||
const { service, bookingsRepository } = makeService('RAIL_CONTAINER');
|
||||
await service.reviewOperationRequest('b-1', 'ADJUST_PRICE', 'staff-1', {
|
||||
amount: 1500,
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({
|
||||
adjustedTotalAmount: 1500,
|
||||
status: 'OPERATION_PRICE_PENDING_CONFIRM',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { isRoadService } from '../booking-orders/road.util';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
@@ -879,12 +880,27 @@ export class BookingTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a reviewed operation request into the batch holding pool. The pool query
|
||||
* (findBatchPoolByRouteDay) keys on FULLY_EXECUTED + scheduled_date, so we set
|
||||
* those and kick the day-level fill immediately instead of waiting for cron.
|
||||
* Move a reviewed operation request forward after Marketing accepts.
|
||||
*
|
||||
* - Train services enter the batch holding pool: the pool query
|
||||
* (findBatchPoolByRouteDay) keys on FULLY_EXECUTED + scheduled_date, so we
|
||||
* set those and kick the day-level fill immediately instead of waiting for
|
||||
* cron.
|
||||
* - Road (truck) services skip the train batch entirely and wait for truck
|
||||
* dispatch at ROAD_DISPATCH_PENDING; they are billed by KM, not wagons.
|
||||
*/
|
||||
private async acceptOperationRequest(booking: Booking): Promise<Booking> {
|
||||
const now = new Date();
|
||||
|
||||
if (isRoadService(booking.serviceType)) {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'ROAD_DISPATCH_PENDING',
|
||||
fullyExecutedAt: now,
|
||||
lockedAt: booking.lockedAt ?? now,
|
||||
} as never);
|
||||
return this.bookingsService.findById(booking.id);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'FULLY_EXECUTED',
|
||||
fullyExecutedAt: now,
|
||||
|
||||
@@ -79,6 +79,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
],
|
||||
exports: [BookingsService, BookingsRepository],
|
||||
exports: [BookingsService, BookingsRepository, BookingPricingService],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
|
||||
@@ -463,6 +463,7 @@ export class BookingsService {
|
||||
containerTypeId:
|
||||
dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null,
|
||||
quantity: r.quantity,
|
||||
km: r.km ?? null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -77,6 +77,18 @@ export class CreateContractRouteDto {
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Road distance (km) for this route; used to bill road orders.',
|
||||
minimum: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) =>
|
||||
value === undefined || value === null || value === '' ? undefined : Number(value),
|
||||
)
|
||||
km?: number;
|
||||
}
|
||||
|
||||
export class CreateBookingDto {
|
||||
|
||||
@@ -47,6 +47,9 @@ export const BOOKING_STATUSES = [
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY',
|
||||
// Road (truck) drawdown orders skip the train batch pool and wait here for
|
||||
// truck dispatch after Marketing accepts; billed by KM, not wagons.
|
||||
'ROAD_DISPATCH_PENDING',
|
||||
'OPERATION_REQUESTED',
|
||||
// Operations review gate: customer picks a schedule day and submits the
|
||||
// operation request; the operations team reviews capacity/docs/route before
|
||||
@@ -288,6 +291,15 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||
isHazardous!: boolean;
|
||||
|
||||
/**
|
||||
* Refrigerated cargo flag. For one-time bookings reefer is derived from the
|
||||
* container type; for general-contract drawdown orders the customer enters a
|
||||
* reefer quantity per order, which sets this flag on the spawned child so the
|
||||
* REEFER_SURCHARGE rate applies even when the container type is not a reefer.
|
||||
*/
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||
isReefer!: boolean;
|
||||
|
||||
@Column({ name: 'payment_currency', type: 'varchar', length: 5 })
|
||||
paymentCurrency!: string;
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface BookingEvaluationInput {
|
||||
paymentCurrency: string;
|
||||
tradeDirection: string;
|
||||
isHazardous: boolean;
|
||||
/** Booking-level reefer flag; ORed with per-container reefer. */
|
||||
isReefer?: boolean;
|
||||
isGovernment?: boolean;
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
@@ -195,7 +197,8 @@ export class RuleEngineService {
|
||||
shippingLineMapped = Boolean(line?.mappedToCode);
|
||||
}
|
||||
|
||||
const hasReefer = input.containers.some((c) => c.isReefer);
|
||||
const hasReefer =
|
||||
input.isReefer === true || input.containers.some((c) => c.isReefer);
|
||||
const hasOverweight = containerWeightResults.some((r) => r.isOverweight);
|
||||
|
||||
// Surcharges are now self-describing rates: any LIVE rate whose `trigger`
|
||||
|
||||
@@ -59,13 +59,17 @@ export function BookingConfirmDialog({
|
||||
const needsTextInput = action.input === "note" || action.input === "reason";
|
||||
const needsFileInput = action.input === "file";
|
||||
const needsDaysInput = action.input === "days";
|
||||
const needsAmountInput = action.input === "amount";
|
||||
const daysValue = Number(inputValue.trim());
|
||||
const daysValid =
|
||||
Number.isInteger(daysValue) && daysValue >= 1 && daysValue <= 365;
|
||||
const amountValue = Number(inputValue.trim());
|
||||
const amountValid = !!inputValue.trim() && Number.isFinite(amountValue) && amountValue >= 0;
|
||||
const inputMissing =
|
||||
(needsTextInput && !inputValue.trim()) ||
|
||||
(needsFileInput && !selectedFile) ||
|
||||
(needsDaysInput && !daysValid);
|
||||
(needsDaysInput && !daysValid) ||
|
||||
(needsAmountInput && !amountValid);
|
||||
const isDestructive = action.variant === "destructive";
|
||||
const accent = isDestructive ? "red" : "edr-green";
|
||||
|
||||
@@ -168,6 +172,19 @@ export function BookingConfirmDialog({
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{needsAmountInput && (
|
||||
<NumberInput
|
||||
label={action.inputLabel ?? "Adjusted total"}
|
||||
withAsterisk
|
||||
min={0}
|
||||
allowNegative={false}
|
||||
decimalScale={2}
|
||||
thousandSeparator=","
|
||||
placeholder={action.inputPlaceholder ?? "0.00"}
|
||||
value={inputValue === "" ? "" : Number(inputValue)}
|
||||
onChange={(value) => onInputChange(value === "" ? "" : String(value))}
|
||||
/>
|
||||
)}
|
||||
{extra}
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -15,6 +15,13 @@ function isValidValidityDays(value: string): boolean {
|
||||
return Number.isInteger(days) && days >= 1 && days <= 365;
|
||||
}
|
||||
|
||||
/** An adjusted price must be a non-negative number. */
|
||||
function isValidAmount(value: string): boolean {
|
||||
if (!value.trim()) return false;
|
||||
const amount = Number(value.trim());
|
||||
return Number.isFinite(amount) && amount >= 0;
|
||||
}
|
||||
|
||||
export function useBookingActionDialog(
|
||||
bookingId: string,
|
||||
context: BookingActionContext,
|
||||
@@ -77,6 +84,24 @@ export function useBookingActionDialog(
|
||||
case "reject":
|
||||
mutations.staffReject.mutate(inputValue.trim(), { onSuccess });
|
||||
break;
|
||||
case "operationAccept":
|
||||
mutations.reviewOperation.mutate({ decision: "ACCEPT" }, { onSuccess });
|
||||
break;
|
||||
case "operationRequestChanges":
|
||||
mutations.reviewOperation.mutate(
|
||||
{ decision: "REQUEST_CHANGES", note: inputValue.trim() },
|
||||
{ onSuccess },
|
||||
);
|
||||
break;
|
||||
case "operationAdjustPrice": {
|
||||
const amount = Number(inputValue.trim());
|
||||
if (!Number.isFinite(amount) || amount < 0) return;
|
||||
mutations.reviewOperation.mutate(
|
||||
{ decision: "ADJUST_PRICE", amount },
|
||||
{ onSuccess },
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "approve": {
|
||||
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
|
||||
if (!step) return;
|
||||
@@ -126,7 +151,8 @@ export function useBookingActionDialog(
|
||||
(pendingAction?.input === "file" && !selectedFile) ||
|
||||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
|
||||
(pendingAction?.input === "note" && !inputValue.trim()) ||
|
||||
(pendingAction?.input === "days" && !isValidValidityDays(inputValue));
|
||||
(pendingAction?.input === "days" && !isValidValidityDays(inputValue)) ||
|
||||
(pendingAction?.input === "amount" && !isValidAmount(inputValue));
|
||||
|
||||
return {
|
||||
actions,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Ban,
|
||||
Check,
|
||||
Coins,
|
||||
FileSignature,
|
||||
MessageSquareWarning,
|
||||
Play,
|
||||
@@ -34,9 +35,17 @@ export type BookingActionId =
|
||||
| "allocateBooking"
|
||||
| "startTransit"
|
||||
| "complete"
|
||||
| "operationAccept"
|
||||
| "operationRequestChanges"
|
||||
| "operationAdjustPrice"
|
||||
| "cancel";
|
||||
|
||||
export type BookingActionInputKind = "note" | "reason" | "file" | "days";
|
||||
export type BookingActionInputKind =
|
||||
| "note"
|
||||
| "reason"
|
||||
| "file"
|
||||
| "days"
|
||||
| "amount";
|
||||
|
||||
export interface BookingActionDef {
|
||||
id: BookingActionId;
|
||||
@@ -159,6 +168,50 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// Marketing/operations review of a drawdown order's operation request.
|
||||
const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
|
||||
{
|
||||
id: "operationAccept",
|
||||
label: "Accept operation",
|
||||
shortLabel: "Accept",
|
||||
description: "Accept the operation request and release it for dispatch",
|
||||
confirmTitle: "Accept operation request?",
|
||||
confirmDescription:
|
||||
"Train orders enter the batch pool; road orders move to truck dispatch.",
|
||||
variant: "default",
|
||||
icon: Check,
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
id: "operationRequestChanges",
|
||||
label: "Request changes",
|
||||
shortLabel: "Changes",
|
||||
description: "Ask the customer to adjust the operation request",
|
||||
confirmTitle: "Request changes to the operation?",
|
||||
confirmDescription:
|
||||
"The customer will see your note and can adjust and resubmit the order.",
|
||||
variant: "outline",
|
||||
icon: MessageSquareWarning,
|
||||
input: "note",
|
||||
inputLabel: "Message to customer",
|
||||
inputPlaceholder: "Describe what needs to change…",
|
||||
},
|
||||
{
|
||||
id: "operationAdjustPrice",
|
||||
label: "Adjust price",
|
||||
shortLabel: "Price",
|
||||
description: "Set an adjusted total the customer must confirm",
|
||||
confirmTitle: "Adjust the order price?",
|
||||
confirmDescription:
|
||||
"Enter the new total. The customer must confirm it before the order proceeds.",
|
||||
variant: "outline",
|
||||
icon: Coins,
|
||||
input: "amount",
|
||||
inputLabel: "Adjusted total",
|
||||
inputPlaceholder: "0.00",
|
||||
},
|
||||
];
|
||||
|
||||
const CANCEL_ACTION: BookingActionDef = {
|
||||
id: "cancel",
|
||||
label: "Cancel booking",
|
||||
@@ -210,6 +263,9 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
||||
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
|
||||
startTransit: FREIGHT_PERMS.bookings.operations,
|
||||
complete: FREIGHT_PERMS.bookings.operations,
|
||||
operationAccept: FREIGHT_PERMS.bookings.operations,
|
||||
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
|
||||
operationAdjustPrice: FREIGHT_PERMS.bookings.operations,
|
||||
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
|
||||
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||
};
|
||||
@@ -303,6 +359,9 @@ export function getBookingActions(
|
||||
},
|
||||
];
|
||||
break;
|
||||
case "OPERATION_REQUEST_PENDING":
|
||||
actions = withCancel(OPERATION_REVIEW_ACTIONS);
|
||||
break;
|
||||
case "PAID":
|
||||
if (
|
||||
canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })
|
||||
|
||||
@@ -86,6 +86,22 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
|
||||
label: "Consolidated",
|
||||
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||
},
|
||||
OPERATION_REQUEST_PENDING: {
|
||||
label: "Operation Review",
|
||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
},
|
||||
OPERATION_CHANGES_REQUESTED: {
|
||||
label: "Operation Changes",
|
||||
color: "bg-orange-50 text-orange-700 border-orange-200",
|
||||
},
|
||||
OPERATION_PRICE_PENDING_CONFIRM: {
|
||||
label: "Price Confirm",
|
||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
},
|
||||
ROAD_DISPATCH_PENDING: {
|
||||
label: "Truck Dispatch",
|
||||
color: "bg-blue-50 text-blue-700 border-blue-200",
|
||||
},
|
||||
};
|
||||
|
||||
export interface StatusMeta {
|
||||
@@ -257,10 +273,19 @@ export const BOOKING_LIST_TABS = [
|
||||
"EXPIRED",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "ops_review",
|
||||
label: "Ops review",
|
||||
statuses: [
|
||||
"OPERATION_REQUEST_PENDING",
|
||||
"OPERATION_CHANGES_REQUESTED",
|
||||
"OPERATION_PRICE_PENDING_CONFIRM",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "operations",
|
||||
label: "Operations",
|
||||
statuses: ["PAID", "IN_TRANSIT"],
|
||||
statuses: ["PAID", "IN_TRANSIT", "ROAD_DISPATCH_PENDING"],
|
||||
},
|
||||
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
|
||||
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
|
||||
|
||||
@@ -61,6 +61,16 @@ export function useBookingMutations(bookingId: string) {
|
||||
onError: () => toast.error("Failed to reject booking"),
|
||||
});
|
||||
|
||||
const reviewOperation = useMutation({
|
||||
mutationFn: (payload: {
|
||||
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE";
|
||||
note?: string;
|
||||
amount?: number;
|
||||
}) => api.bookings.reviewOperation.call({ id: bookingId, ...payload }),
|
||||
onSuccess: (data) => onSuccess(data, "Operation request reviewed"),
|
||||
onError: () => toast.error("Failed to review operation request"),
|
||||
});
|
||||
|
||||
const approveStep = useMutation({
|
||||
mutationFn: ({
|
||||
stepId,
|
||||
@@ -148,12 +158,14 @@ export function useBookingMutations(bookingId: string) {
|
||||
payBooking.isPending ||
|
||||
startTransit.isPending ||
|
||||
complete.isPending ||
|
||||
reviewOperation.isPending ||
|
||||
cancel.isPending;
|
||||
|
||||
return {
|
||||
staffAccept,
|
||||
requestChanges,
|
||||
staffReject,
|
||||
reviewOperation,
|
||||
approveStep,
|
||||
rejectStep,
|
||||
generateContract,
|
||||
|
||||
@@ -2,27 +2,41 @@ import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Inbox,
|
||||
MessageSquareWarning,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
|
||||
const REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW";
|
||||
@@ -30,85 +44,214 @@ const REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW";
|
||||
export default function GlClearancePage() {
|
||||
const qc = useQueryClient();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// Bookings currently awaiting GL document review.
|
||||
const { data: list, isLoading } = useQuery({
|
||||
queryKey: ["gl-clearance", "list"],
|
||||
queryFn: () => bookingsService.list({ status: REVIEW_STATUS, pageSize: 100 }),
|
||||
queryFn: () =>
|
||||
bookingsService.list({ status: REVIEW_STATUS, pageSize: 100 }),
|
||||
});
|
||||
|
||||
const bookings = list?.items ?? [];
|
||||
const activeId = selectedId ?? bookings[0]?.id ?? null;
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return bookings;
|
||||
return bookings.filter(
|
||||
(b) =>
|
||||
b.reference?.toLowerCase().includes(q) ||
|
||||
b.tradeDirection?.toLowerCase().includes(q) ||
|
||||
b.freightType?.toLowerCase().includes(q),
|
||||
);
|
||||
}, [bookings, search]);
|
||||
|
||||
const activeId =
|
||||
selectedId && filtered.some((b) => b.id === selectedId)
|
||||
? selectedId
|
||||
: (filtered[0]?.id ?? null);
|
||||
|
||||
return (
|
||||
<Box p="lg">
|
||||
<Group gap={10} mb="lg">
|
||||
<ShieldCheck size={22} color="#0A6F4D" />
|
||||
<Text fw={800} fz="22px" c="#10202F">
|
||||
Document Clearance
|
||||
</Text>
|
||||
</Group>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Document Clearance"
|
||||
subtitle="Review customer documents, approve or raise a query, and finalize clearance."
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={13} />}
|
||||
>
|
||||
{bookings.length} awaiting review
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
<Card withBorder radius="md" p="sm" style={{ width: 300, flexShrink: 0 }}>
|
||||
<Text fz="13px" fw={700} c="#10202F" mb="xs">
|
||||
Awaiting review ({bookings.length})
|
||||
</Text>
|
||||
{isLoading && (
|
||||
<Text fz="13px" c="dimmed">
|
||||
Loading…
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-start">
|
||||
{/* ── Review queue ─────────────────────────────────────────────── */}
|
||||
<Card
|
||||
withBorder
|
||||
shadow="sm"
|
||||
radius="lg"
|
||||
p="sm"
|
||||
className="w-full shrink-0 lg:w-[320px]"
|
||||
>
|
||||
<Group justify="space-between" align="center" mb="xs" px={4}>
|
||||
<Text fz="13px" fw={700} c="edr-text">
|
||||
Review queue
|
||||
</Text>
|
||||
<Badge size="sm" variant="default" radius="sm">
|
||||
{filtered.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
placeholder="Search reference…"
|
||||
size="xs"
|
||||
radius="md"
|
||||
mb="xs"
|
||||
leftSection={<Search size={14} />}
|
||||
rightSection={
|
||||
search ? (
|
||||
<X
|
||||
size={14}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => setSearch("")}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="lg" gap={8}>
|
||||
<Loader size="xs" color="edr-green" />
|
||||
<Text fz="13px" c="dimmed">
|
||||
Loading…
|
||||
</Text>
|
||||
</Group>
|
||||
) : filtered.length === 0 ? (
|
||||
<Stack align="center" gap={6} py="xl">
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={40}>
|
||||
<Inbox size={20} />
|
||||
</ThemeIcon>
|
||||
<Text fz="13px" c="dimmed" ta="center">
|
||||
{search
|
||||
? "No bookings match your search."
|
||||
: "Nothing awaiting document review."}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={620} type="hover" offsetScrollbars>
|
||||
<Stack gap={6}>
|
||||
{filtered.map((b) => (
|
||||
<QueueItem
|
||||
key={b.id}
|
||||
booking={b}
|
||||
active={b.id === activeId}
|
||||
onSelect={() => setSelectedId(b.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
{!isLoading && bookings.length === 0 && (
|
||||
<Text fz="13px" c="dimmed">
|
||||
No bookings awaiting document review.
|
||||
</Text>
|
||||
)}
|
||||
<Stack gap={6}>
|
||||
{bookings.map((b) => (
|
||||
<button
|
||||
key={b.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(b.id)}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
border: `1px solid ${b.id === activeId ? "#0A6F4D" : "#E6ECF2"}`,
|
||||
background: b.id === activeId ? "#F4FBF7" : "#fff",
|
||||
borderRadius: 10,
|
||||
padding: "8px 10px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Text fz="13px" fw={600} c="#10202F">
|
||||
{b.reference}
|
||||
</Text>
|
||||
<Text fz="11.5px" c="dimmed">
|
||||
{b.tradeDirection} · {b.freightType}
|
||||
</Text>
|
||||
</button>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* ── Review panel ─────────────────────────────────────────────── */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
{activeId ? (
|
||||
<ClearanceReviewPanel
|
||||
key={activeId}
|
||||
bookingId={activeId}
|
||||
onChanged={() =>
|
||||
qc.invalidateQueries({ queryKey: ["gl-clearance", "list"] })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Card withBorder radius="md" p="xl">
|
||||
<Text c="dimmed">Select a booking to review its documents.</Text>
|
||||
</Card>
|
||||
<EmptyPanel />
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single booking row in the left-hand review queue. */
|
||||
function QueueItem({
|
||||
booking,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
ta="left"
|
||||
p="xs"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderRadius: 12,
|
||||
border: "1px solid",
|
||||
borderColor: active
|
||||
? "var(--mantine-color-edr-green-5)"
|
||||
: "var(--mantine-color-edr-border-6)",
|
||||
background: active
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: "var(--mantine-color-edr-card-6)",
|
||||
transition: "all 120ms ease",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap={8}>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz="13.5px" fw={700} c="edr-text" truncate>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
<Group gap={6} mt={3} wrap="nowrap">
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
radius="sm"
|
||||
color={
|
||||
booking.tradeDirection === "IMPORT" ? "edr-blue" : "edr-accent"
|
||||
}
|
||||
>
|
||||
{booking.tradeDirection}
|
||||
</Badge>
|
||||
<Text fz="11px" c="edr-muted" truncate>
|
||||
{booking.freightType}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPanel() {
|
||||
return (
|
||||
<Card withBorder shadow="sm" radius="lg" p={48}>
|
||||
<Stack align="center" gap={10}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="xl" size={56}>
|
||||
<ShieldCheck size={28} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} c="edr-text">
|
||||
No booking selected
|
||||
</Text>
|
||||
<Text fz="13px" c="dimmed" ta="center" maw={320}>
|
||||
Pick a booking from the review queue to inspect its customer documents
|
||||
and start clearance.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ClearanceReviewPanel({
|
||||
bookingId,
|
||||
onChanged,
|
||||
@@ -118,6 +261,7 @@ function ClearanceReviewPanel({
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
||||
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({});
|
||||
|
||||
const { data: clearance, isLoading } = useQuery({
|
||||
@@ -136,15 +280,20 @@ function ClearanceReviewPanel({
|
||||
status: "APPROVED" | "QUERIED";
|
||||
note?: string;
|
||||
}) => bookingsService.reviewClearanceDocument(bookingId, p),
|
||||
onSuccess: () => {
|
||||
toast.success("Document updated");
|
||||
onSuccess: (_d, p) => {
|
||||
toast.success(
|
||||
p.status === "APPROVED" ? "Document approved" : "Query sent to customer",
|
||||
);
|
||||
if (p.status === "QUERIED")
|
||||
setOpenQuery((o) => ({ ...o, [p.fileKey]: false }));
|
||||
refresh();
|
||||
},
|
||||
onError: () => toast.error("Could not update document"),
|
||||
});
|
||||
|
||||
const outputMutation = useMutation({
|
||||
mutationFn: () => bookingsService.uploadClearanceOutput(bookingId, outputFiles),
|
||||
mutationFn: () =>
|
||||
bookingsService.uploadClearanceOutput(bookingId, outputFiles),
|
||||
onSuccess: () => {
|
||||
toast.success("Output documents uploaded");
|
||||
setOutputFiles({});
|
||||
@@ -160,7 +309,9 @@ function ClearanceReviewPanel({
|
||||
refresh();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : "Could not finalize clearance"),
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Could not finalize clearance",
|
||||
),
|
||||
});
|
||||
|
||||
const customerDocs = useMemo(
|
||||
@@ -172,85 +323,151 @@ function ClearanceReviewPanel({
|
||||
[clearance],
|
||||
);
|
||||
|
||||
// Review progress across the customer documents — drives the summary bar.
|
||||
const stats = useMemo(() => {
|
||||
const total = customerDocs.length;
|
||||
const approved = customerDocs.filter(
|
||||
(d) => d.reviewStatus === "APPROVED",
|
||||
).length;
|
||||
const queried = customerDocs.filter(
|
||||
(d) => d.reviewStatus === "QUERIED",
|
||||
).length;
|
||||
const pending = total - approved - queried;
|
||||
return { total, approved, queried, pending };
|
||||
}, [customerDocs]);
|
||||
|
||||
if (isLoading || !clearance) {
|
||||
return (
|
||||
<Card withBorder radius="md" p="xl">
|
||||
<Text c="dimmed">Loading clearance…</Text>
|
||||
<Card withBorder shadow="sm" radius="lg" p={48}>
|
||||
<Group justify="center" gap={10}>
|
||||
<Loader size="sm" color="edr-green" />
|
||||
<Text c="dimmed">Loading clearance…</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const progressPct =
|
||||
stats.total === 0 ? 0 : Math.round((stats.approved / stats.total) * 100);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={700} c="#10202F">
|
||||
Customer documents
|
||||
</Text>
|
||||
{/* ── Progress summary ───────────────────────────────────────────── */}
|
||||
<Card withBorder shadow="sm" radius="lg" p="lg">
|
||||
<Group justify="space-between" align="flex-start" mb="md">
|
||||
<Box>
|
||||
<Text fw={700} fz="15px" c="edr-text">
|
||||
Customer documents
|
||||
</Text>
|
||||
<Text fz="12.5px" c="dimmed" mt={2}>
|
||||
Approve each document, or open a query to tell the customer what to
|
||||
fix.
|
||||
</Text>
|
||||
</Box>
|
||||
{clearance.allApproved ? (
|
||||
<Group gap={6} c="#0A6F4D">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text fz="12.5px" fw={600} c="#0A6F4D">
|
||||
All approved
|
||||
</Text>
|
||||
</Group>
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
size="lg"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
>
|
||||
All approved
|
||||
</Badge>
|
||||
) : (
|
||||
<Group gap={6} c="#2E5B96">
|
||||
<Clock size={16} />
|
||||
<Text fz="12.5px" fw={600} c="#2E5B96">
|
||||
Review pending
|
||||
</Text>
|
||||
</Group>
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-blue"
|
||||
radius="sm"
|
||||
size="lg"
|
||||
leftSection={<Clock size={14} />}
|
||||
>
|
||||
Review pending
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Stack gap={12}>
|
||||
{customerDocs.map((doc) => (
|
||||
<DocReviewRow
|
||||
key={`${doc.settingCode}:${doc.fileKey}`}
|
||||
doc={doc}
|
||||
note={queryNotes[doc.fileKey] ?? ""}
|
||||
onNote={(v) =>
|
||||
setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))
|
||||
}
|
||||
onApprove={() =>
|
||||
reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" })
|
||||
}
|
||||
onQuery={() =>
|
||||
reviewMutation.mutate({
|
||||
fileKey: doc.fileKey,
|
||||
status: "QUERIED",
|
||||
note: queryNotes[doc.fileKey],
|
||||
})
|
||||
}
|
||||
busy={reviewMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Progress
|
||||
value={progressPct}
|
||||
color="edr-green"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
mb="sm"
|
||||
/>
|
||||
<Group gap="lg">
|
||||
<StatPill color="edr-green" label="Approved" value={stats.approved} />
|
||||
<StatPill color="red" label="Queried" value={stats.queried} />
|
||||
<StatPill color="edr-slate" label="Pending" value={stats.pending} />
|
||||
<Text fz="12.5px" c="dimmed" ml="auto">
|
||||
{stats.approved}/{stats.total} approved
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
{/* ── Document review list ───────────────────────────────────────── */}
|
||||
<Stack gap={12}>
|
||||
{customerDocs.map((doc) => (
|
||||
<DocReviewCard
|
||||
key={`${doc.settingCode}:${doc.fileKey}`}
|
||||
doc={doc}
|
||||
note={queryNotes[doc.fileKey] ?? ""}
|
||||
queryOpen={openQuery[doc.fileKey] ?? false}
|
||||
onToggleQuery={(open) =>
|
||||
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
|
||||
}
|
||||
onNote={(v) => setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))}
|
||||
onApprove={() =>
|
||||
reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" })
|
||||
}
|
||||
onQuery={() =>
|
||||
reviewMutation.mutate({
|
||||
fileKey: doc.fileKey,
|
||||
status: "QUERIED",
|
||||
note: queryNotes[doc.fileKey],
|
||||
})
|
||||
}
|
||||
busy={reviewMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* ── Customs output documents (GL-supplied) ─────────────────────── */}
|
||||
{clearance.outputCode && (
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Text fw={700} c="#10202F" mb="md">
|
||||
Customs output documents
|
||||
</Text>
|
||||
<Card withBorder shadow="sm" radius="lg" p="lg">
|
||||
<Group gap={8} mb="md">
|
||||
<ThemeIcon variant="light" color="edr-blue" radius="md" size={28}>
|
||||
<Upload size={15} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} c="edr-text">
|
||||
Customs output documents
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={10}>
|
||||
{glDocs.map((doc) => (
|
||||
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<FileText size={16} color="#2E5B96" />
|
||||
<Text fz="13px" c="#10202F" truncate>
|
||||
<FileText size={16} color="var(--mantine-color-edr-blue-6)" />
|
||||
<Text fz="13px" c="edr-text" truncate>
|
||||
{doc.label}
|
||||
{doc.required ? " *" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{doc.file ? (
|
||||
<a href={doc.file.url} target="_blank" rel="noreferrer">
|
||||
<Download size={15} />
|
||||
</a>
|
||||
<Tooltip label="Download">
|
||||
<Box
|
||||
component="a"
|
||||
href={doc.file.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
c="edr-blue"
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<Download size={15} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text fz="12px" c="#9AA8B5">
|
||||
<Text fz="12px" c="edr-muted">
|
||||
Not uploaded
|
||||
</Text>
|
||||
)}
|
||||
@@ -300,25 +517,74 @@ function ClearanceReviewPanel({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
disabled={!clearance.allApproved}
|
||||
loading={finalizeMutation.isPending}
|
||||
onClick={() => finalizeMutation.mutate()}
|
||||
>
|
||||
Finalize clearance
|
||||
</Button>
|
||||
</Group>
|
||||
{/* ── Finalize bar ───────────────────────────────────────────────── */}
|
||||
<Card withBorder shadow="sm" radius="lg" p="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
{clearance.allApproved
|
||||
? "All required documents are approved. You can finalize clearance."
|
||||
: "Approve every required document to unlock finalization."}
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
disabled={!clearance.allApproved}
|
||||
loading={finalizeMutation.isPending}
|
||||
onClick={() => finalizeMutation.mutate()}
|
||||
>
|
||||
Finalize clearance
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function DocReviewRow({
|
||||
function StatPill({
|
||||
color,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
color: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
background: `var(--mantine-color-${color}-6)`,
|
||||
}}
|
||||
/>
|
||||
<Text fz="12.5px" c="edr-text" fw={600}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Visual treatment for each document review state. */
|
||||
const STATUS_META: Record<
|
||||
Freight.DocumentReviewStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
APPROVED: { label: "Approved", color: "edr-green" },
|
||||
QUERIED: { label: "Queried", color: "red" },
|
||||
PENDING: { label: "Pending", color: "edr-slate" },
|
||||
};
|
||||
|
||||
function DocReviewCard({
|
||||
doc,
|
||||
note,
|
||||
queryOpen,
|
||||
onToggleQuery,
|
||||
onNote,
|
||||
onApprove,
|
||||
onQuery,
|
||||
@@ -326,74 +592,174 @@ function DocReviewRow({
|
||||
}: {
|
||||
doc: Freight.ClearanceDocument;
|
||||
note: string;
|
||||
queryOpen: boolean;
|
||||
onToggleQuery: (open: boolean) => void;
|
||||
onNote: (v: string) => void;
|
||||
onApprove: () => void;
|
||||
onQuery: () => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const status = doc.reviewStatus ?? "PENDING";
|
||||
const meta = STATUS_META[status];
|
||||
const hasFile = !!doc.file;
|
||||
|
||||
return (
|
||||
<Box className="rounded-xl" style={{ border: "1px solid #E6ECF2", padding: 12 }}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<FileText size={18} color="#2E5B96" />
|
||||
<Card
|
||||
withBorder
|
||||
shadow="sm"
|
||||
radius="lg"
|
||||
p="md"
|
||||
style={{
|
||||
borderColor:
|
||||
status === "QUERIED"
|
||||
? "var(--mantine-color-red-2)"
|
||||
: status === "APPROVED"
|
||||
? "var(--mantine-color-edr-green-2)"
|
||||
: "var(--mantine-color-edr-border-6)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={hasFile ? "edr-blue" : "gray"}
|
||||
radius="md"
|
||||
size={40}
|
||||
>
|
||||
<FileText size={19} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz="13.5px" fw={600} c="#10202F" truncate>
|
||||
<Text fz="14px" fw={700} c="edr-text" truncate>
|
||||
{doc.label}
|
||||
{doc.required ? " *" : ""}
|
||||
</Text>
|
||||
<Text fz="12px" c="dimmed" truncate>
|
||||
{doc.file ? doc.file.name : "Not uploaded"}
|
||||
<Text fz="12px" c="edr-muted" truncate>
|
||||
{hasFile ? doc.file!.name : "Not uploaded by customer"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{doc.reviewStatus === "APPROVED" && (
|
||||
<Text fz="12px" fw={600} c="#0A6F4D">
|
||||
Approved
|
||||
</Text>
|
||||
)}
|
||||
{doc.reviewStatus === "QUERIED" && (
|
||||
<Text fz="12px" fw={600} c="#C0392B">
|
||||
Queried
|
||||
</Text>
|
||||
)}
|
||||
{doc.file && (
|
||||
<a href={doc.file.url} target="_blank" rel="noreferrer">
|
||||
<Download size={15} />
|
||||
</a>
|
||||
<Badge variant="light" color={meta.color} radius="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{hasFile && (
|
||||
<Tooltip label="Open document">
|
||||
<Button
|
||||
component="a"
|
||||
href={doc.file!.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<ExternalLink size={13} />}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{doc.file && (
|
||||
<Group gap={8} mt={10} align="flex-end" wrap="nowrap">
|
||||
<TextInput
|
||||
placeholder="Query note (required to query)"
|
||||
value={note}
|
||||
onChange={(e) => onNote(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
radius="md"
|
||||
size="xs"
|
||||
/>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
disabled={busy || !note.trim()}
|
||||
onClick={onQuery}
|
||||
>
|
||||
Query
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
disabled={busy}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</Group>
|
||||
{/* Previously raised query — visible so staff see what was asked. */}
|
||||
{status === "QUERIED" && doc.note && (
|
||||
<Alert
|
||||
mt="sm"
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<MessageSquareWarning size={15} />}
|
||||
p="xs"
|
||||
>
|
||||
<Text fz="12.5px" c="red.9">
|
||||
{doc.note}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Action row — only when the customer actually uploaded a file. */}
|
||||
{hasFile && (
|
||||
<Box mt="sm">
|
||||
{!queryOpen ? (
|
||||
<Group justify="flex-end" gap={8}>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<MessageSquareWarning size={14} />}
|
||||
disabled={busy}
|
||||
onClick={() => onToggleQuery(true)}
|
||||
>
|
||||
Open query
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
disabled={busy}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: "var(--mantine-color-red-0)",
|
||||
border: "1px solid var(--mantine-color-red-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap={6} mb={6}>
|
||||
<MessageSquareWarning
|
||||
size={14}
|
||||
color="var(--mantine-color-red-7)"
|
||||
/>
|
||||
<Text fz="12.5px" fw={700} c="red.8">
|
||||
Describe the problem for the customer
|
||||
</Text>
|
||||
</Group>
|
||||
<Textarea
|
||||
placeholder="e.g. The commercial invoice is missing the HS code and the totals don't match the packing list."
|
||||
value={note}
|
||||
onChange={(e) => onNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
radius="md"
|
||||
size="sm"
|
||||
autoFocus
|
||||
/>
|
||||
<Group justify="flex-end" gap={8} mt={8}>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
disabled={busy}
|
||||
onClick={() => onToggleQuery(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<MessageSquareWarning size={14} />}
|
||||
loading={busy}
|
||||
disabled={!note.trim()}
|
||||
onClick={onQuery}
|
||||
>
|
||||
Send query to customer
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1835,6 +1835,18 @@ export const api = {
|
||||
({ id, reason }) => bookingsService.staffReject(id, reason),
|
||||
),
|
||||
|
||||
reviewOperation: endpoint<
|
||||
{
|
||||
id: string;
|
||||
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE";
|
||||
note?: string;
|
||||
amount?: number;
|
||||
},
|
||||
BookingDetail
|
||||
>("bookings", "reviewOperation", ({ id, decision, note, amount }) =>
|
||||
bookingsService.reviewOperation(id, decision, { note, amount }),
|
||||
),
|
||||
|
||||
approveStep: endpoint<ApproveStepPayload, BookingDetail>(
|
||||
"bookings",
|
||||
"approveStep",
|
||||
|
||||
@@ -209,6 +209,17 @@ export const bookingsService = {
|
||||
staffReject: (id: string, reason: string) =>
|
||||
postBooking<BookingDetail>(B.STAFF_REJECT(id), { reason }),
|
||||
|
||||
/** Marketing/operations review of a drawdown order's operation request. */
|
||||
reviewOperation: (
|
||||
id: string,
|
||||
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE",
|
||||
options: { note?: string; amount?: number } = {},
|
||||
) =>
|
||||
postBooking<BookingDetail>(`/bookings/${id}/operation/review`, {
|
||||
decision,
|
||||
...options,
|
||||
}),
|
||||
|
||||
/** Adjust a booking's total price (pass null amount to clear the adjustment). */
|
||||
adjustPrice: (id: string, amount: number | null, reason?: string) =>
|
||||
postBooking<BookingDetail>(`/bookings/${id}/adjust-price`, {
|
||||
|
||||
@@ -144,6 +144,29 @@ export const STATUS_MAP: Record<
|
||||
description: "Your shipment is currently moving through the rail network.",
|
||||
stage: 6,
|
||||
},
|
||||
OPERATION_REQUEST_PENDING: {
|
||||
title: "Operation request under review",
|
||||
description:
|
||||
"Your order has been submitted to operations and is awaiting acceptance.",
|
||||
stage: 4,
|
||||
},
|
||||
OPERATION_CHANGES_REQUESTED: {
|
||||
title: "Operation changes requested",
|
||||
description: "Operations requested changes to this order. Please review and resubmit.",
|
||||
stage: 4,
|
||||
},
|
||||
OPERATION_PRICE_PENDING_CONFIRM: {
|
||||
title: "Price adjusted — confirm to proceed",
|
||||
description:
|
||||
"Operations adjusted this order's price. Confirm the new price to proceed.",
|
||||
stage: 4,
|
||||
},
|
||||
ROAD_DISPATCH_PENDING: {
|
||||
title: "Awaiting truck dispatch",
|
||||
description:
|
||||
"This road order was accepted and is awaiting truck dispatch. Billed by distance.",
|
||||
stage: 5,
|
||||
},
|
||||
PENDING_CONSOLIDATION: {
|
||||
title: "Pending consolidation",
|
||||
description: "Awaiting a consolidation partner shipment.",
|
||||
|
||||
@@ -486,6 +486,7 @@ export default function NewBookingPage() {
|
||||
originYardId: r.originYard,
|
||||
destinationYardId: r.destinationYard,
|
||||
quantity: Number(r.quantity),
|
||||
...(r.km && Number(r.km) > 0 ? { km: Number(r.km) } : {}),
|
||||
})),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -156,6 +156,8 @@ export const bookingFormSchema = z
|
||||
originYard: z.string(),
|
||||
destinationYard: z.string(),
|
||||
quantity: z.string(),
|
||||
// Road distance for this route; used to bill road (truck) orders.
|
||||
km: z.string().default(""),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
|
||||
@@ -208,6 +208,7 @@ export function Step4Route({
|
||||
originYard: "",
|
||||
destinationYard: "",
|
||||
quantity: "",
|
||||
km: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -276,6 +277,23 @@ export function Step4Route({
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ width: 110 }}>
|
||||
<Controller
|
||||
name={`extraRoutes.${i}.km`}
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<NumberInput
|
||||
label="Distance (km)"
|
||||
placeholder="0"
|
||||
min={0}
|
||||
step={1}
|
||||
value={field.value === "" ? "" : Number(field.value)}
|
||||
onChange={(v) => field.onChange(String(v ?? ""))}
|
||||
radius="md"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
@@ -18,8 +20,10 @@ import {
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
Inbox,
|
||||
Layers,
|
||||
MapPin,
|
||||
PackageCheck,
|
||||
PackagePlus,
|
||||
Ship,
|
||||
} from "lucide-react";
|
||||
@@ -34,6 +38,7 @@ import {
|
||||
INK,
|
||||
MetaItem,
|
||||
MUTED,
|
||||
StatCard,
|
||||
} from "./contract-ui";
|
||||
import { PlaceOrderDialog } from "./PlaceOrderDialog";
|
||||
|
||||
@@ -94,6 +99,18 @@ export default function ContractDetailPage() {
|
||||
const isActive = contract.status === "CONTRACT_ACTIVE";
|
||||
const awaitingPayment = contract.status === "FULLY_EXECUTED";
|
||||
const poolLines = pool ?? [];
|
||||
const showPool = contract.status !== "DRAFT";
|
||||
|
||||
// Overall utilization across every pool line — drives the header ring + stat.
|
||||
const totals = useMemo(() => {
|
||||
const contracted = poolLines.reduce(
|
||||
(s, l) => s + (l.contractedQuantity || 0),
|
||||
0,
|
||||
);
|
||||
const ordered = poolLines.reduce((s, l) => s + (l.orderedQuantity || 0), 0);
|
||||
const pct = contracted > 0 ? Math.round((ordered / contracted) * 100) : 0;
|
||||
return { contracted, ordered, pct };
|
||||
}, [poolLines]);
|
||||
|
||||
return (
|
||||
<Box style={{ padding: "28px 32px 40px" }}>
|
||||
@@ -111,8 +128,8 @@ export default function ContractDetailPage() {
|
||||
<ArrowLeft size={18} />
|
||||
</Button>
|
||||
<Group gap={12} wrap="nowrap" align="center">
|
||||
<ThemeIcon size={46} radius="md" variant="light" color="violet">
|
||||
<Layers size={22} />
|
||||
<ThemeIcon size={48} radius="lg" variant="light" color="violet">
|
||||
<Layers size={23} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Group gap={10} align="center">
|
||||
@@ -122,18 +139,22 @@ export default function ContractDetailPage() {
|
||||
<ContractStatusBadge status={contract.status} />
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt={2}>
|
||||
General contract · {isContainer ? "Containerised" : "Bulk"}
|
||||
General contract · {isContainer ? "Containerised" : "Bulk"} ·{" "}
|
||||
{contract.tradeDirection ?? "—"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
{awaitingPayment && <PayNowButton booking={contract} label="Pay & activate" size="sm" />}
|
||||
{awaitingPayment && (
|
||||
<PayNowButton booking={contract} label="Pay & activate" size="sm" />
|
||||
)}
|
||||
{isActive && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={() => setOrderOpen(true)}
|
||||
>
|
||||
@@ -143,7 +164,7 @@ export default function ContractDetailPage() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Summary */}
|
||||
{/* Summary meta */}
|
||||
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||
<Group gap={48} wrap="wrap">
|
||||
<MetaItem
|
||||
@@ -168,15 +189,61 @@ export default function ContractDetailPage() {
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Stat strip */}
|
||||
{showPool && (
|
||||
<Group gap="md" wrap="wrap" align="stretch">
|
||||
<StatCard
|
||||
label="Orders placed"
|
||||
value={orders?.length ?? 0}
|
||||
icon={PackageCheck}
|
||||
color="violet"
|
||||
/>
|
||||
<StatCard
|
||||
label="Utilization"
|
||||
hint="of reserved quantity"
|
||||
value={`${totals.pct}%`}
|
||||
icon={PackageCheck}
|
||||
color="edr-green"
|
||||
/>
|
||||
<StatCard
|
||||
label="Ordering until"
|
||||
value={
|
||||
contract.expiresAt
|
||||
? new Date(contract.expiresAt).toLocaleDateString()
|
||||
: "—"
|
||||
}
|
||||
icon={CalendarClock}
|
||||
color="edr-accent"
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* Drawdown pool */}
|
||||
{contract.status !== "DRAFT" && (
|
||||
{showPool && (
|
||||
<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>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="lg">
|
||||
<Box>
|
||||
<Text fw={700} fz={16} style={{ color: INK }}>
|
||||
Contracted quantity
|
||||
</Text>
|
||||
<Text fz={13} c="dimmed" mt={2}>
|
||||
How much of this contract has been ordered versus what remains.
|
||||
</Text>
|
||||
</Box>
|
||||
{totals.contracted > 0 && (
|
||||
<RingProgress
|
||||
size={72}
|
||||
thickness={7}
|
||||
roundCaps
|
||||
sections={[{ value: totals.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Text ta="center" fz={13} fw={800} style={{ color: INK }}>
|
||||
{totals.pct}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Stack gap="lg">
|
||||
{poolLines.length === 0 && (
|
||||
<Text fz={13} c="dimmed">
|
||||
@@ -196,14 +263,26 @@ export default function ContractDetailPage() {
|
||||
: line.unitOfMeasure === "PER_ITEM"
|
||||
? "Items"
|
||||
: "Tons";
|
||||
const depleted = line.remainingQuantity <= 0;
|
||||
return (
|
||||
<div key={line.containerTypeId ?? `bulk-${i}`}>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text fz={14} fw={600} style={{ color: INK }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap={8} align="center">
|
||||
<Text fz={14} fw={600} style={{ color: INK }}>
|
||||
{label}
|
||||
</Text>
|
||||
{depleted && (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
Fully ordered
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz={13} c="dimmed">
|
||||
<Text span fw={700} style={{ color: GREEN }}>
|
||||
<Text
|
||||
span
|
||||
fw={700}
|
||||
style={{ color: depleted ? MUTED : GREEN }}
|
||||
>
|
||||
{formatQuantity(
|
||||
line.remainingQuantity,
|
||||
line.unitOfMeasure,
|
||||
@@ -220,7 +299,7 @@ export default function ContractDetailPage() {
|
||||
</Group>
|
||||
<Progress
|
||||
value={pct}
|
||||
color="edr-green"
|
||||
color={depleted ? "gray" : "edr-green"}
|
||||
size="md"
|
||||
radius="xl"
|
||||
/>
|
||||
@@ -233,46 +312,64 @@ export default function ContractDetailPage() {
|
||||
|
||||
{/* 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)."}
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<Text fw={700} fz={16} style={{ color: INK }}>
|
||||
Orders
|
||||
</Text>
|
||||
<Badge variant="light" color="violet" radius="sm">
|
||||
{orders?.length ?? 0}
|
||||
</Badge>
|
||||
</Group>
|
||||
{!orders || orders.length === 0 ? (
|
||||
<Stack align="center" gap={8} py="xl">
|
||||
<ThemeIcon size={48} radius="xl" variant="light" color="gray">
|
||||
<Inbox size={22} />
|
||||
</ThemeIcon>
|
||||
<Text fz={13} c="dimmed" ta="center" maw={360}>
|
||||
{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>
|
||||
) : (
|
||||
<Stack gap={0}>
|
||||
{orders.map((order, idx) => (
|
||||
<Box
|
||||
<Stack gap={10}>
|
||||
{orders.map((order) => (
|
||||
<Group
|
||||
key={order.id}
|
||||
py="sm"
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="sm"
|
||||
style={{
|
||||
borderTop: idx === 0 ? undefined : `1px solid ${BORDER}`,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${BORDER}`,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text fz={14} fw={700} style={{ color: INK }}>
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon size={38} radius="md" variant="light" color="violet">
|
||||
<PackageCheck size={18} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||
{order.reference}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed">
|
||||
<Text fz={12} c="dimmed" truncate>
|
||||
Ship {new Date(order.scheduledDate).toLocaleDateString()}
|
||||
{" · "}
|
||||
{order.lines
|
||||
.map(
|
||||
(l) =>
|
||||
`${Number.isInteger(l.quantity) ? l.quantity : l.quantity.toFixed(2)}${
|
||||
l.containerTypeName ? ` ${l.containerTypeName}` : ""
|
||||
l.containerTypeName
|
||||
? ` ${l.containerTypeName}`
|
||||
: ""
|
||||
}`,
|
||||
)
|
||||
.join(", ")}
|
||||
</Text>
|
||||
</div>
|
||||
<ContractStatusBadge status={order.status} />
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
<ContractStatusBadge status={order.status} />
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,15 @@ import {
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Layers, Plus, Search, X } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
FileStack,
|
||||
Layers,
|
||||
Plus,
|
||||
Search,
|
||||
Timer,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { BookingListFilter } from "@/services/bookings.service";
|
||||
@@ -26,7 +34,7 @@ import {
|
||||
usePagination,
|
||||
} from "@edr/ui-common";
|
||||
import { CargoModeCell, PaymentBadge } from "../bookings/booking-display";
|
||||
import { ContractStatusBadge, GREEN, INK, MUTED } from "./contract-ui";
|
||||
import { BORDER, ContractStatusBadge, INK, StatCard } from "./contract-ui";
|
||||
|
||||
export default function ContractsList() {
|
||||
const navigate = useNavigate();
|
||||
@@ -82,11 +90,22 @@ export default function ContractsList() {
|
||||
);
|
||||
}, [data, query]);
|
||||
|
||||
const activeCount = useMemo(
|
||||
() =>
|
||||
(data?.items ?? []).filter((b) => b.status === "CONTRACT_ACTIVE").length,
|
||||
[data],
|
||||
);
|
||||
const stats = useMemo(() => {
|
||||
const items = data?.items ?? [];
|
||||
const active = items.filter((b) => b.status === "CONTRACT_ACTIVE").length;
|
||||
const pending = items.filter((b) =>
|
||||
[
|
||||
"SUBMITTED",
|
||||
"PENDING_APPROVAL",
|
||||
"APPROVED_PENDING_SIGNATURE",
|
||||
"CONTRACT_READY",
|
||||
"SIGNED_CUSTOMER",
|
||||
"FULLY_EXECUTED",
|
||||
].includes(b.status),
|
||||
).length;
|
||||
const total = data?.meta?.total ?? items.length;
|
||||
return { active, pending, total };
|
||||
}, [data]);
|
||||
|
||||
const columns: ColumnDef<Freight.IBooking>[] = [
|
||||
{
|
||||
@@ -171,20 +190,24 @@ export default function ContractsList() {
|
||||
<Stack gap="lg">
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
|
||||
<Box>
|
||||
<Group gap={10} align="center">
|
||||
<Group gap={14} wrap="nowrap" align="center">
|
||||
<ThemeIcon size={48} radius="lg" variant="light" color="violet">
|
||||
<Layers size={24} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
General Contracts
|
||||
</Title>
|
||||
</Group>
|
||||
<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>
|
||||
<Text size="sm" c="edr-muted" mt={4} maw={520}>
|
||||
Reserve a quantity once, then place orders against it until the
|
||||
contract runs out or its window closes.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => navigate("/bookings/new")}
|
||||
>
|
||||
@@ -192,78 +215,97 @@ export default function ContractsList() {
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Summary */}
|
||||
<SimpleStat
|
||||
label="Active contracts"
|
||||
value={activeCount}
|
||||
hint="accepting orders"
|
||||
/>
|
||||
{/* Summary strip */}
|
||||
<Group gap="md" wrap="wrap" align="stretch">
|
||||
<StatCard
|
||||
label="Active"
|
||||
hint="accepting orders"
|
||||
value={stats.active}
|
||||
icon={CheckCircle2}
|
||||
color="edr-green"
|
||||
/>
|
||||
<StatCard
|
||||
label="In progress"
|
||||
hint="setup / signing"
|
||||
value={stats.pending}
|
||||
icon={Timer}
|
||||
color="edr-accent"
|
||||
/>
|
||||
<StatCard
|
||||
label="Total contracts"
|
||||
value={stats.total}
|
||||
icon={FileStack}
|
||||
color="violet"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Search + filters */}
|
||||
<Group gap={10} wrap="wrap" align="center">
|
||||
<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 } }}
|
||||
style={{ flex: 1, minWidth: 220, maxWidth: 360 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Any cargo"
|
||||
data={[
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
]}
|
||||
value={freightFilter}
|
||||
onChange={(v) => {
|
||||
setFreightFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 160 }}
|
||||
styles={{ input: { height: 44 } }}
|
||||
aria-label="Filter by cargo type"
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
value={createdFrom}
|
||||
onChange={(e) => {
|
||||
setCreatedFrom(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
radius="md"
|
||||
style={{ width: 160 }}
|
||||
styles={{ input: { height: 44 } }}
|
||||
aria-label="Created from"
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
value={createdTo}
|
||||
onChange={(e) => {
|
||||
setCreatedTo(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
radius="md"
|
||||
style={{ width: 160 }}
|
||||
styles={{ input: { height: 44 } }}
|
||||
aria-label="Created to"
|
||||
/>
|
||||
{hasExtraFilters && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
<Paper withBorder radius="lg" p="sm" style={{ borderColor: BORDER }}>
|
||||
<Group gap={10} wrap="wrap" align="center">
|
||||
<TextInput
|
||||
placeholder="Search by reference or route…"
|
||||
leftSection={<Search size={16} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
radius="md"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={clearExtraFilters}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
styles={{ input: { height: 42 } }}
|
||||
style={{ flex: 1, minWidth: 220, maxWidth: 360 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Any cargo"
|
||||
data={[
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
]}
|
||||
value={freightFilter}
|
||||
onChange={(v) => {
|
||||
setFreightFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 150 }}
|
||||
styles={{ input: { height: 42 } }}
|
||||
aria-label="Filter by cargo type"
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
value={createdFrom}
|
||||
onChange={(e) => {
|
||||
setCreatedFrom(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
radius="md"
|
||||
style={{ width: 152 }}
|
||||
styles={{ input: { height: 42 } }}
|
||||
aria-label="Created from"
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
value={createdTo}
|
||||
onChange={(e) => {
|
||||
setCreatedTo(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
radius="md"
|
||||
style={{ width: 152 }}
|
||||
styles={{ input: { height: 42 } }}
|
||||
aria-label="Created to"
|
||||
/>
|
||||
{hasExtraFilters && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={clearExtraFilters}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Table */}
|
||||
<Card p={0} style={{ overflow: "hidden" }}>
|
||||
@@ -302,37 +344,3 @@ function ColHeader({ label }: { label: string }) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, CalendarDays, PackagePlus } from "lucide-react";
|
||||
@@ -42,6 +43,11 @@ export function PlaceOrderDialog({
|
||||
const [scheduledDate, setScheduledDate] = useState<string | null>(null);
|
||||
const [quantities, setQuantities] = useState<Record<string, number | "">>({});
|
||||
const [routeLineId, setRouteLineId] = useState<string | null>(null);
|
||||
// Per-order hazardous / reefer counts, entered once when the toggle is on.
|
||||
const [hazardousOn, setHazardousOn] = useState(false);
|
||||
const [hazardousQty, setHazardousQty] = useState<number | "">("");
|
||||
const [reeferOn, setReeferOn] = useState(false);
|
||||
const [reeferQty, setReeferQty] = useState<number | "">("");
|
||||
|
||||
// Multi-route contracts expose route lines; single-route contracts return [].
|
||||
const { data: routeLines = [] } = useQuery({
|
||||
@@ -117,8 +123,28 @@ export function PlaceOrderDialog({
|
||||
setScheduledDate(null);
|
||||
setQuantities({});
|
||||
setRouteLineId(null);
|
||||
setHazardousOn(false);
|
||||
setHazardousQty("");
|
||||
setReeferOn(false);
|
||||
setReeferQty("");
|
||||
}
|
||||
|
||||
// Total quantity across the order; haz/reefer counts cannot exceed it.
|
||||
const orderTotalQty = isMultiRoute
|
||||
? typeof quantities["__route__"] === "number"
|
||||
? (quantities["__route__"] as number)
|
||||
: 0
|
||||
: pool.reduce((sum, l) => {
|
||||
const raw = quantities[lineKey(l)];
|
||||
return sum + (typeof raw === "number" ? raw : 0);
|
||||
}, 0);
|
||||
|
||||
const hazValue = hazardousOn && typeof hazardousQty === "number" ? hazardousQty : 0;
|
||||
const reeferValue = reeferOn && typeof reeferQty === "number" ? reeferQty : 0;
|
||||
const hazReeferValid =
|
||||
(!hazardousOn || (hazValue > 0 && hazValue <= orderTotalQty)) &&
|
||||
(!reeferOn || (reeferValue > 0 && reeferValue <= orderTotalQty));
|
||||
|
||||
function handleClose() {
|
||||
if (createMutation.isPending) return;
|
||||
reset();
|
||||
@@ -133,6 +159,7 @@ export function PlaceOrderDialog({
|
||||
const raw = quantities["__route__"];
|
||||
const qty = typeof raw === "number" ? raw : 0;
|
||||
if (qty <= 0) return;
|
||||
if (!hazReeferValid) return;
|
||||
createMutation.mutate({
|
||||
contractBookingId: contract.id,
|
||||
routeLineId: selectedRoute.routeLineId,
|
||||
@@ -143,6 +170,8 @@ export function PlaceOrderDialog({
|
||||
? (selectedRoute.containerTypeId ?? null)
|
||||
: null,
|
||||
quantity: qty,
|
||||
hazardousQuantity: hazValue,
|
||||
reeferQuantity: reeferValue,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -161,6 +190,14 @@ export function PlaceOrderDialog({
|
||||
.filter((l) => l.quantity > 0);
|
||||
|
||||
if (lines.length === 0) return;
|
||||
if (!hazReeferValid) return;
|
||||
|
||||
// Haz/reefer are entered once per order; attach the counts to the first line.
|
||||
lines[0] = {
|
||||
...lines[0],
|
||||
hazardousQuantity: hazValue,
|
||||
reeferQuantity: reeferValue,
|
||||
};
|
||||
|
||||
createMutation.mutate({
|
||||
contractBookingId: contract.id,
|
||||
@@ -180,6 +217,7 @@ export function PlaceOrderDialog({
|
||||
const canSubmit =
|
||||
!!scheduledDate &&
|
||||
hasQuantity &&
|
||||
hazReeferValid &&
|
||||
(!isMultiRoute || !!selectedRoute) &&
|
||||
!createMutation.isPending;
|
||||
|
||||
@@ -346,6 +384,67 @@ export function PlaceOrderDialog({
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack gap="sm">
|
||||
<Text fz={13} fw={600} style={{ color: INK }}>
|
||||
Cargo handling
|
||||
</Text>
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Switch
|
||||
label="Hazardous cargo"
|
||||
checked={hazardousOn}
|
||||
onChange={(e) => {
|
||||
setHazardousOn(e.currentTarget.checked);
|
||||
if (!e.currentTarget.checked) setHazardousQty("");
|
||||
}}
|
||||
color="edr-green"
|
||||
/>
|
||||
{hazardousOn && (
|
||||
<NumberInput
|
||||
value={hazardousQty}
|
||||
onChange={(v) => setHazardousQty(v === "" ? "" : Number(v))}
|
||||
min={0}
|
||||
max={orderTotalQty || undefined}
|
||||
step={isContainer ? 1 : 0.5}
|
||||
clampBehavior="strict"
|
||||
radius="md"
|
||||
w={130}
|
||||
placeholder="How many"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Switch
|
||||
label="Refrigerated (reefer)"
|
||||
checked={reeferOn}
|
||||
onChange={(e) => {
|
||||
setReeferOn(e.currentTarget.checked);
|
||||
if (!e.currentTarget.checked) setReeferQty("");
|
||||
}}
|
||||
color="edr-green"
|
||||
/>
|
||||
{reeferOn && (
|
||||
<NumberInput
|
||||
value={reeferQty}
|
||||
onChange={(v) => setReeferQty(v === "" ? "" : Number(v))}
|
||||
min={0}
|
||||
max={orderTotalQty || undefined}
|
||||
step={isContainer ? 1 : 0.5}
|
||||
clampBehavior="strict"
|
||||
radius="md"
|
||||
w={130}
|
||||
placeholder="How many"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
{(hazardousOn || reeferOn) && (
|
||||
<Text fz={12} c="dimmed">
|
||||
Hazardous/reefer quantity cannot exceed the order total
|
||||
{orderTotalQty > 0 ? ` (${orderTotalQty})` : ""}. These add the
|
||||
relevant surcharge to this order's price.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{createMutation.isError && (
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
{createMutation.error instanceof Error
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Badge, Group, Text } from "@mantine/core";
|
||||
import { Box, Badge, Group, Paper, Text, ThemeIcon } from "@mantine/core";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Brand palette (mirrors the booking form's shared constants).
|
||||
@@ -8,6 +9,48 @@ export const GREEN = "#0EA371";
|
||||
export const GREEN_DARK = "#0A6F4D";
|
||||
export const BORDER = "#E6ECF2";
|
||||
|
||||
/**
|
||||
* A compact KPI tile used on the contracts list + detail header strips. Icon in
|
||||
* a tinted chip, big value, small label — consistent with the app's house cards.
|
||||
*/
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
icon: Icon,
|
||||
color = "edr-green",
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: string;
|
||||
icon: LucideIcon;
|
||||
color?: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="md"
|
||||
style={{ borderColor: BORDER, flex: 1, minWidth: 180 }}
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" align="center">
|
||||
<ThemeIcon size={42} radius="md" variant="light" color={color}>
|
||||
<Icon size={20} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={24} fw={800} lh={1.05} style={{ color: INK, letterSpacing: "-0.02em" }}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text fz={12} fw={600} c="dimmed" truncate>
|
||||
{label}
|
||||
{hint ? ` · ${hint}` : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** Visual config for a general-contract status. */
|
||||
export const CONTRACT_STATUS_CONFIG: Record<
|
||||
string,
|
||||
|
||||
@@ -717,6 +717,10 @@ export interface CreateBookingOrderLineDto {
|
||||
/** Null for bulk/break-bulk; the container type id for container contracts. */
|
||||
containerTypeId?: string | null;
|
||||
quantity: number;
|
||||
/** How much of this line is hazardous (≤ quantity). Defaults to 0. */
|
||||
hazardousQuantity?: number;
|
||||
/** How much of this line is refrigerated (≤ quantity). Defaults to 0. */
|
||||
reeferQuantity?: number;
|
||||
}
|
||||
|
||||
/** Per-route contracted / ordered / remaining pool line (multi-route contracts). */
|
||||
@@ -731,6 +735,8 @@ export interface ContractRouteLine {
|
||||
contractedQuantity: number;
|
||||
orderedQuantity: number;
|
||||
remainingQuantity: number;
|
||||
/** Road distance (km) for this route; used to bill road orders. Null for rail-only. */
|
||||
km?: number | null;
|
||||
}
|
||||
|
||||
export interface CreateBookingOrderDto {
|
||||
@@ -748,6 +754,8 @@ export interface IBookingOrderLine {
|
||||
containerTypeId?: string | null;
|
||||
containerTypeName?: string | null;
|
||||
quantity: number;
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
}
|
||||
|
||||
export interface IBookingOrder {
|
||||
|
||||
Reference in New Issue
Block a user