mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #141 from Tria-plc/freight_feature/schedule
auto allocation and batch managemnt, tracking the train
This commit is contained in:
@@ -280,7 +280,6 @@ Authentication is provided by an external `@edr/iamui-common` / `@tria-plc/iamap
|
||||
pnpm install
|
||||
```
|
||||
|
||||
<<<<<<< HEAD
|
||||
### 3. Environment Configuration
|
||||
```bash
|
||||
# Copy environment template
|
||||
@@ -949,7 +948,6 @@ For technical support or questions:
|
||||
---
|
||||
|
||||
**Built with ❤️ for Ethio-Djibouti Railway**
|
||||
=======
|
||||
### Start local databases
|
||||
|
||||
```bash
|
||||
@@ -1022,4 +1020,3 @@ pnpm dev:passenger # passenger API + portal + backoffice
|
||||
- **One DB per domain** — no cross-database joins.
|
||||
|
||||
See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions.
|
||||
>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"test": "jest",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"type-check": "tsc --noEmit",
|
||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts"
|
||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
@@ -28,6 +29,7 @@
|
||||
"@nestjs/mapped-types": "^2.1.1",
|
||||
"@nestjs/microservices": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@tria-plc/api-common": "^1.4.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module, OnApplicationBootstrap } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { DataSource, DataSourceOptions } from "typeorm";
|
||||
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
|
||||
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
|
||||
@@ -58,6 +59,7 @@ import { OverviewModule } from './modules/overview/overview.module';
|
||||
isGlobal: true,
|
||||
load: [appConfig, databaseConfig, telebirrConfig],
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
// EventEmitterModule.forRoot(),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { deriveTradeDirection } from './derive-trade-direction.util';
|
||||
|
||||
describe('deriveTradeDirection', () => {
|
||||
it('returns IMPORT when origin is Djibouti', () => {
|
||||
expect(deriveTradeDirection({ country: 'Djibouti' }, { country: 'Ethiopia' })).toBe(
|
||||
'IMPORT',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns EXPORT when destination is Djibouti and origin is not', () => {
|
||||
expect(deriveTradeDirection({ country: 'Ethiopia' }, { country: 'Djibouti' })).toBe(
|
||||
'EXPORT',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns DOMESTIC for intra-Ethiopia routes', () => {
|
||||
expect(deriveTradeDirection({ country: 'Ethiopia' }, { country: 'Ethiopia' })).toBe(
|
||||
'DOMESTIC',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ScheduleTradeDirection } from '@edr/types';
|
||||
|
||||
type YardLike = { country?: string | null };
|
||||
|
||||
/** Derive booking/schedule trade direction from origin and destination yard countries. */
|
||||
export function deriveTradeDirection(
|
||||
originYard: YardLike,
|
||||
destinationYard: YardLike,
|
||||
): ScheduleTradeDirection {
|
||||
const originCountry = originYard.country?.trim();
|
||||
const destinationCountry = destinationYard.country?.trim();
|
||||
|
||||
if (originCountry === 'Djibouti') {
|
||||
return 'IMPORT';
|
||||
}
|
||||
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
|
||||
return 'EXPORT';
|
||||
}
|
||||
return 'DOMESTIC';
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddLocomotiveReadiness1781000000000 implements MigrationInterface {
|
||||
name = 'AddLocomotiveReadiness1781000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives
|
||||
ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_locomotives_readiness
|
||||
ON freight.locomotives (readiness)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_locomotives_readiness`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives
|
||||
DROP COLUMN IF EXISTS readiness
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateTrainCheckpointEvents1781000000001 implements MigrationInterface {
|
||||
name = 'CreateTrainCheckpointEvents1781000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_checkpoint_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
train_schedule_id UUID NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
|
||||
yard_id UUID NOT NULL,
|
||||
sequence_no INT NOT NULL,
|
||||
kind VARCHAR(20) NOT NULL,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
note TEXT NULL,
|
||||
recorded_by_user_id UUID NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_checkpoint_events_schedule
|
||||
ON freight.train_checkpoint_events (train_schedule_id, sequence_no)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_train_checkpoint_events_schedule`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_checkpoint_events`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBatchBookingFields1781000000002 implements MigrationInterface {
|
||||
name = 'AddBatchBookingFields1781000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Booking → target schedule (pool membership) + 1h pay-window deadline.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS train_schedule_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS payment_deadline TIMESTAMPTZ NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_train_schedule_id
|
||||
ON freight.bookings (train_schedule_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
// TrainSchedule → booking-window status (OPEN/FULL/CLOSED).
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS booking_window_status VARCHAR(10) NOT NULL DEFAULT 'OPEN'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_schedules_booking_window_status
|
||||
ON freight.train_schedules (booking_window_status)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_train_schedules_booking_window_status`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS booking_window_status`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_train_schedule_id`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS train_schedule_id,
|
||||
DROP COLUMN IF EXISTS payment_deadline
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddSelectedForBatchStatus1781000000003 implements MigrationInterface {
|
||||
name = 'AddSelectedForBatchStatus1781000000003';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS selected_for_batch_at TIMESTAMPTZ NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET
|
||||
status = 'SELECTED_FOR_BATCH',
|
||||
selected_for_batch_at = COALESCE(
|
||||
payment_deadline - INTERVAL '5 minutes',
|
||||
updated_at
|
||||
)
|
||||
WHERE status = 'AWAITING_PAYMENT'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET status = 'AWAITING_PAYMENT'
|
||||
WHERE status = 'SELECTED_FOR_BATCH'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS selected_for_batch_at
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Allow DOMESTIC trade direction on weight_limit_rules (domestic corridor bookings).
|
||||
*/
|
||||
export class AddDomesticWeightLimitTradeDirection1781000000004
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddDomesticWeightLimitTradeDirection1781000000004';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TYPE freight.weight_limit_rules_trade_direction_enum ADD VALUE 'DOMESTIC';
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
WHEN undefined_object THEN
|
||||
BEGIN
|
||||
ALTER TYPE weight_limit_rules_trade_direction_enum ADD VALUE 'DOMESTIC';
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// PostgreSQL does not support removing enum values safely.
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Readable } from 'stream';
|
||||
@@ -19,9 +22,12 @@ import { assertBookingStatus } from './booking-status.util';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
|
||||
@Injectable()
|
||||
export class BookingContractService {
|
||||
private readonly logger = new Logger(BookingContractService.name);
|
||||
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
@@ -30,6 +36,8 @@ export class BookingContractService {
|
||||
private readonly viewModelBuilder: ContractViewModelBuilder,
|
||||
private readonly renderer: ContractRendererService,
|
||||
private readonly pdfService: ContractPdfService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
) {}
|
||||
|
||||
buildContractSummary(booking: Booking): string {
|
||||
@@ -92,7 +100,16 @@ export class BookingContractService {
|
||||
|
||||
const templateKey = this.templateResolver.resolve(booking);
|
||||
const summary = this.buildContractSummary(booking);
|
||||
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
|
||||
|
||||
// PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract
|
||||
// from becoming ready — the document is (re)rendered lazily on view/download.
|
||||
try {
|
||||
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download once Chromium is available.`,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
@@ -191,11 +208,20 @@ export class BookingContractService {
|
||||
}
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, updates as never);
|
||||
await this.upsertContractPdf(
|
||||
bookingId,
|
||||
booking.reference,
|
||||
booking.contractTemplateKey ?? this.templateResolver.resolve(booking),
|
||||
);
|
||||
if (role === 'STAFF' && updated?.trainScheduleId) {
|
||||
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
|
||||
}
|
||||
try {
|
||||
await this.upsertContractPdf(
|
||||
bookingId,
|
||||
booking.reference,
|
||||
booking.contractTemplateKey ?? this.templateResolver.resolve(booking),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Signed-contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`,
|
||||
);
|
||||
}
|
||||
return updated!;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export class BookingPaymentService {
|
||||
|
||||
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED', '']);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
|
||||
|
||||
const existing = await this.paymentService.findBookingById(bookingId);
|
||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import type { Booking } from './entities/booking.entity';
|
||||
import type { Rate } from '../rule-engine/entities/rate.entity';
|
||||
|
||||
describe('BookingPricingService — domestic corridor', () => {
|
||||
const intercityBulkEtb: Rate = {
|
||||
id: 'rate-intercity-bulk-etb',
|
||||
rateType: 'INTERCITY_BULK',
|
||||
currency: 'ETB',
|
||||
rateValue: 1900,
|
||||
rateUnit: 'PER_TON',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
} as Rate;
|
||||
|
||||
const intercityContainerEtb: Rate = {
|
||||
id: 'rate-intercity-container-etb',
|
||||
rateType: 'INTERCITY_CONTAINER',
|
||||
currency: 'ETB',
|
||||
rateValue: 25000,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
} as Rate;
|
||||
|
||||
let service: BookingPricingService;
|
||||
let bookingsRepository: { calculateWagonCount: jest.Mock };
|
||||
let ratesService: { findLiveRates: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
|
||||
ratesService = {
|
||||
findLiveRates: jest.fn().mockResolvedValue([intercityBulkEtb, intercityContainerEtb]),
|
||||
};
|
||||
|
||||
service = new BookingPricingService(
|
||||
bookingsRepository as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
ratesService as never,
|
||||
{} as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('prices domestic bulk using INTERCITY_BULK and cargo tons', async () => {
|
||||
const booking = {
|
||||
id: 'b-1',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'ETB',
|
||||
cargoTotalWeightVgm: 120,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: { containers: [] },
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||
|
||||
expect(result.lineItems).toHaveLength(1);
|
||||
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
|
||||
expect(result.lineItems[0].amount).toBe(1900 * 120);
|
||||
});
|
||||
|
||||
it('prices domestic container using INTERCITY_CONTAINER fallback', async () => {
|
||||
const booking = {
|
||||
id: 'b-2',
|
||||
freightType: 'CONTAINER',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'ETB',
|
||||
cargoTotalWeightVgm: 50,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: {
|
||||
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||||
},
|
||||
) => Promise<{ lineItems: Array<{ amount: number; code: string }> }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, {
|
||||
containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
|
||||
});
|
||||
|
||||
expect(result.lineItems.some((l) => l.code === 'INTERCITY_CONTAINER')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -275,7 +275,9 @@ export class BookingPricingService {
|
||||
? isBulk
|
||||
? 'BULK_EXPORT'
|
||||
: 'CONTAINER_EXPORT'
|
||||
: 'INTERCITY_CONTAINER';
|
||||
: isBulk
|
||||
? 'INTERCITY_BULK'
|
||||
: 'INTERCITY_CONTAINER';
|
||||
|
||||
const lines: PriceLineItemDto[] = [];
|
||||
const usedRatesMap = new Map<string, Rate>();
|
||||
@@ -301,7 +303,10 @@ export class BookingPricingService {
|
||||
);
|
||||
if (fallback) {
|
||||
usedRatesMap.set(fallback.id, fallback);
|
||||
const amount = this.amountForRate(fallback, 1, wagonCount);
|
||||
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const quantity =
|
||||
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
|
||||
const amount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `Base rail (${rateType})`,
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from '../../common/resolve-auth-user-id';
|
||||
import { assertFreightPermission } from '../../common/freight-permission.util';
|
||||
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
||||
|
||||
@ApiTags('bookings')
|
||||
@Controller('bookings')
|
||||
@@ -73,7 +73,7 @@ export class BookingsController {
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
|
||||
@ApiBody({ type: CreateBookingDto })
|
||||
create(
|
||||
async create(
|
||||
@Body() dto: CreateBookingDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@@ -81,7 +81,22 @@ export class BookingsController {
|
||||
if (dto.isGovernment) {
|
||||
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||||
}
|
||||
return this.bookingsService.create(dto, files ?? [], user?.id);
|
||||
const result = await this.bookingsService.create(dto, files ?? [], user?.id);
|
||||
|
||||
// Staff-created commercial bookings skip the draft stage: auto generate-price + submit.
|
||||
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||||
if (isStaff && !dto.isGovernment) {
|
||||
try {
|
||||
await this.pricingService.generatePrice(result.booking.id);
|
||||
await this.transitionService.submit(result.booking.id);
|
||||
const submitted = await this.bookingsService.findById(result.booking.id);
|
||||
return { booking: submitted, warnings: result.warnings };
|
||||
} catch {
|
||||
// If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually.
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
// import { CustomersModule } from '../customers/customers.module';
|
||||
@@ -29,6 +29,7 @@ import { ContractRendererService } from '../../contracts/contract-renderer.servi
|
||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||
import { PaymentModule } from '../payment/payment.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -42,6 +43,7 @@ import { PaymentModule } from '../payment/payment.module';
|
||||
BookingContractSignature,
|
||||
]),
|
||||
PaymentModule,
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
CompaniesModule,
|
||||
|
||||
@@ -659,6 +659,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
originStationId?: string;
|
||||
destinationStationId?: string;
|
||||
schedulingStatus?: string;
|
||||
trainScheduleId?: string;
|
||||
}): Promise<Booking[]> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('booking')
|
||||
@@ -676,6 +677,14 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
|
||||
.andWhere('scheduleBooking.id IS NULL');
|
||||
|
||||
// Mirror the automatic batch pool: a schedule only ever considers bookings that
|
||||
// targeted THAT schedule (same as findBatchPool's train_schedule_id filter).
|
||||
if (options.trainScheduleId) {
|
||||
qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
|
||||
trainScheduleId: options.trainScheduleId,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.freightType) {
|
||||
qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType });
|
||||
}
|
||||
@@ -703,6 +712,90 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ready, not-yet-allocated bookings targeting a schedule (the batch pool).
|
||||
* Commercial = FULLY_EXECUTED; government = APPROVED or PAID (skips contract).
|
||||
* Ordered government → priority → contract-sign time.
|
||||
*/
|
||||
findBatchPool(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere('sb.id IS NULL')
|
||||
.andWhere(
|
||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
||||
)
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
|
||||
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
|
||||
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** PAID bookings targeting a schedule that have no train_schedule_bookings link yet. */
|
||||
findPaidUnlinkedForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoin(
|
||||
TrainScheduleBooking,
|
||||
'scheduleBooking',
|
||||
'scheduleBooking.booking_id = booking.id',
|
||||
)
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere(`booking.status = 'PAID'`)
|
||||
.andWhere('scheduleBooking.id IS NULL')
|
||||
.orderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Commercial bookings already allocated to a schedule, lowest-priority first (for government preempt). */
|
||||
findAllocatedCommercialForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.innerJoin(
|
||||
TrainScheduleBooking,
|
||||
'sb',
|
||||
'sb.booking_id = booking.id AND sb.train_schedule_id = :scheduleId',
|
||||
{ scheduleId },
|
||||
)
|
||||
.where('booking.is_government = false')
|
||||
.orderBy('booking.priority_score', 'ASC')
|
||||
.addOrderBy('booking.created_at', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
findByIdsForScheduling(bookingIds: string[], manager?: EntityManager): Promise<Booking[]> {
|
||||
if (!bookingIds.length) return Promise.resolve([]);
|
||||
return this.bookingRepo(manager).find({
|
||||
|
||||
@@ -14,6 +14,12 @@ import {
|
||||
BookingEvaluationInput,
|
||||
RuleEngineService,
|
||||
} from '../rule-engine/rule-engine.service';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { assertFreightShape } from './booking-freight.util';
|
||||
@@ -40,6 +46,7 @@ const NEEDS_ACTION_STATUSES = [
|
||||
@Injectable()
|
||||
export class BookingsService {
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
@@ -50,6 +57,36 @@ export class BookingsService {
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
) {}
|
||||
|
||||
/** Resolve trade direction from yard countries; reject client mismatch. */
|
||||
private async resolveTradeDirectionForBooking(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
provided?: string,
|
||||
): Promise<string> {
|
||||
const yards = await this.dataSource.getRepository(Yard).find({
|
||||
where: { id: In([originYardId, destinationYardId]) },
|
||||
});
|
||||
const origin = yards.find((y) => y.id === originYardId);
|
||||
const destination = yards.find((y) => y.id === destinationYardId);
|
||||
if (!origin) {
|
||||
throw new BadRequestException(`Origin yard ${originYardId} not found`);
|
||||
}
|
||||
if (!destination) {
|
||||
throw new BadRequestException(`Destination yard ${destinationYardId} not found`);
|
||||
}
|
||||
if (originYardId === destinationYardId) {
|
||||
throw new BadRequestException('Origin and destination yards must differ');
|
||||
}
|
||||
|
||||
const expected = deriveTradeDirection(origin, destination);
|
||||
if (provided && provided !== expected) {
|
||||
throw new BadRequestException(
|
||||
`tradeDirection must be ${expected} for the selected yard pair (got ${provided})`,
|
||||
);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
/** Generate a unique booking reference number. */
|
||||
private async generateReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
@@ -199,6 +236,25 @@ export class BookingsService {
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
// Schedule targeting: when provided, the schedule must be OPEN and on the same route.
|
||||
if (dto.trainScheduleId) {
|
||||
const schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: dto.trainScheduleId } });
|
||||
if (!schedule) {
|
||||
throw new BadRequestException(`Train schedule ${dto.trainScheduleId} not found`);
|
||||
}
|
||||
if (schedule.bookingWindowStatus !== 'OPEN') {
|
||||
throw new BadRequestException('Selected schedule is no longer accepting bookings');
|
||||
}
|
||||
if (
|
||||
schedule.originStationId !== dto.originYardId ||
|
||||
schedule.destinationStationId !== dto.destinationYardId
|
||||
) {
|
||||
throw new BadRequestException('Selected schedule is not on the booking route');
|
||||
}
|
||||
}
|
||||
|
||||
const reference = dto.reference || (await this.generateReference());
|
||||
const containers = dto.containers ?? [];
|
||||
assertFreightShape({
|
||||
@@ -207,6 +263,12 @@ export class BookingsService {
|
||||
containers,
|
||||
});
|
||||
|
||||
const tradeDirection = await this.resolveTradeDirectionForBooking(
|
||||
dto.originYardId,
|
||||
dto.destinationYardId,
|
||||
dto.tradeDirection,
|
||||
);
|
||||
|
||||
const allowConsolidation =
|
||||
dto.freightType === 'CONTAINER'
|
||||
? await this.resolveConsolidation(containers, dto.allowConsolidation)
|
||||
@@ -217,7 +279,7 @@ export class BookingsService {
|
||||
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
tradeDirection,
|
||||
isHazardous: dto.isHazardous,
|
||||
isGovernment,
|
||||
allowConsolidation,
|
||||
@@ -235,6 +297,7 @@ export class BookingsService {
|
||||
isGovernment,
|
||||
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
||||
trainId: dto.trainId,
|
||||
trainScheduleId: dto.trainScheduleId ?? null,
|
||||
contractType: dto.contractType,
|
||||
previousContractId: dto.previousContractId,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
@@ -243,7 +306,7 @@ export class BookingsService {
|
||||
equipmentReturn: dto.equipmentReturn,
|
||||
originYardId: dto.originYardId,
|
||||
destinationYardId: dto.destinationYardId,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
tradeDirection,
|
||||
freightType: dto.freightType,
|
||||
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
|
||||
cargoFreeText: dto.cargoFreeText,
|
||||
@@ -338,6 +401,14 @@ export class BookingsService {
|
||||
|
||||
assertFreightShape({ freightType, cargoTypeId, containers });
|
||||
|
||||
const originYardId = dto.originYardId ?? existing.originYardId;
|
||||
const destinationYardId = dto.destinationYardId ?? existing.destinationYardId;
|
||||
const tradeDirection = await this.resolveTradeDirectionForBooking(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
dto.tradeDirection,
|
||||
);
|
||||
|
||||
const allowConsolidation =
|
||||
freightType === 'CONTAINER'
|
||||
? await this.resolveConsolidation(
|
||||
@@ -351,7 +422,7 @@ export class BookingsService {
|
||||
cargoTypeId,
|
||||
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
||||
tradeDirection,
|
||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||
allowConsolidation,
|
||||
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
||||
@@ -377,6 +448,7 @@ export class BookingsService {
|
||||
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
||||
allowConsolidation,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
tradeDirection,
|
||||
};
|
||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
||||
if (dto.startDate) updates.startDate = new Date(dto.startDate);
|
||||
|
||||
@@ -91,6 +91,12 @@ export class CreateBookingDto {
|
||||
@IsUUID()
|
||||
trainId?: string;
|
||||
|
||||
/** Target schedule this booking is created against (required by the backoffice create form). */
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Target train schedule (pool membership)' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
|
||||
@@ -26,6 +26,8 @@ export const BOOKING_STATUSES = [
|
||||
'CONTRACT_READY',
|
||||
'SIGNED_CUSTOMER',
|
||||
'FULLY_EXECUTED',
|
||||
'SELECTED_FOR_BATCH',
|
||||
'EXPIRED',
|
||||
'PNR_GENERATED',
|
||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
'PAID',
|
||||
@@ -274,6 +276,18 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
|
||||
scheduledAt?: Date | null;
|
||||
|
||||
/** The schedule this booking targets (pool membership), set at creation. FK to train_schedules. */
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||
trainScheduleId?: string | null;
|
||||
|
||||
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
|
||||
@Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true })
|
||||
paymentDeadline?: Date | null;
|
||||
|
||||
/** When the batch engine picked this booking and opened the pay window. */
|
||||
@Column({ name: 'selected_for_batch_at', type: 'timestamptz', nullable: true })
|
||||
selectedForBatchAt?: Date | null;
|
||||
|
||||
@OneToMany(() => BookingContainer, (bc) => bc.booking)
|
||||
bookingContainers?: BookingContainer[];
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
|
||||
import {
|
||||
LOCOMOTIVE_READINESS_VALUES,
|
||||
LOCOMOTIVE_STATUSES,
|
||||
LOCOMOTIVE_TYPES,
|
||||
} from '../entities/locomotive.entity';
|
||||
|
||||
export class CreateLocomotiveDto {
|
||||
@ApiProperty({ example: 'LOCO-001' })
|
||||
@@ -24,6 +28,11 @@ export class CreateLocomotiveDto {
|
||||
@IsIn([...LOCOMOTIVE_STATUSES])
|
||||
status!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_READINESS_VALUES, default: 'IMPORT_READY' })
|
||||
@IsOptional()
|
||||
@IsIn([...LOCOMOTIVE_READINESS_VALUES])
|
||||
readiness?: string;
|
||||
|
||||
@ApiProperty({ example: 3500 })
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsNumber()
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional } from 'class-validator';
|
||||
|
||||
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
|
||||
import {
|
||||
LOCOMOTIVE_READINESS_VALUES,
|
||||
LOCOMOTIVE_STATUSES,
|
||||
LOCOMOTIVE_TYPES,
|
||||
} from '../entities/locomotive.entity';
|
||||
|
||||
export class FilterLocomotivesDto {
|
||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
|
||||
@@ -13,4 +17,9 @@ export class FilterLocomotivesDto {
|
||||
@IsOptional()
|
||||
@IsIn([...LOCOMOTIVE_TYPES])
|
||||
locomotiveType?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_READINESS_VALUES })
|
||||
@IsOptional()
|
||||
@IsIn([...LOCOMOTIVE_READINESS_VALUES])
|
||||
readiness?: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { WagonReadiness } from '@edr/types';
|
||||
import { Column, Entity, Index, OneToMany } from 'typeorm';
|
||||
|
||||
import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
||||
@@ -12,12 +13,20 @@ export const LOCOMOTIVE_STATUSES = [
|
||||
|
||||
export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const;
|
||||
|
||||
/** Locomotives reuse the wagon readiness values (IMPORT_READY / EXPORT_READY). */
|
||||
export const LOCOMOTIVE_READINESS_VALUES = [
|
||||
WagonReadiness.ImportReady,
|
||||
WagonReadiness.ExportReady,
|
||||
] as const;
|
||||
|
||||
export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
|
||||
export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number];
|
||||
export type LocomotiveReadiness = (typeof LOCOMOTIVE_READINESS_VALUES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'locomotives' })
|
||||
@Index(['code'])
|
||||
@Index(['status'])
|
||||
@Index(['readiness'])
|
||||
export class Locomotive extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
|
||||
code!: string;
|
||||
@@ -37,6 +46,9 @@ export class Locomotive extends BaseEntity {
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
|
||||
status!: LocomotiveStatus;
|
||||
|
||||
@Column({ name: 'readiness', type: 'varchar', length: 20, default: WagonReadiness.ImportReady })
|
||||
readiness!: LocomotiveReadiness;
|
||||
|
||||
@Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
powerKw?: number | null;
|
||||
|
||||
|
||||
@@ -3,7 +3,14 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
|
||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||
import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
|
||||
import { WagonReadiness } from '@edr/types';
|
||||
|
||||
import {
|
||||
Locomotive,
|
||||
type LocomotiveReadiness,
|
||||
type LocomotiveStatus,
|
||||
type LocomotiveType,
|
||||
} from './entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from './locomotives.repository';
|
||||
|
||||
@Injectable()
|
||||
@@ -17,6 +24,7 @@ export class LocomotivesService {
|
||||
...(filter.locomotiveType
|
||||
? { locomotiveType: filter.locomotiveType as LocomotiveType }
|
||||
: {}),
|
||||
...(filter.readiness ? { readiness: filter.readiness as LocomotiveReadiness } : {}),
|
||||
},
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
@@ -34,6 +42,7 @@ export class LocomotivesService {
|
||||
name: dto.name?.trim() || null,
|
||||
locomotiveType: dto.locomotiveType as LocomotiveType,
|
||||
status: dto.status as LocomotiveStatus,
|
||||
readiness: (dto.readiness as LocomotiveReadiness) ?? WagonReadiness.ImportReady,
|
||||
maxPullWeightTons: dto.maxPullWeightTons,
|
||||
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||||
powerKw: dto.powerKw ?? null,
|
||||
@@ -67,6 +76,10 @@ export class LocomotivesService {
|
||||
locomotiveType:
|
||||
dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType,
|
||||
status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus,
|
||||
readiness:
|
||||
dto.readiness === undefined
|
||||
? locomotive.readiness
|
||||
: (dto.readiness as LocomotiveReadiness),
|
||||
name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
|
||||
powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null,
|
||||
tractionForceKn:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { Module, forwardRef } from "@nestjs/common";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { PaymentController } from "./payment.controller";
|
||||
@@ -7,11 +7,12 @@ import { PaymentRepository } from "./payment.repository";
|
||||
import { WebhookController } from "./webhooks/webhook.controller";
|
||||
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
|
||||
import { TelebirrProvider } from "@edr/payment-providers";
|
||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule, ConfigModule],
|
||||
imports: [HttpModule, ConfigModule, forwardRef(() => TrainSchedulingModule)],
|
||||
providers: [PaymentRepository, PaymentService, TelebirrWebhookService, TelebirrProvider],
|
||||
controllers: [PaymentController, WebhookController],
|
||||
exports: [PaymentService]
|
||||
})
|
||||
export class PaymentModule { }
|
||||
export class PaymentModule { }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
@@ -12,7 +14,7 @@ import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as Handlebars from "handlebars";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { SchedulingStatus } from "@edr/types";
|
||||
// import { SchedulingStatus } from "@edr/types";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
|
||||
import {
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
} from "@edr/payment-providers";
|
||||
import { ProviderInitiationInput } from "@edr/types"
|
||||
import { InitiateResponseDto, PaymentPlatformDto } from "./payments.dto";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
|
||||
const DEFAULT_CURRENCY = "ETB";
|
||||
|
||||
@@ -33,6 +36,8 @@ export class PaymentService {
|
||||
private readonly datasource: DataSource,
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
private readonly telebirrProvider: TelebirrProvider,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
) { }
|
||||
|
||||
async initBookingTelebirr(
|
||||
@@ -62,107 +67,6 @@ export class PaymentService {
|
||||
|
||||
const result = await this.telebirrProvider.initiate(input);
|
||||
|
||||
<<<<<<< HEAD
|
||||
const queryRunner = this.datasource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
console.log(paymentResp.expiresAt);
|
||||
try {
|
||||
const resp = await cb(queryRunner);
|
||||
const payment = await this.paymentRepo.createTr(queryRunner, {
|
||||
amount,
|
||||
currency,
|
||||
method,
|
||||
refId: resp.id,
|
||||
type: resp.type,
|
||||
merchantOrderId: orderId,
|
||||
rawInitiation: paymentResp.rawInitiation,
|
||||
clientAction: paymentResp.clientAction,
|
||||
expiresAt: paymentResp.expiresAt,
|
||||
reason,
|
||||
});
|
||||
await queryRunner.commitTransaction();
|
||||
return {
|
||||
refId: payment.refId,
|
||||
clientAction: paymentResp.clientAction,
|
||||
status: payment.status,
|
||||
paidAt: payment.paidAt?.toISOString(),
|
||||
failureCode: payment.failerCode ?? undefined,
|
||||
failureMessage: payment.failureMessage ?? undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw new Error("payment failed");
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
|
||||
async getActivePaymentByRefIdAndMethod(
|
||||
refId: string,
|
||||
method: PaymentEntity["method"],
|
||||
): Promise<PaymentEntity | null> {
|
||||
return this.paymentRepo.getActivePaymentByRefIdAndMethod(refId, method);
|
||||
}
|
||||
|
||||
async genReceiptHtml(orderId: string) {
|
||||
const payment = await this.paymentRepo.findOneBy({
|
||||
merchantOrderId: orderId,
|
||||
status: "success",
|
||||
});
|
||||
if (!payment) {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
const filePath = path.join(__dirname, "templates", "receipt.hbs");
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
const source = fs.readFileSync(filePath, "utf8");
|
||||
const template = Handlebars.compile(source);
|
||||
|
||||
const html = template({
|
||||
vendorName: "Ethio Djibouti Railway Ticket Booking",
|
||||
vendorAddress: "Addis Ababa",
|
||||
receiptDate: payment.paidAt,
|
||||
paymentMethod: payment?.method,
|
||||
subtotal: payment?.amount.toString(),
|
||||
total: payment?.amount.toString(),
|
||||
currency: payment?.currency,
|
||||
reason: payment?.reason,
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
async checkStatusAndUpdate(orderId: string) {
|
||||
const resp = await this.paymentRepo.findOneBy({ merchantOrderId: orderId });
|
||||
if (!resp) {
|
||||
throw new NotFoundException("order id not found");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.telebirrPaymentStategy.queryStatus(
|
||||
resp.merchantOrderId,
|
||||
);
|
||||
const bizContent = result.rawResponse.biz_content as {
|
||||
order_status: string;
|
||||
};
|
||||
|
||||
const ordersStatus = bizContent.order_status;
|
||||
if (ordersStatus == "PAY_SUCCESS") {
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
const now = new Date();
|
||||
const holdExpires = new Date(now.getTime() + 3 * 60 * 60 * 1000);
|
||||
await mg.update(Booking, { id: resp.refId }, {
|
||||
status: "PAID",
|
||||
schedulingStatus: SchedulingStatus.Holding,
|
||||
holdStartedAt: now,
|
||||
holdExpiresAt: holdExpires,
|
||||
});
|
||||
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" });
|
||||
=======
|
||||
const payment = await this.paymentRepo.create({
|
||||
amount: amount,
|
||||
currency: DEFAULT_CURRENCY,
|
||||
@@ -174,7 +78,6 @@ export class PaymentService {
|
||||
clientAction: result.clientAction as Record<string, unknown>,
|
||||
expiresAt: result.expiresAt,
|
||||
reason: `Payment for booking`,
|
||||
>>>>>>> eda21e22d872344b74c0c72308f87ce7435b299f
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -227,9 +130,12 @@ export class PaymentService {
|
||||
|
||||
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(Booking, { id: resp.refId }, { status: "PAID" })
|
||||
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" })
|
||||
await mg.update(Booking, { id: resp.refId }, { paymentStatus: "PAID" })
|
||||
})
|
||||
if (resp.type === "booking") {
|
||||
await this.bookingBatchService.ensurePaidBookingAllocated(resp.refId)
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: result.status
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { TelebirrDto } from '../dto/telebirr.dto';
|
||||
import { PaymentRepository } from '../../payment.repository';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Booking } from '../../../bookings/entities/booking.entity';
|
||||
import { TelebirrProvider, ProviderPaymentStatus } from '@edr/payment-providers';
|
||||
import { BookingBatchService } from '../../../train-scheduling/booking-batch.service';
|
||||
|
||||
@Injectable()
|
||||
export class TelebirrWebhookService {
|
||||
@@ -13,6 +14,8 @@ export class TelebirrWebhookService {
|
||||
private readonly datasource: DataSource,
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
private readonly telebirrProvider: TelebirrProvider,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
) { }
|
||||
|
||||
verifyTelebirrNotification(payload: TelebirrDto) {
|
||||
@@ -40,6 +43,7 @@ export class TelebirrWebhookService {
|
||||
{ id: payment.refId },
|
||||
{ paymentStatus: "PAID" },
|
||||
);
|
||||
await this.bookingBatchService.ensurePaidBookingAllocated(payment.refId);
|
||||
}
|
||||
break;
|
||||
case ProviderPaymentStatus.FAILED:
|
||||
@@ -50,4 +54,4 @@ export class TelebirrWebhookService {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
|
||||
|
||||
export class CreateWeightLimitRuleDto {
|
||||
@ApiProperty({ description: 'FK to container_types.id' })
|
||||
@IsUUID()
|
||||
containerTypeId!: string;
|
||||
|
||||
@ApiProperty({ enum: TRADE_DIRECTIONS, description: 'Trade direction: IMPORT, EXPORT, or BOTH' })
|
||||
@ApiProperty({
|
||||
enum: TRADE_DIRECTIONS,
|
||||
description: 'Trade direction: IMPORT, EXPORT, BOTH, or DOMESTIC',
|
||||
})
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
tradeDirection!: string;
|
||||
|
||||
|
||||
@@ -79,6 +79,10 @@ export class TrainSchedule extends BaseEntity {
|
||||
@Column({ name: 'max_wagons', type: 'int', default: 53 })
|
||||
maxWagons!: number;
|
||||
|
||||
/** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */
|
||||
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
|
||||
bookingWindowStatus!: string;
|
||||
|
||||
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
|
||||
scheduleBookings?: TrainScheduleBooking[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
getBatchWindowForTimestamp,
|
||||
listBatchWindowsForDate,
|
||||
listBatchWindowsForBookings,
|
||||
BATCH_WINDOW_START_HOURS,
|
||||
} from './batch-window.util';
|
||||
|
||||
describe('batch-window.util', () => {
|
||||
it('maps 20:15 EAT to the 19:00–22:00 window', () => {
|
||||
// 20:15 EAT = 17:15 UTC on 11 Jun 2026
|
||||
const ts = new Date('2026-06-11T17:15:00.000Z');
|
||||
const window = getBatchWindowForTimestamp(ts);
|
||||
|
||||
expect(window.label).toContain('19:00');
|
||||
expect(window.label).toContain('22:00');
|
||||
expect(window.label).toContain('11 Jun 2026');
|
||||
});
|
||||
|
||||
it('maps 08:30 EAT to the 07:00–10:00 window', () => {
|
||||
const ts = new Date('2026-06-11T05:30:00.000Z'); // 08:30 EAT
|
||||
const window = getBatchWindowForTimestamp(ts);
|
||||
expect(window.label).toContain('07:00');
|
||||
expect(window.label).toContain('10:00');
|
||||
});
|
||||
|
||||
it('maps 02:00 EAT to the previous day 22:00–07:00 window', () => {
|
||||
const ts = new Date('2026-06-11T23:00:00.000Z'); // 02:00 EAT on 12 Jun
|
||||
const window = getBatchWindowForTimestamp(ts);
|
||||
expect(window.label).toContain('22:00');
|
||||
expect(window.label).toContain('07:00');
|
||||
expect(window.label).toContain('11 Jun 2026');
|
||||
});
|
||||
|
||||
it('lists six windows for a calendar day', () => {
|
||||
const ref = new Date('2026-06-11T12:00:00.000Z');
|
||||
const windows = listBatchWindowsForDate(ref);
|
||||
expect(windows).toHaveLength(BATCH_WINDOW_START_HOURS.length);
|
||||
expect(windows[0].label).toContain('07:00');
|
||||
expect(windows[windows.length - 1].label).toContain('22:00');
|
||||
});
|
||||
|
||||
it('includes cross-day overnight window when booking signed at 00:02 EAT', () => {
|
||||
// 21:02 UTC = 00:02 EAT on 12 Jun → belongs to 11 Jun 22:00–07:00 window
|
||||
const fullyExecutedAt = new Date('2026-06-11T21:02:05.153Z');
|
||||
const scheduleDate = new Date('2026-06-12T06:00:00.000Z');
|
||||
const windows = listBatchWindowsForBookings([fullyExecutedAt], scheduleDate);
|
||||
const overnight = windows.find((w) => w.label.includes('22:00') && w.label.includes('07:00'));
|
||||
expect(overnight).toBeDefined();
|
||||
expect(overnight!.label).toContain('11 Jun 2026');
|
||||
expect(getBatchWindowForTimestamp(fullyExecutedAt).key).toBe(overnight!.key);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
|
||||
/** EAT intake boundaries — cron runs at these hours; each window spans to the next. */
|
||||
export const BATCH_WINDOW_START_HOURS = [7, 10, 13, 16, 19, 22] as const;
|
||||
|
||||
export interface BatchWindow {
|
||||
key: string;
|
||||
label: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
type EatDateParts = {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
};
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
});
|
||||
|
||||
const timeFmt = new Intl.DateTimeFormat('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
});
|
||||
|
||||
function eatParts(date: Date): EatDateParts {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
|
||||
const get = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
Number(parts.find((p) => p.type === type)?.value ?? 0);
|
||||
|
||||
return {
|
||||
year: get('year'),
|
||||
month: get('month'),
|
||||
day: get('day'),
|
||||
hour: get('hour'),
|
||||
minute: get('minute'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
|
||||
function eatToUtc(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hour: number,
|
||||
minute = 0,
|
||||
): Date {
|
||||
// EAT is UTC+3 year-round (no DST). Binary search would be safer across DST zones;
|
||||
// for Africa/Addis_Ababa the offset is fixed.
|
||||
const utcMs = Date.UTC(year, month - 1, day, hour - 3, minute, 0, 0);
|
||||
return new Date(utcMs);
|
||||
}
|
||||
|
||||
function formatWindowLabel(start: Date, end: Date, endHourLabel?: string): string {
|
||||
const endTime = endHourLabel ?? timeFmt.format(new Date(end.getTime() - 60_000));
|
||||
return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${endTime} EAT`;
|
||||
}
|
||||
|
||||
function windowFromEatStart(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
startHour: number,
|
||||
): BatchWindow {
|
||||
const start = eatToUtc(year, month, day, startHour);
|
||||
let endYear = year;
|
||||
let endMonth = month;
|
||||
let endDay = day;
|
||||
let endHour: number;
|
||||
let endHourLabel: string;
|
||||
|
||||
const idx = BATCH_WINDOW_START_HOURS.indexOf(startHour as (typeof BATCH_WINDOW_START_HOURS)[number]);
|
||||
if (idx === BATCH_WINDOW_START_HOURS.length - 1) {
|
||||
endHour = 7;
|
||||
endHourLabel = '07:00';
|
||||
const next = new Date(eatToUtc(year, month, day, 0));
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
const nextParts = eatParts(next);
|
||||
endYear = nextParts.year;
|
||||
endMonth = nextParts.month;
|
||||
endDay = nextParts.day;
|
||||
} else {
|
||||
endHour = BATCH_WINDOW_START_HOURS[idx + 1];
|
||||
endHourLabel = `${String(endHour).padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
const end = eatToUtc(endYear, endMonth, endDay, endHour);
|
||||
return {
|
||||
key: start.toISOString(),
|
||||
start,
|
||||
end,
|
||||
label: formatWindowLabel(start, end, endHourLabel),
|
||||
};
|
||||
}
|
||||
|
||||
/** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */
|
||||
export function getBatchWindowForTimestamp(date: Date): BatchWindow {
|
||||
const { year, month, day, hour } = eatParts(date);
|
||||
|
||||
if (hour < 7) {
|
||||
const prev = new Date(eatToUtc(year, month, day, 0));
|
||||
prev.setUTCDate(prev.getUTCDate() - 1);
|
||||
const prevParts = eatParts(prev);
|
||||
return windowFromEatStart(prevParts.year, prevParts.month, prevParts.day, 22);
|
||||
}
|
||||
|
||||
let startHour: (typeof BATCH_WINDOW_START_HOURS)[number] = 7;
|
||||
for (const h of BATCH_WINDOW_START_HOURS) {
|
||||
if (hour >= h) startHour = h;
|
||||
}
|
||||
|
||||
return windowFromEatStart(year, month, day, startHour);
|
||||
}
|
||||
|
||||
/** All six intake windows for an EAT calendar day (includes overnight 22:00–07:00). */
|
||||
export function listBatchWindowsForDate(reference: Date): BatchWindow[] {
|
||||
const { year, month, day } = eatParts(reference);
|
||||
return BATCH_WINDOW_START_HOURS.map((startHour) =>
|
||||
windowFromEatStart(year, month, day, startHour),
|
||||
);
|
||||
}
|
||||
|
||||
export function compareBatchWindows(a: BatchWindow, b: BatchWindow): number {
|
||||
return a.start.getTime() - b.start.getTime();
|
||||
}
|
||||
|
||||
/** Schedule-day windows plus any extra windows that contain booking timestamps (cross-day). */
|
||||
export function listBatchWindowsForBookings(
|
||||
timestamps: Array<Date | null | undefined>,
|
||||
referenceDate: Date,
|
||||
): BatchWindow[] {
|
||||
const byKey = new Map<string, BatchWindow>();
|
||||
for (const w of listBatchWindowsForDate(referenceDate)) {
|
||||
byKey.set(w.key, w);
|
||||
}
|
||||
for (const ts of timestamps) {
|
||||
if (!ts) continue;
|
||||
const w = getBatchWindowForTimestamp(ts);
|
||||
byKey.set(w.key, w);
|
||||
}
|
||||
return [...byKey.values()].sort(compareBatchWindows);
|
||||
}
|
||||
|
||||
/** Group items by batch window key; items without a timestamp go to `pendingKey`. */
|
||||
export function groupByBatchWindow<T>(
|
||||
items: T[],
|
||||
getTimestamp: (item: T) => Date | null | undefined,
|
||||
referenceDate: Date,
|
||||
pendingKey = 'pending-contract',
|
||||
): Map<string, { window: BatchWindow | null; items: T[] }> {
|
||||
const timestamps = items.map(getTimestamp);
|
||||
const windows = listBatchWindowsForBookings(timestamps, referenceDate);
|
||||
const map = new Map<string, { window: BatchWindow | null; items: T[] }>();
|
||||
|
||||
for (const w of windows) {
|
||||
map.set(w.key, { window: w, items: [] });
|
||||
}
|
||||
map.set(pendingKey, { window: null, items: [] });
|
||||
|
||||
for (const item of items) {
|
||||
const ts = getTimestamp(item);
|
||||
if (!ts) {
|
||||
map.get(pendingKey)!.items.push(item);
|
||||
continue;
|
||||
}
|
||||
const w = getBatchWindowForTimestamp(ts);
|
||||
if (!map.has(w.key)) {
|
||||
map.set(w.key, { window: w, items: [] });
|
||||
}
|
||||
map.get(w.key)!.items.push(item);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Tunables for the demand-batching booking → allocation flow.
|
||||
* Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock.
|
||||
*/
|
||||
|
||||
/** Batch boundaries — every 3h from 07:00 (the 07:00–10:00 intake settles at 10:00, etc.). */
|
||||
// export const BATCH_CRON = '0 7,10,13,16,19,22 * * *';
|
||||
// export const BATCH_CRON = '*/3 * * * *';
|
||||
export const BATCH_CRON = '*/5 * * * *';
|
||||
|
||||
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
|
||||
|
||||
/** How long a selected commercial customer has to pay before their slot expires. */
|
||||
// export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
export const PAYMENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes (test mode)
|
||||
|
||||
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
|
||||
export const DEFAULT_WAGONS_PER_BOOKING = 1;
|
||||
|
||||
/**
|
||||
* Fallback per-wagon length (m) for the batch length budget when global rules don't yet
|
||||
* define maxTrainLength / maxWagons to derive it from. Used only to estimate train length
|
||||
* against the locomotive's max train length.
|
||||
*/
|
||||
export const DEFAULT_WAGON_LENGTH_METERS = 14;
|
||||
|
||||
/** Default NW5 flat wagon length for container bookings (m). */
|
||||
export const DEFAULT_CONTAINER_WAGON_LENGTH_METERS = 14;
|
||||
|
||||
/** Default CW3 covered wagon length for bulk bookings (m). */
|
||||
export const DEFAULT_BULK_WAGON_LENGTH_METERS = 14;
|
||||
@@ -0,0 +1,144 @@
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
describe('BookingBatchService — PAID reconcile', () => {
|
||||
const scheduleId = 'schedule-1';
|
||||
const bookingId = 'booking-1';
|
||||
|
||||
const paidBooking = {
|
||||
id: bookingId,
|
||||
reference: 'BK-2026-000034',
|
||||
trainScheduleId: scheduleId,
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
isGovernment: false,
|
||||
cargoTotalWeightVgm: 20,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
let service: BookingBatchService;
|
||||
let bookingsRepository: {
|
||||
findPaidUnlinkedForSchedule: jest.Mock;
|
||||
findBatchPool: jest.Mock;
|
||||
findReservedForSchedule: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
let trainScheduleBookingsRepository: {
|
||||
existsForBooking: jest.Mock;
|
||||
createMany: jest.Mock;
|
||||
};
|
||||
let trainSchedulesRepository: {
|
||||
findByIdWithFullGraph: jest.Mock;
|
||||
findAll: jest.Mock;
|
||||
};
|
||||
let trainSchedulingService: {
|
||||
tryAutoWagonAllocation: jest.Mock;
|
||||
};
|
||||
let dataSource: {
|
||||
getRepository: jest.Mock;
|
||||
transaction: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
bookingsRepository = {
|
||||
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
findBatchPool: jest.fn().mockResolvedValue([]),
|
||||
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
trainScheduleBookingsRepository = {
|
||||
existsForBooking: jest.fn().mockResolvedValue(false),
|
||||
createMany: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
trainSchedulesRepository = {
|
||||
findByIdWithFullGraph: jest.fn().mockResolvedValue({
|
||||
id: scheduleId,
|
||||
maxWagons: 10,
|
||||
bookingWindowStatus: 'OPEN',
|
||||
trainSet: { locomotive: { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 } },
|
||||
scheduleBookings: [],
|
||||
}),
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
trainSchedulingService = {
|
||||
tryAutoWagonAllocation: jest.fn().mockResolvedValue({
|
||||
assignedBookingIds: [],
|
||||
deferred: [],
|
||||
issues: [],
|
||||
violations: [],
|
||||
}),
|
||||
};
|
||||
|
||||
const bookingRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(paidBooking),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
dataSource = {
|
||||
getRepository: jest.fn().mockReturnValue(bookingRepo),
|
||||
transaction: jest.fn(async (fn: (m: unknown) => Promise<void>) => {
|
||||
const manager = {
|
||||
getRepository: () => bookingRepo,
|
||||
};
|
||||
await fn(manager);
|
||||
}),
|
||||
};
|
||||
|
||||
service = new BookingBatchService(
|
||||
dataSource as never,
|
||||
bookingsRepository as never,
|
||||
trainSchedulesRepository as never,
|
||||
trainScheduleBookingsRepository as never,
|
||||
{ payNow: jest.fn(), secured: jest.fn(), expired: jest.fn() } as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('reconcilePaidUnlinked links PAID bookings without a schedule row', async () => {
|
||||
bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([paidBooking]);
|
||||
|
||||
await service.reconcilePaidUnlinked(scheduleId);
|
||||
|
||||
expect(bookingsRepository.findPaidUnlinkedForSchedule).toHaveBeenCalledWith(scheduleId);
|
||||
expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledWith(
|
||||
[{ trainScheduleId: scheduleId, bookingId }],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('ensurePaidBookingAllocated links PAID booking when not yet linked', async () => {
|
||||
await service.ensurePaidBookingAllocated(bookingId);
|
||||
|
||||
expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledWith(scheduleId);
|
||||
});
|
||||
|
||||
it('ensurePaidBookingAllocated is idempotent when already linked', async () => {
|
||||
trainScheduleBookingsRepository.existsForBooking.mockResolvedValue(true);
|
||||
|
||||
await service.ensurePaidBookingAllocated(bookingId);
|
||||
await service.ensurePaidBookingAllocated(bookingId);
|
||||
|
||||
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
|
||||
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
|
||||
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(undefined);
|
||||
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);
|
||||
const reconcileSpy = jest.spyOn(service, 'reconcilePaidUnlinked').mockResolvedValue(undefined);
|
||||
|
||||
await service.processSchedule(scheduleId);
|
||||
|
||||
expect(fillSpy).toHaveBeenCalledWith(scheduleId);
|
||||
expect(settleSpy).toHaveBeenCalledWith(scheduleId);
|
||||
expect(reconcileSpy).toHaveBeenCalledWith(scheduleId);
|
||||
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledWith(scheduleId);
|
||||
|
||||
const fillOrder = fillSpy.mock.invocationCallOrder[0];
|
||||
const reconcileOrder = reconcileSpy.mock.invocationCallOrder[0];
|
||||
const wagonOrder = trainSchedulingService.tryAutoWagonAllocation.mock.invocationCallOrder[0];
|
||||
expect(fillOrder).toBeLessThan(reconcileOrder);
|
||||
expect(reconcileOrder).toBeLessThan(wagonOrder);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { PAYMENT_WINDOW_MS } from './booking-batch.constants';
|
||||
|
||||
@Injectable()
|
||||
export class BookingNotifierService {
|
||||
private readonly logger = new Logger(BookingNotifierService.name);
|
||||
|
||||
constructor(private readonly notifications: NotificationsService) {}
|
||||
|
||||
private ref(b: Booking): string {
|
||||
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
|
||||
}
|
||||
|
||||
private async notifyContact(
|
||||
b: Booking,
|
||||
message: string,
|
||||
logLabel: string,
|
||||
): Promise<void> {
|
||||
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
||||
const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
|
||||
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
|
||||
|
||||
if (phone) {
|
||||
try {
|
||||
await this.notifications.directSend('sms', phone, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`SMS failed for ${this.ref(b)}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (email) {
|
||||
try {
|
||||
await this.notifications.directSend('email', email, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Email failed for ${this.ref(b)}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (!phone && !email) {
|
||||
this.logger.warn(`No contact on file for ${this.ref(b)} — notification not sent`);
|
||||
}
|
||||
}
|
||||
|
||||
async payNow(b: Booking, deadline: Date): Promise<void> {
|
||||
const payMinutes = Math.round(PAYMENT_WINDOW_MS / 60_000);
|
||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||
const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`;
|
||||
await this.notifyContact(b, msg, 'PAY NOW');
|
||||
}
|
||||
|
||||
secured(b: Booking, reason: 'paid' | 'gov'): void {
|
||||
const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${
|
||||
reason === 'gov' ? ' (government)' : ''
|
||||
}.`;
|
||||
void this.notifyContact(b, msg, 'ALLOCATED');
|
||||
}
|
||||
|
||||
expired(b: Booking): void {
|
||||
const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`;
|
||||
void this.notifyContact(b, msg, 'EXPIRED');
|
||||
}
|
||||
|
||||
scheduleFull(b: Booking): void {
|
||||
this.logger.warn(
|
||||
`SCHEDULE FULL — ${this.ref(b)} could not be placed; change schedule, pick another day, or cancel.`,
|
||||
);
|
||||
}
|
||||
|
||||
displaced(b: Booking): void {
|
||||
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
|
||||
void this.notifyContact(b, msg, 'DISPLACED');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
autoFillPlacements,
|
||||
findMissingContainerNumberIssues,
|
||||
type ContainerUnitForPlacement,
|
||||
} from './container-placement.util';
|
||||
|
||||
describe('container-placement.util', () => {
|
||||
const units: ContainerUnitForPlacement[] = [
|
||||
{
|
||||
bookingId: 'b1',
|
||||
bookingContainerId: 'c1',
|
||||
unitIndex: 0,
|
||||
label: 'REF · 1/1 · 20GP',
|
||||
teuSlots: 1,
|
||||
sizeFt: 20,
|
||||
containerNumber: 'ABCD1234567',
|
||||
},
|
||||
{
|
||||
bookingId: 'b2',
|
||||
bookingContainerId: 'c2',
|
||||
unitIndex: 0,
|
||||
label: 'REF2 · 1/1 · 40GP',
|
||||
teuSlots: 2,
|
||||
sizeFt: 40,
|
||||
containerNumber: null,
|
||||
},
|
||||
];
|
||||
|
||||
it('auto-fills placements across slots', () => {
|
||||
const placements = autoFillPlacements(units, [1, 2]);
|
||||
expect(placements).toHaveLength(2);
|
||||
expect(placements[0].sequenceNo).toBe(1);
|
||||
expect(placements[1].sequenceNo).toBe(2);
|
||||
});
|
||||
|
||||
it('reports missing container numbers only when placement is empty', () => {
|
||||
const placements = autoFillPlacements(units, [1, 2]);
|
||||
const issues = findMissingContainerNumberIssues(units, placements);
|
||||
expect(issues).toHaveLength(0);
|
||||
expect(placements[1].containerNumber).toMatch(/^TBD-/);
|
||||
});
|
||||
|
||||
it('generates TBD placeholder for missing container numbers', () => {
|
||||
const single: ContainerUnitForPlacement[] = [
|
||||
{
|
||||
bookingId: 'b2',
|
||||
bookingReference: 'BK-2026-000033',
|
||||
bookingContainerId: 'c2',
|
||||
unitIndex: 0,
|
||||
label: 'REF2 · 1/1 · 40GP',
|
||||
teuSlots: 2,
|
||||
sizeFt: 40,
|
||||
containerNumber: null,
|
||||
},
|
||||
];
|
||||
const placements = autoFillPlacements(single, [1]);
|
||||
expect(placements[0].containerNumber).toBe('TBD-BK-2026-000033-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ContainerPlacementInput } from './wagon-plan.util';
|
||||
|
||||
export type ContainerUnitForPlacement = {
|
||||
bookingId: string;
|
||||
bookingReference?: string | null;
|
||||
bookingContainerId: string;
|
||||
unitIndex: number;
|
||||
label: string;
|
||||
teuSlots?: number;
|
||||
sizeFt?: number;
|
||||
containerNumber?: string | null;
|
||||
};
|
||||
|
||||
export function placeholderContainerNumber(unit: ContainerUnitForPlacement): string {
|
||||
const ref = unit.bookingReference ?? unit.bookingId.slice(0, 8);
|
||||
return `TBD-${ref}-${unit.unitIndex + 1}`;
|
||||
}
|
||||
|
||||
export function isPlaceholderContainerNumber(value: string | null | undefined): boolean {
|
||||
return Boolean(value?.trim().startsWith('TBD-'));
|
||||
}
|
||||
|
||||
export function resolveContainerNumber(unit: ContainerUnitForPlacement): string {
|
||||
const trimmed = unit.containerNumber?.trim();
|
||||
return trimmed || placeholderContainerNumber(unit);
|
||||
}
|
||||
|
||||
export function autoFillPlacements(
|
||||
units: ContainerUnitForPlacement[],
|
||||
containerSlots: number[],
|
||||
): ContainerPlacementInput[] {
|
||||
if (!units.length || !containerSlots.length) return [];
|
||||
|
||||
const placements: ContainerPlacementInput[] = [];
|
||||
const MAX_TEU_PER_WAGON = 2;
|
||||
let currentSlotIndex = 0;
|
||||
let teuInCurrentSlot = 0;
|
||||
|
||||
for (const unit of units) {
|
||||
const teu = unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1);
|
||||
|
||||
if (teuInCurrentSlot > 0 && teuInCurrentSlot + teu > MAX_TEU_PER_WAGON) {
|
||||
currentSlotIndex += 1;
|
||||
teuInCurrentSlot = 0;
|
||||
}
|
||||
|
||||
const sequenceNo =
|
||||
containerSlots[Math.min(currentSlotIndex, containerSlots.length - 1)] ??
|
||||
containerSlots[containerSlots.length - 1] ??
|
||||
containerSlots[0];
|
||||
|
||||
placements.push({
|
||||
bookingContainerId: unit.bookingContainerId,
|
||||
unitIndex: unit.unitIndex,
|
||||
sequenceNo,
|
||||
containerNumber: resolveContainerNumber(unit),
|
||||
});
|
||||
|
||||
teuInCurrentSlot += teu;
|
||||
}
|
||||
|
||||
return placements;
|
||||
}
|
||||
|
||||
export function findMissingContainerNumberIssues(
|
||||
units: ContainerUnitForPlacement[],
|
||||
placements: ContainerPlacementInput[],
|
||||
): Array<{ bookingId: string; issue: string }> {
|
||||
const issues: Array<{ bookingId: string; issue: string }> = [];
|
||||
const byUnit = new Map(
|
||||
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
|
||||
);
|
||||
|
||||
for (const unit of units) {
|
||||
const placement = byUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`);
|
||||
if (!placement?.containerNumber?.trim()) {
|
||||
issues.push({
|
||||
bookingId: unit.bookingId,
|
||||
issue: `Missing container number for ${unit.label}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function placementsForBookings(
|
||||
placements: ContainerPlacementInput[],
|
||||
bookingIds: Set<string>,
|
||||
units: ContainerUnitForPlacement[],
|
||||
): ContainerPlacementInput[] {
|
||||
const unitBookingIds = new Map(
|
||||
units.map((u) => [`${u.bookingContainerId}:${u.unitIndex}`, u.bookingId]),
|
||||
);
|
||||
return placements.filter((p) => {
|
||||
const bookingId = unitBookingIds.get(`${p.bookingContainerId}:${p.unitIndex}`);
|
||||
return bookingId ? bookingIds.has(bookingId) : false;
|
||||
});
|
||||
}
|
||||
@@ -1,19 +1,4 @@
|
||||
import type { ScheduleTradeDirection } from '@edr/types';
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
|
||||
type YardLike = { country?: string | null };
|
||||
|
||||
export function deriveScheduleDirection(
|
||||
originYard: YardLike,
|
||||
destinationYard: YardLike,
|
||||
): ScheduleTradeDirection {
|
||||
const originCountry = originYard.country?.trim();
|
||||
const destinationCountry = destinationYard.country?.trim();
|
||||
|
||||
if (originCountry === 'Djibouti') {
|
||||
return 'IMPORT';
|
||||
}
|
||||
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
|
||||
return 'EXPORT';
|
||||
}
|
||||
return 'DOMESTIC';
|
||||
}
|
||||
/** @deprecated Use deriveTradeDirection from common — kept as alias for train scheduling. */
|
||||
export const deriveScheduleDirection = deriveTradeDirection;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class AvailableLocomotivesQueryDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'Route used to derive import/export/domestic readiness' })
|
||||
@IsUUID()
|
||||
routeId!: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class BookableSchedulesQueryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
originYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
destinationYardId?: string;
|
||||
}
|
||||
@@ -17,6 +17,14 @@ export class GetEligibleBookingsDto {
|
||||
@IsUUID()
|
||||
destinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Scope to bookings that targeted this specific schedule (batch parity).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
schedulingStatus?: string;
|
||||
|
||||
@@ -12,6 +12,11 @@ export class GetEligibleBulkBookingsDto {
|
||||
@IsUUID()
|
||||
destinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'HOLDING' })
|
||||
@IsOptional()
|
||||
schedulingStatus?: string;
|
||||
|
||||
@@ -12,6 +12,11 @@ export class GetEligibleContainerBookingsDto {
|
||||
@IsUUID()
|
||||
destinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'HOLDING' })
|
||||
@IsOptional()
|
||||
schedulingStatus?: string;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { TrainCheckpointKind } from '@edr/types';
|
||||
import {
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class RecordCheckpointDto {
|
||||
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sequenceNo!: number;
|
||||
|
||||
@ApiProperty({ enum: TrainCheckpointKind, required: false })
|
||||
@IsOptional()
|
||||
@IsEnum(TrainCheckpointKind)
|
||||
kind?: TrainCheckpointKind;
|
||||
|
||||
@ApiProperty({ required: false, description: 'ISO timestamp; defaults to now.' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
occurredAt?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { TrainCheckpointKind } from '@edr/types';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
|
||||
/**
|
||||
* One staff-logged tracking checkpoint for a dispatched train as it passes a
|
||||
* station along its route (origin → milestones → destination).
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'train_checkpoint_events' })
|
||||
@Index(['trainScheduleId'])
|
||||
@Index(['trainScheduleId', 'sequenceNo'])
|
||||
export class TrainCheckpointEvent extends BaseEntity {
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid' })
|
||||
trainScheduleId!: string;
|
||||
|
||||
@ManyToOne(() => TrainSchedule, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'train_schedule_id' })
|
||||
trainSchedule?: TrainSchedule;
|
||||
|
||||
@Column({ name: 'yard_id', type: 'uuid' })
|
||||
yardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'yard_id' })
|
||||
yard?: Yard;
|
||||
|
||||
/** Position along the corridor: 0 = origin, N+1 = destination. */
|
||||
@Column({ name: 'sequence_no', type: 'int' })
|
||||
sequenceNo!: number;
|
||||
|
||||
@Column({ name: 'kind', type: 'varchar', length: 20 })
|
||||
kind!: TrainCheckpointKind;
|
||||
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||
occurredAt!: Date;
|
||||
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
|
||||
@Column({ name: 'recorded_by_user_id', type: 'uuid', nullable: true })
|
||||
recordedByUserId?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
bookingTrainLengthMeters,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
} from './train-capacity.util';
|
||||
|
||||
describe('train-capacity.util', () => {
|
||||
const nw5 = { lengthMeters: 14, capacityTons: 70 };
|
||||
|
||||
it('derives wagon slots from locomotive length and weight, not a fixed 53', () => {
|
||||
const shortLoco = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 2000, maxTrainLengthMeters: 280 },
|
||||
[nw5],
|
||||
);
|
||||
expect(shortLoco.maxWagonSlots).toBe(20); // 280 / 14
|
||||
expect(shortLoco.maxWagonSlots).not.toBe(53);
|
||||
|
||||
const heavyLoco = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 2100, maxTrainLengthMeters: 760 },
|
||||
[nw5],
|
||||
);
|
||||
expect(heavyLoco.maxWagonSlots).toBe(30); // min(54, 30) from weight 2100/70
|
||||
});
|
||||
|
||||
it('uses shortest wagon type when mixed types are present', () => {
|
||||
const longBulk = { lengthMeters: 18, capacityTons: 80 };
|
||||
const mixed = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||
[nw5, longBulk],
|
||||
);
|
||||
expect(mixed.maxWagonSlots).toBe(
|
||||
Math.min(Math.floor(760 / 14), Math.floor(3500 / 70)),
|
||||
);
|
||||
});
|
||||
|
||||
it('computes booking length by freight type', () => {
|
||||
expect(
|
||||
bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 }),
|
||||
).toBe(28);
|
||||
expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/** Physical dimensions used when deriving how many wagons a locomotive can pull. */
|
||||
export type WagonTypeDimensions = {
|
||||
lengthMeters: number;
|
||||
capacityTons: number;
|
||||
};
|
||||
|
||||
export type LocomotiveLimits = {
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
};
|
||||
|
||||
export type DerivedTrainCapacity = {
|
||||
maxWeightTons: number;
|
||||
maxLengthMeters: number;
|
||||
maxWagonSlots: number;
|
||||
};
|
||||
|
||||
const DEFAULT_WAGON_LENGTH_M = 14;
|
||||
const DEFAULT_WAGON_CAPACITY_T = 70;
|
||||
|
||||
/**
|
||||
* Derive train capacity from locomotive pull weight and train length.
|
||||
* Wagon count is NOT a fixed 53 — it is the minimum of:
|
||||
* - floor(maxLength / shortest wagon type length)
|
||||
* - floor(maxWeight / lightest wagon type capacity)
|
||||
*/
|
||||
export function deriveTrainCapacityFromLocomotive(
|
||||
locomotive: LocomotiveLimits,
|
||||
wagonTypes: WagonTypeDimensions[],
|
||||
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
|
||||
): DerivedTrainCapacity {
|
||||
const maxWeightTons = Math.min(
|
||||
Number(locomotive.maxPullWeightTons) || Infinity,
|
||||
ruleCaps?.maxTrainWeightTons ?? Infinity,
|
||||
);
|
||||
const maxLengthMeters = Math.min(
|
||||
Number(locomotive.maxTrainLengthMeters) || Infinity,
|
||||
ruleCaps?.maxTrainLengthMeters ?? Infinity,
|
||||
);
|
||||
|
||||
const types =
|
||||
wagonTypes.length > 0
|
||||
? wagonTypes
|
||||
: [{ lengthMeters: DEFAULT_WAGON_LENGTH_M, capacityTons: DEFAULT_WAGON_CAPACITY_T }];
|
||||
|
||||
const minLength = Math.min(...types.map((w) => Number(w.lengthMeters) || DEFAULT_WAGON_LENGTH_M));
|
||||
const minCapacity = Math.min(
|
||||
...types.map((w) => Number(w.capacityTons) || DEFAULT_WAGON_CAPACITY_T),
|
||||
);
|
||||
|
||||
const byLength =
|
||||
minLength > 0 && Number.isFinite(maxLengthMeters)
|
||||
? Math.floor(maxLengthMeters / minLength)
|
||||
: 0;
|
||||
const byWeight =
|
||||
minCapacity > 0 && Number.isFinite(maxWeightTons)
|
||||
? Math.floor(maxWeightTons / minCapacity)
|
||||
: byLength;
|
||||
|
||||
const maxWagonSlots = Math.max(0, Math.min(byLength, byWeight));
|
||||
|
||||
return {
|
||||
maxWeightTons: Number.isFinite(maxWeightTons) ? maxWeightTons : MAX_FALLBACK_WEIGHT,
|
||||
maxLengthMeters: Number.isFinite(maxLengthMeters) ? maxLengthMeters : MAX_FALLBACK_LENGTH,
|
||||
maxWagonSlots,
|
||||
};
|
||||
}
|
||||
|
||||
export const MAX_FALLBACK_WEIGHT = 3500;
|
||||
export const MAX_FALLBACK_LENGTH = 760;
|
||||
|
||||
/** Per-booking train length from wagon count and freight-specific wagon type length. */
|
||||
export function bookingTrainLengthMeters(
|
||||
freightType: string | null | undefined,
|
||||
wagonCount: number,
|
||||
lengths: { container: number; bulk: number },
|
||||
): number {
|
||||
const perWagon = freightType === 'BULK' ? lengths.bulk : lengths.container;
|
||||
return wagonCount * perWagon;
|
||||
}
|
||||
|
||||
export function wagonTypeDimensionsFromEntity(wt: {
|
||||
lengthMeters?: number | string | null;
|
||||
capacityTons?: number | string | null;
|
||||
}): WagonTypeDimensions {
|
||||
return {
|
||||
lengthMeters: Number(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M,
|
||||
capacityTons: Number(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TrainCheckpointEventsRepository extends BaseRepository<TrainCheckpointEvent> {
|
||||
constructor(
|
||||
@InjectRepository(TrainCheckpointEvent)
|
||||
repository: Repository<TrainCheckpointEvent>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findBySchedule(trainScheduleId: string): Promise<TrainCheckpointEvent[]> {
|
||||
return this.findAll({
|
||||
where: { trainScheduleId },
|
||||
relations: { yard: true },
|
||||
order: { sequenceNo: 'ASC', occurredAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -21,14 +21,21 @@ import { PinWagonsDto } from './dto/pin-wagons.dto';
|
||||
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||
import { AvailableLocomotivesQueryDto } from './dto/available-locomotives-query.dto';
|
||||
import { BookableSchedulesQueryDto } from './dto/bookable-schedules-query.dto';
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
|
||||
@ApiTags('train-scheduling')
|
||||
@ApiBearerAuth()
|
||||
@Controller('train-scheduling')
|
||||
export class TrainSchedulingController {
|
||||
constructor(private readonly trainSchedulingService: TrainSchedulingService) {}
|
||||
constructor(
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
) {}
|
||||
|
||||
@Get('global-rules')
|
||||
@TrainSchedulingView()
|
||||
@@ -51,6 +58,39 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.getEligibleBookings(query);
|
||||
}
|
||||
|
||||
@Get('batch-board')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'Batch monitoring board: schedules with bookings grouped by state' })
|
||||
getBatchBoard() {
|
||||
return this.bookingBatchService.getBatchBoard();
|
||||
}
|
||||
|
||||
@Get('batch-board/:scheduleId')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'Batch board detail for one schedule with EAT 3h windows' })
|
||||
getBatchBoardDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
||||
return this.bookingBatchService.getBatchBoardDetail(scheduleId);
|
||||
}
|
||||
|
||||
@Get('available-locomotives')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary: 'List AVAILABLE locomotives filtered by route corridor readiness',
|
||||
})
|
||||
getAvailableLocomotives(@Query() query: AvailableLocomotivesQueryDto) {
|
||||
return this.trainSchedulingService.getAvailableLocomotivesForRoute(query.routeId);
|
||||
}
|
||||
|
||||
@Get('bookable-schedules')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'OPEN same-route schedules a new booking can target' })
|
||||
getBookableSchedules(@Query() query: BookableSchedulesQueryDto) {
|
||||
return this.trainSchedulingService.getBookableSchedules(
|
||||
query.originYardId,
|
||||
query.destinationYardId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('container/eligible-bookings')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'List eligible container bookings' })
|
||||
@@ -161,6 +201,85 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.dispatchSchedule(id);
|
||||
}
|
||||
|
||||
// ---- batch / booking-window staff actions ----
|
||||
|
||||
@Post('schedules/:id/run-batch')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Manually run the batch fill for a schedule' })
|
||||
async runBatch(@Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.bookingBatchService.fillSchedule(id);
|
||||
return this.bookingBatchService.getBatchBoardDetail(id);
|
||||
}
|
||||
|
||||
@Post('schedules/:id/run-allocation')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Run wagon-level allocation for all eligible linked bookings' })
|
||||
async runAllocation(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.bookingBatchService.runWagonAllocation(id);
|
||||
}
|
||||
|
||||
@Patch('schedules/:id/booking-window')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Open or close a schedule booking window' })
|
||||
async setBookingWindow(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('status') status: 'OPEN' | 'CLOSED',
|
||||
) {
|
||||
await this.trainSchedulingService.setBookingWindow(id, status === 'CLOSED' ? 'CLOSED' : 'OPEN');
|
||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/mark-paid')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Staff: mark a reserved booking paid and allocate it now' })
|
||||
async markBookingPaid(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
await this.bookingBatchService.markPaid(bookingId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/expire')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Staff: expire a reservation and free its capacity' })
|
||||
async expireBooking(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
await this.bookingBatchService.expireReservation(bookingId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/move-schedule')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Re-point a booking to another OPEN same-route schedule' })
|
||||
async moveBookingSchedule(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body('trainScheduleId', ParseUUIDPipe) trainScheduleId: string,
|
||||
) {
|
||||
await this.bookingBatchService.moveToSchedule(bookingId, trainScheduleId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('schedules/:id/checkpoints')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'Get the tracking corridor + logged checkpoints for a train' })
|
||||
getScheduleCheckpoints(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getScheduleCheckpoints(id);
|
||||
}
|
||||
|
||||
@Post('schedules/:id/checkpoints')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Log the train passing a station (final station triggers arrival)' })
|
||||
recordCheckpoint(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RecordCheckpointDto,
|
||||
) {
|
||||
return this.trainSchedulingService.recordCheckpoint(id, dto);
|
||||
}
|
||||
|
||||
@Post('schedules/:id/arrive')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Mark a dispatched train arrived (flip readiness, free assets)' })
|
||||
arriveSchedule(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.arriveSchedule(id);
|
||||
}
|
||||
|
||||
@Get('container/schedules')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'List container train schedules' })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
@@ -14,9 +14,14 @@ import { TrainSchedulesModule } from '../train-schedules/train-schedules.module'
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||
import { TrainSchedulingController } from './train-scheduling.controller';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -29,8 +34,10 @@ import { TrainSchedulingService } from './train-scheduling.service';
|
||||
Wagon,
|
||||
Container,
|
||||
TrainSchedulingGlobalRules,
|
||||
TrainCheckpointEvent,
|
||||
]),
|
||||
BookingsModule,
|
||||
forwardRef(() => BookingsModule),
|
||||
NotificationsModule,
|
||||
LocomotivesModule,
|
||||
WagonTypesModule,
|
||||
TrainSetsModule,
|
||||
@@ -38,7 +45,12 @@ import { TrainSchedulingService } from './train-scheduling.service';
|
||||
RuleEngineModule,
|
||||
],
|
||||
controllers: [TrainSchedulingController],
|
||||
providers: [TrainSchedulingService],
|
||||
exports: [TrainSchedulingService],
|
||||
providers: [
|
||||
TrainSchedulingService,
|
||||
TrainCheckpointEventsRepository,
|
||||
BookingBatchService,
|
||||
BookingNotifierService,
|
||||
],
|
||||
exports: [TrainSchedulingService, BookingBatchService],
|
||||
})
|
||||
export class TrainSchedulingModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
@@ -25,6 +25,7 @@ const locomotive = {
|
||||
maxPullWeightTons: 3500,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: 'AVAILABLE',
|
||||
readiness: WagonReadiness.ImportReady,
|
||||
};
|
||||
|
||||
const cw3 = {
|
||||
@@ -125,6 +126,13 @@ describe('TrainSchedulingService', () => {
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const trainCheckpointEventsRepository = {
|
||||
findBySchedule: jest.fn().mockResolvedValue([]),
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
service = new TrainSchedulingService(
|
||||
dataSource as never,
|
||||
bookingsRepository as never,
|
||||
@@ -135,6 +143,7 @@ describe('TrainSchedulingService', () => {
|
||||
wagonBookingAllocationsRepository as never,
|
||||
wagonAllocationContainerItemsRepository as never,
|
||||
wagonAllocationBulkLoadsRepository as never,
|
||||
trainCheckpointEventsRepository as never,
|
||||
);
|
||||
|
||||
const defaultFleetWagons = [
|
||||
@@ -412,6 +421,9 @@ describe('TrainSchedulingService', () => {
|
||||
if (entity === TrainSchedulingGlobalRules) {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === WagonType) {
|
||||
return { find: jest.fn().mockResolvedValue([nw5, cw3]) };
|
||||
}
|
||||
throw new Error(`Unexpected repository ${(entity as { name?: string })?.name}`);
|
||||
});
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: 'schedule-1' });
|
||||
@@ -564,4 +576,202 @@ describe('TrainSchedulingService', () => {
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('flags physical fleet shortfall when export schedule lacks EXPORT_READY wagons', async () => {
|
||||
const exportBooking = makeBooking(
|
||||
'exp-1',
|
||||
'BKG-EXP',
|
||||
50,
|
||||
1,
|
||||
'40FT',
|
||||
1,
|
||||
'2026-06-20T08:00:00.000Z',
|
||||
'yard-addis',
|
||||
'yard-djibouti',
|
||||
{
|
||||
originYard: { label: 'Addis Ababa', code: 'ADDIS', country: 'Ethiopia' },
|
||||
destinationYard: { label: 'Djibouti', code: 'DJIBOUTI', country: 'Djibouti' },
|
||||
},
|
||||
);
|
||||
|
||||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||
bookingsRepository.findByIdsForScheduling.mockResolvedValue([exportBooking]);
|
||||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||
|
||||
const importOnlyFleet = Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `wagon-nw5-${index}`,
|
||||
wagonTypeId: nw5.id,
|
||||
wagonNumber: `WGN-${index}`,
|
||||
status: WagonStatus.Available,
|
||||
readiness: WagonReadiness.ImportReady,
|
||||
currentTrainScheduleId: null,
|
||||
}));
|
||||
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if (entity === TrainSchedulingGlobalRules) {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === Wagon) {
|
||||
return { find: jest.fn().mockResolvedValue(importOnlyFleet) };
|
||||
}
|
||||
if (entity === WagonType) {
|
||||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||||
}
|
||||
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||||
});
|
||||
|
||||
const result = await service.previewContainerTrainSchedule({
|
||||
bookingIds: ['exp-1'],
|
||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||
originStationId: 'yard-addis',
|
||||
destinationStationId: 'yard-djibouti',
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(
|
||||
result.violations.some((v) => v.includes('EXPORT_READY') && v.includes('NW5')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('assignBookingsToSchedule rejects when physical wagons cannot be pinned', async () => {
|
||||
const scheduleId = 'sched-assign-1';
|
||||
const trainSetId = 'train-set-1';
|
||||
const booking = makeBooking('b-pin', 'BKG-PIN', 50, 1, '40FT', 1);
|
||||
|
||||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||
bookingsRepository.findByIdsForScheduling.mockResolvedValue([{ ...booking, trainScheduleId: scheduleId }]);
|
||||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||
trainSchedulesRepository.findById.mockResolvedValue({
|
||||
id: scheduleId,
|
||||
direction: 'IMPORT',
|
||||
});
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
||||
id: scheduleId,
|
||||
status: 'DRAFT',
|
||||
direction: 'IMPORT',
|
||||
trainSetId,
|
||||
originStationId: 'yard-origin',
|
||||
destinationStationId: 'yard-destination',
|
||||
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
|
||||
trainSet: {
|
||||
id: trainSetId,
|
||||
locomotive,
|
||||
wagons: [],
|
||||
},
|
||||
scheduleBookings: [],
|
||||
});
|
||||
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if (entity === TrainSchedulingGlobalRules) {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === Wagon) {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === WagonType) {
|
||||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||||
}
|
||||
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||||
});
|
||||
|
||||
const wagonRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const trainSetWagonRepo = {
|
||||
delete: jest.fn(),
|
||||
create: jest.fn((v) => v),
|
||||
save: jest.fn(async (rows) =>
|
||||
rows.map((r: { sequenceNo: number; wagonTypeId: string }, i: number) => ({
|
||||
...r,
|
||||
id: `slot-${i + 1}`,
|
||||
})),
|
||||
),
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
const manager = {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === Wagon) return wagonRepo;
|
||||
if (entity === WagonType) return { find: jest.fn().mockResolvedValue([nw5]) };
|
||||
if (entity === TrainSetWagon) return trainSetWagonRepo;
|
||||
if ((entity as { name?: string })?.name === 'TrainSet') return { update: jest.fn() };
|
||||
if ((entity as { name?: string })?.name === 'TrainScheduleBooking') return { delete: jest.fn() };
|
||||
if ((entity as { name?: string })?.name === 'WagonBookingAllocation') {
|
||||
return {
|
||||
create: jest.fn((v) => v),
|
||||
save: jest.fn(async (v) => ({ ...v, id: 'alloc-1' })),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
}
|
||||
return { delete: jest.fn(), update: jest.fn(), find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
};
|
||||
dataSource.transaction.mockImplementation(async (cb: (m: typeof manager) => Promise<void>) =>
|
||||
cb(manager),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.assignBookingsToSchedule(
|
||||
scheduleId,
|
||||
{ bookingIds: ['b-pin'], containerPlacements: [] },
|
||||
'CONTAINER',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
describe('getAvailableLocomotivesForRoute', () => {
|
||||
it('filters to export-ready locomotives on Ethiopia → Djibouti routes', async () => {
|
||||
const routeId = 'route-export';
|
||||
const routeRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: routeId,
|
||||
name: 'Addis → Djibouti',
|
||||
isActive: true,
|
||||
originYard: { country: 'Ethiopia' },
|
||||
destinationYard: { country: 'Djibouti' },
|
||||
}),
|
||||
};
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
||||
return { findOne: jest.fn(), update: jest.fn() };
|
||||
});
|
||||
locomotivesRepository.findAll.mockResolvedValue([
|
||||
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', readiness: WagonReadiness.ImportReady },
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', readiness: WagonReadiness.ExportReady },
|
||||
]);
|
||||
|
||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].code).toBe('EXP');
|
||||
});
|
||||
|
||||
it('returns all available locomotives on domestic routes', async () => {
|
||||
const routeId = 'route-domestic';
|
||||
const routeRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: routeId,
|
||||
name: 'Addis → Dire Dawa',
|
||||
isActive: true,
|
||||
originYard: { country: 'Ethiopia' },
|
||||
destinationYard: { country: 'Ethiopia' },
|
||||
}),
|
||||
};
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
||||
return { findOne: jest.fn(), update: jest.fn() };
|
||||
});
|
||||
locomotivesRepository.findAll.mockResolvedValue([
|
||||
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', readiness: WagonReadiness.ImportReady },
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', readiness: WagonReadiness.ExportReady },
|
||||
]);
|
||||
|
||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -415,8 +415,10 @@ export function validateTrainLimits(
|
||||
const violations: string[] = [];
|
||||
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
|
||||
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
|
||||
const wagonLength = Number(wagonType.lengthMeters) || 14;
|
||||
const maxWagonsPerTrain =
|
||||
limits?.maxWagonsPerTrain ?? Number(wagonType.maxWagonsPerTrain ?? 53);
|
||||
limits?.maxWagonsPerTrain ??
|
||||
Math.floor(maxLengthMeters / wagonLength);
|
||||
|
||||
const totalWeightTons = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0),
|
||||
@@ -451,9 +453,13 @@ export function validateMixedTrainLimits(
|
||||
wagonTypes: WagonType[],
|
||||
limits?: TrainLimitConfig,
|
||||
): string[] {
|
||||
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
|
||||
const minWagonLength = Math.min(
|
||||
...wagonTypes.map((wt) => Number(wt.lengthMeters) || 14),
|
||||
14,
|
||||
);
|
||||
const maxWagonsPerTrain =
|
||||
limits?.maxWagonsPerTrain ??
|
||||
Math.max(...wagonTypes.map((wt) => Number(wt.maxWagonsPerTrain ?? 53)), 53);
|
||||
limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / minWagonLength);
|
||||
|
||||
return validateTrainLimits(
|
||||
wagonPlan,
|
||||
|
||||
@@ -16,3 +16,16 @@ export function wagonReadinessMatchesSchedule(
|
||||
if (!required) return true;
|
||||
return wagonReadiness === required;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a readiness value (IMPORT_READY ↔ EXPORT_READY). Used when a train
|
||||
* reaches its destination: the asset has repositioned, so it is now ready for
|
||||
* the opposite direction. Direction-agnostic so it handles round trips.
|
||||
*/
|
||||
export function flipReadiness(
|
||||
readiness: WagonReadiness | string,
|
||||
): WagonReadiness {
|
||||
return readiness === WagonReadiness.ImportReady
|
||||
? WagonReadiness.ExportReady
|
||||
: WagonReadiness.ImportReady;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
|
||||
export class ListWagonsQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Search wagon number (partial match)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: WagonStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(WagonStatus)
|
||||
status?: WagonStatus;
|
||||
|
||||
@ApiPropertyOptional({ enum: WagonReadiness })
|
||||
@IsOptional()
|
||||
@IsEnum(WagonReadiness)
|
||||
readiness?: WagonReadiness;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
wagonTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainId?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 'wagonNumber' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortBy?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'ASC' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 500 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(500)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
||||
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
||||
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
|
||||
@@ -29,7 +30,7 @@ export class WagonsController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all wagons' })
|
||||
findAll(@Query() query: Record<string, string | undefined>) {
|
||||
findAll(@Query() query: ListWagonsQueryDto) {
|
||||
return this.wagonsService.findAll(query);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
|
||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
||||
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
||||
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
|
||||
@@ -31,16 +32,16 @@ export class WagonsService {
|
||||
return this.wagonRepo.save(wagon);
|
||||
}
|
||||
|
||||
async findAll(query: Record<string, string | undefined> = {}): Promise<Wagon[]> {
|
||||
async findAll(query: ListWagonsQueryDto = {}): Promise<Wagon[]> {
|
||||
const where: FindOptionsWhere<Wagon>[] | FindOptionsWhere<Wagon> = [];
|
||||
const search = query.search?.trim();
|
||||
const status = query.status?.trim();
|
||||
const readiness = query.readiness?.trim();
|
||||
const trainId = query.trainId?.trim();
|
||||
const filters = {
|
||||
...(status ? { status: status as Wagon['status'] } : {}),
|
||||
...(readiness ? { readiness: readiness as Wagon['readiness'] } : {}),
|
||||
const wagonTypeId = query.wagonTypeId?.trim();
|
||||
const filters: FindOptionsWhere<Wagon> = {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.readiness ? { readiness: query.readiness } : {}),
|
||||
...(trainId ? { trainId } : {}),
|
||||
...(wagonTypeId ? { wagonTypeId } : {}),
|
||||
};
|
||||
|
||||
if (search) {
|
||||
|
||||
@@ -10,6 +10,8 @@ import { ServiceType } from "../modules/rule-engine/entities/service-type.entity
|
||||
import { ShippingLine } from "../modules/rule-engine/entities/shipping-line.entity";
|
||||
import { SurchargeType } from "../modules/rule-engine/entities/surcharge-type.entity";
|
||||
import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-rule.entity";
|
||||
import { Route } from "../modules/routes/entities/route.entity";
|
||||
import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity";
|
||||
import { Yard } from "../modules/rule-engine/entities/yard.entity";
|
||||
|
||||
const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001";
|
||||
@@ -32,6 +34,7 @@ export class PricingDataSeeder {
|
||||
const rRepo = manager.getRepository(Rate);
|
||||
|
||||
await this.upsertReferenceData(manager, ctRepo, stRepo, yRepo, slRepo);
|
||||
await this.seedDomesticRoute(manager, yRepo);
|
||||
await this.seedWeightLimits(wlRepo, ctRepo);
|
||||
await this.seedPriorityRules(prRepo);
|
||||
const containerTypes = await ctRepo.find();
|
||||
@@ -287,6 +290,40 @@ export class PricingDataSeeder {
|
||||
);
|
||||
}
|
||||
|
||||
private async seedDomesticRoute(manager: any, yRepo: any): Promise<void> {
|
||||
const addis = await yRepo.findOneBy({ code: "ADDIS_ABABA" });
|
||||
const direDawa = await yRepo.findOneBy({ code: "DIRE_DAWA" });
|
||||
if (!addis || !direDawa) return;
|
||||
|
||||
const routeRepo = manager.getRepository(Route);
|
||||
const milestoneRepo = manager.getRepository(RouteMilestone);
|
||||
const routeName = "Addis Ababa → Dire Dawa";
|
||||
let route = await routeRepo.findOneBy({ name: routeName });
|
||||
if (!route) {
|
||||
route = await routeRepo.save(
|
||||
routeRepo.create({
|
||||
name: routeName,
|
||||
originYardId: addis.id,
|
||||
destinationYardId: direDawa.id,
|
||||
isActive: true,
|
||||
}),
|
||||
);
|
||||
await milestoneRepo.save([
|
||||
milestoneRepo.create({
|
||||
routeId: route.id,
|
||||
yardId: addis.id,
|
||||
sequenceNo: 1,
|
||||
}),
|
||||
milestoneRepo.create({
|
||||
routeId: route.id,
|
||||
yardId: direDawa.id,
|
||||
sequenceNo: 2,
|
||||
}),
|
||||
]);
|
||||
this.logger.log("Seeded domestic route Addis Ababa → Dire Dawa");
|
||||
}
|
||||
}
|
||||
|
||||
private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
await wlRepo.createQueryBuilder().delete().execute();
|
||||
const twenty = await ctRepo.findOneByOrFail({ code: "20FT" });
|
||||
@@ -317,6 +354,18 @@ export class PricingDataSeeder {
|
||||
maxVgmTons: 28,
|
||||
effectiveFrom: base,
|
||||
},
|
||||
{
|
||||
containerTypeId: twenty.id,
|
||||
tradeDirection: "DOMESTIC",
|
||||
maxVgmTons: 26,
|
||||
effectiveFrom: base,
|
||||
},
|
||||
{
|
||||
containerTypeId: forty.id,
|
||||
tradeDirection: "DOMESTIC",
|
||||
maxVgmTons: 28,
|
||||
effectiveFrom: base,
|
||||
},
|
||||
]);
|
||||
this.logger.log("Seeded weight limit rules");
|
||||
}
|
||||
@@ -472,6 +521,20 @@ export class PricingDataSeeder {
|
||||
rateValue: 25000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_BULK",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 35,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_BULK",
|
||||
containerTypeId: null,
|
||||
currency: "ETB",
|
||||
rateValue: 1900,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "BULK_IMPORT",
|
||||
containerTypeId: null,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Boxes,
|
||||
FileText,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
Network,
|
||||
Paperclip,
|
||||
Settings,
|
||||
@@ -37,7 +38,10 @@ import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirec
|
||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||
import TrainsPage from "./pages/trains/TrainsPage";
|
||||
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
||||
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
|
||||
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
|
||||
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
||||
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||
@@ -75,6 +79,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
icon: <Train />,
|
||||
},
|
||||
{
|
||||
label: "Batch Board",
|
||||
href: "/dashboard/operations/batch-board",
|
||||
icon: <LayoutGrid />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -256,6 +265,11 @@ const App = () => {
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||
<Route path="operations/batch-board" element={<BatchBoardPage />} />
|
||||
<Route
|
||||
path="operations/batch-board/:scheduleId"
|
||||
element={<BatchScheduleDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2"
|
||||
element={<TrainScheduleV2ListPage />}
|
||||
@@ -264,6 +278,10 @@ const App = () => {
|
||||
path="operations/train-scheduling-v2/:scheduleId"
|
||||
element={<TrainScheduleV2DetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId/track"
|
||||
element={<TrainScheduleTrackPage />}
|
||||
/>
|
||||
<Route path="routes" element={<RoutesPage />} />
|
||||
<Route path="locomotives" element={<FleetResourcePage />} />
|
||||
<Route path="trains" element={<FleetResourcePage />} />
|
||||
|
||||
@@ -11,14 +11,11 @@ import {
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
import type {
|
||||
BookingListSummaryMetrics,
|
||||
BookingListSummaryTabs,
|
||||
} from "@/services/bookings.service";
|
||||
|
||||
const HERO_GRADIENT = `linear-gradient(135deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 48%, ${freightBrand.primaryLight} 120%)`;
|
||||
|
||||
/** Lifecycle stages for the pipeline distribution bar (in flow order). */
|
||||
const PIPELINE_STAGES: Array<{
|
||||
key: keyof BookingListSummaryTabs;
|
||||
@@ -26,13 +23,18 @@ const PIPELINE_STAGES: Array<{
|
||||
color: string;
|
||||
}> = [
|
||||
{ key: "intake", label: "Intake", color: "#38bdf8" },
|
||||
{ key: "in_approval", label: "Approval", color: "#fbbf24" },
|
||||
{ key: "approved_contract", label: "Contract", color: "#a78bfa" },
|
||||
{ key: "in_approval", label: "Approval", color: "#f59e0b" },
|
||||
{ key: "approved_contract", label: "Contract", color: "#8b5cf6" },
|
||||
{ key: "payment", label: "Payment", color: "#fb923c" },
|
||||
{ key: "operations", label: "Operations", color: "#2dd4bf" },
|
||||
{ key: "completed", label: "Completed", color: "#86efac" },
|
||||
{ key: "operations", label: "Operations", color: "#14b8a6" },
|
||||
{ key: "completed", label: "Completed", color: "#22c55e" },
|
||||
];
|
||||
|
||||
const CARD_STYLE = {
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
} as const;
|
||||
|
||||
export interface BookingRequestsHeaderProps {
|
||||
metrics?: BookingListSummaryMetrics;
|
||||
tabs?: BookingListSummaryTabs;
|
||||
@@ -59,74 +61,40 @@ export function BookingRequestsHeader({
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: HERO_GRADIENT,
|
||||
boxShadow: freightBrand.shadow,
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
|
||||
}}
|
||||
>
|
||||
{/* decorative glows */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -100,
|
||||
right: -60,
|
||||
width: 300,
|
||||
height: 300,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.12)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: -130,
|
||||
right: 160,
|
||||
width: 240,
|
||||
height: 240,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={56} radius="lg" variant="white" style={{ color: freightBrand.primary }}>
|
||||
<ThemeIcon size={56} radius="lg" variant="light" color="green">
|
||||
<Inbox size={28} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" fw={700} c="rgba(255,255,255,0.8)" tt="uppercase" style={{ letterSpacing: 1 }}>
|
||||
<Text size="xs" fw={700} c="green.7" tt="uppercase" style={{ letterSpacing: 1 }}>
|
||||
Operations
|
||||
</Text>
|
||||
<Title order={2} c="white" fw={700}>
|
||||
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
||||
Booking Requests
|
||||
</Title>
|
||||
<Text size="sm" c="rgba(255,255,255,0.85)" maw={520}>
|
||||
<Text size="sm" c="dimmed" maw={520}>
|
||||
Track every booking from submission through approval, payment, and
|
||||
dispatch — prioritize what needs action.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="white"
|
||||
c="green.8"
|
||||
radius="lg"
|
||||
leftSection={<Plus size={18} />}
|
||||
onClick={onCreate}
|
||||
>
|
||||
<Button color="green" radius="lg" leftSection={<Plus size={18} />} onClick={onCreate}>
|
||||
Create booking
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="white"
|
||||
variant="default"
|
||||
radius="lg"
|
||||
c="white"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={onRefresh}
|
||||
style={{ background: "rgba(255,255,255,0.15)" }}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
@@ -134,19 +102,14 @@ export function BookingRequestsHeader({
|
||||
</Group>
|
||||
|
||||
<Group grow gap="md" align="stretch" wrap="wrap">
|
||||
<HeroStat
|
||||
icon={LayoutList}
|
||||
label="In queue"
|
||||
value={val(metrics?.inQueue)}
|
||||
hint="Matching current filter"
|
||||
/>
|
||||
<HeroStat icon={LayoutList} label="In queue" value={val(metrics?.inQueue)} hint="Matching current filter" />
|
||||
<HeroStat
|
||||
icon={Clock}
|
||||
label="Needs action"
|
||||
value={val(metrics?.needsAction)}
|
||||
hint="Submitted or pending"
|
||||
ratio={metrics?.inQueue ? (metrics.needsAction ?? 0) / metrics.inQueue : 0}
|
||||
ratioColor="#fbbf24"
|
||||
ratioColor="#f59e0b"
|
||||
/>
|
||||
<HeroStat
|
||||
icon={AlertTriangle}
|
||||
@@ -154,14 +117,9 @@ export function BookingRequestsHeader({
|
||||
value={val(metrics?.urgent)}
|
||||
hint="High priority score"
|
||||
ratio={metrics?.inQueue ? (metrics.urgent ?? 0) / metrics.inQueue : 0}
|
||||
ratioColor="#fca5a5"
|
||||
/>
|
||||
<HeroStat
|
||||
icon={CheckCircle2}
|
||||
label="Completed"
|
||||
value={val(tabs?.completed)}
|
||||
hint="Fully executed"
|
||||
ratioColor="#ef4444"
|
||||
/>
|
||||
<HeroStat icon={CheckCircle2} label="Completed" value={val(tabs?.completed)} hint="Fully executed" />
|
||||
</Group>
|
||||
|
||||
{tabs ? <PipelineBar tabs={tabs} /> : null}
|
||||
@@ -173,36 +131,31 @@ export function BookingRequestsHeader({
|
||||
/** Compact ring gauge with the stat icon at its center. */
|
||||
function MiniDonut({
|
||||
pct,
|
||||
color = "white",
|
||||
color,
|
||||
children,
|
||||
size = 52,
|
||||
stroke = 5,
|
||||
}: {
|
||||
pct?: number | null;
|
||||
color?: string;
|
||||
color: string;
|
||||
children: ReactNode;
|
||||
size?: number;
|
||||
stroke?: number;
|
||||
}) {
|
||||
const radius = (size - stroke) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const clamped =
|
||||
pct != null ? Math.min(100, Math.max(0, Math.round(pct))) : null;
|
||||
const clamped = pct != null ? Math.min(100, Math.max(0, Math.round(pct))) : null;
|
||||
const dash = clamped != null ? (clamped / 100) * circumference : 0;
|
||||
|
||||
return (
|
||||
<Box style={{ position: "relative", width: size, height: size, flexShrink: 0 }}>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
style={{ transform: "rotate(-90deg)", display: "block" }}
|
||||
>
|
||||
<svg width={size} height={size} style={{ transform: "rotate(-90deg)", display: "block" }}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.18)"
|
||||
stroke="var(--mantine-color-gray-2)"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
{clamped != null ? (
|
||||
@@ -226,7 +179,7 @@ function MiniDonut({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -241,7 +194,7 @@ function HeroStat({
|
||||
value,
|
||||
hint,
|
||||
ratio,
|
||||
ratioColor = "white",
|
||||
ratioColor = "var(--mantine-color-green-6)",
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
@@ -252,29 +205,19 @@ function HeroStat({
|
||||
}) {
|
||||
const pct = ratio != null ? Math.round(Math.min(1, Math.max(0, ratio)) * 100) : null;
|
||||
return (
|
||||
<Paper
|
||||
p="md"
|
||||
radius="lg"
|
||||
style={{
|
||||
flex: "1 1 180px",
|
||||
minWidth: 160,
|
||||
background: "rgba(255,255,255,0.12)",
|
||||
border: "1px solid rgba(255,255,255,0.18)",
|
||||
backdropFilter: "blur(6px)",
|
||||
}}
|
||||
>
|
||||
<Paper p="md" radius="lg" style={{ flex: "1 1 180px", minWidth: 160, ...CARD_STYLE }}>
|
||||
<Group gap="md" wrap="nowrap" align="center">
|
||||
<MiniDonut pct={pct} color={ratioColor}>
|
||||
<Icon size={19} />
|
||||
</MiniDonut>
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} tt="uppercase" c="rgba(255,255,255,0.78)" style={{ letterSpacing: 0.4 }}>
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.4 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={700} size="28px" c="white" lh={1}>
|
||||
<Text fw={700} size="28px" lh={1} style={{ color: "#0f172a" }}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="xs" c="rgba(255,255,255,0.72)" truncate>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{pct != null ? `${pct}% of queue` : hint}
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -288,20 +231,12 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
|
||||
const total = segments.reduce((sum, s) => sum + s.count, 0);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="md"
|
||||
radius="lg"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.12)",
|
||||
border: "1px solid rgba(255,255,255,0.18)",
|
||||
backdropFilter: "blur(6px)",
|
||||
}}
|
||||
>
|
||||
<Paper p="md" radius="lg" style={CARD_STYLE}>
|
||||
<Group justify="space-between" mb={10}>
|
||||
<Text size="sm" fw={700} c="white">
|
||||
<Text size="sm" fw={700} style={{ color: "#0f172a" }}>
|
||||
Booking pipeline
|
||||
</Text>
|
||||
<Text size="xs" c="rgba(255,255,255,0.75)">
|
||||
<Text size="xs" c="dimmed">
|
||||
{total} active
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -312,7 +247,7 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
|
||||
height: 14,
|
||||
borderRadius: 999,
|
||||
overflow: "hidden",
|
||||
background: "rgba(255,255,255,0.18)",
|
||||
background: "var(--mantine-color-gray-2)",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
@@ -322,11 +257,7 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
|
||||
<Box
|
||||
key={s.key}
|
||||
title={`${s.label}: ${s.count}`}
|
||||
style={{
|
||||
width: `${(s.count / total) * 100}%`,
|
||||
background: s.color,
|
||||
transition: "width 200ms ease",
|
||||
}}
|
||||
style={{ width: `${(s.count / total) * 100}%`, background: s.color, transition: "width 200ms ease" }}
|
||||
/>
|
||||
) : null,
|
||||
)
|
||||
@@ -339,10 +270,10 @@ function PipelineBar({ tabs }: { tabs: BookingListSummaryTabs }) {
|
||||
{segments.map((s) => (
|
||||
<Group key={s.key} gap={6} wrap="nowrap">
|
||||
<Box style={{ width: 9, height: 9, borderRadius: 3, background: s.color }} />
|
||||
<Text size="xs" c="rgba(255,255,255,0.85)">
|
||||
<Text size="xs" c="dimmed">
|
||||
{s.label}
|
||||
</Text>
|
||||
<Text size="xs" fw={700} c="white">
|
||||
<Text size="xs" fw={700} style={{ color: "#0f172a" }}>
|
||||
{s.count}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Wallet,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import { Box, Button, Group, Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import { Box, Button, Group, Paper, Stack, Text, ThemeIcon, Title } from "@mantine/core";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
@@ -18,12 +18,9 @@ import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
import { formatDate } from "./booking-detail.styles";
|
||||
|
||||
const HERO_GRADIENT = `linear-gradient(135deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 48%, ${freightBrand.primaryLight} 120%)`;
|
||||
|
||||
export interface BookingRequestHeroProps {
|
||||
booking: BookingDetail;
|
||||
customerLabel: string;
|
||||
@@ -55,40 +52,25 @@ export function BookingRequestHero({
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: HERO_GRADIENT,
|
||||
boxShadow: freightBrand.shadow,
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -110,
|
||||
right: -50,
|
||||
width: 300,
|
||||
height: 300,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.10)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Button
|
||||
variant="white"
|
||||
color="white"
|
||||
c="white"
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={onBack}
|
||||
style={{ background: "rgba(255,255,255,0.15)", border: "1px solid rgba(255,255,255,0.25)" }}
|
||||
>
|
||||
Back to list
|
||||
</Button>
|
||||
<Button
|
||||
variant="white"
|
||||
c="green.8"
|
||||
variant="light"
|
||||
color="green"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<RefreshCw size={15} />}
|
||||
@@ -101,11 +83,11 @@ export function BookingRequestHero({
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
|
||||
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="xs" c="rgba(255,255,255,0.78)" fw={700} tt="uppercase" style={{ letterSpacing: 1 }}>
|
||||
<Text size="xs" c="green.7" fw={700} tt="uppercase" style={{ letterSpacing: 1 }}>
|
||||
Booking reference
|
||||
</Text>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={2} c="white" fw={700} style={{ letterSpacing: "-0.4px" }}>
|
||||
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px", color: "#0f172a" }}>
|
||||
{booking.reference}
|
||||
</Title>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
@@ -116,7 +98,7 @@ export function BookingRequestHero({
|
||||
</Group>
|
||||
|
||||
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
|
||||
<Text size="xs" c="rgba(255,255,255,0.85)">
|
||||
<Text size="xs" c="orange.7">
|
||||
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -130,7 +112,12 @@ export function BookingRequestHero({
|
||||
</Group>
|
||||
|
||||
{booking.nextStep ? (
|
||||
<Paper radius="lg" p={4} style={{ background: "rgba(255,255,255,0.92)" }} maw={640}>
|
||||
<Paper
|
||||
radius="lg"
|
||||
p={4}
|
||||
maw={640}
|
||||
style={{ background: "var(--mantine-color-gray-0)", border: "1px solid var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<NextStepBanner nextStep={booking.nextStep} />
|
||||
</Paper>
|
||||
) : null}
|
||||
@@ -143,19 +130,22 @@ export function BookingRequestHero({
|
||||
minimumFractionDigits: 2,
|
||||
})}`}
|
||||
hint={booking.paymentStatus}
|
||||
accent="green"
|
||||
/>
|
||||
<HeroTile icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" />
|
||||
<HeroTile icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" accent="blue" />
|
||||
<HeroTile
|
||||
icon={ContainerIcon}
|
||||
label="Containers"
|
||||
value={containerCount || "—"}
|
||||
hint={`${containers.length} line${containers.length === 1 ? "" : "s"}`}
|
||||
accent="teal"
|
||||
/>
|
||||
<HeroTile
|
||||
icon={Flame}
|
||||
label="Priority score"
|
||||
value={booking.priorityScore ?? 0}
|
||||
hint={booking.tradeDirection}
|
||||
accent="orange"
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -174,8 +164,8 @@ function MetaItem({
|
||||
}) {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Icon size={14} color="rgba(255,255,255,0.8)" />
|
||||
<Text size="sm" fw={strong ? 600 : 400} c={strong ? "white" : "rgba(255,255,255,0.85)"}>
|
||||
<Icon size={14} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" fw={strong ? 600 : 400} c={strong ? "dark" : "dimmed"}>
|
||||
{text}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -187,11 +177,13 @@ function HeroTile({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
accent = "green",
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: ReactNode;
|
||||
accent?: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper
|
||||
@@ -200,36 +192,23 @@ function HeroTile({
|
||||
style={{
|
||||
flex: "1 1 160px",
|
||||
minWidth: 150,
|
||||
background: "rgba(255,255,255,0.12)",
|
||||
border: "1px solid rgba(255,255,255,0.18)",
|
||||
backdropFilter: "blur(6px)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
background: "rgba(255,255,255,0.18)",
|
||||
color: "white",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ThemeIcon size={36} radius="md" variant="light" color={accent}>
|
||||
<Icon size={18} />
|
||||
</Box>
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} tt="uppercase" c="rgba(255,255,255,0.78)" style={{ letterSpacing: 0.4 }}>
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.4 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={700} size="lg" c="white" lh={1.1} style={{ whiteSpace: "nowrap" }}>
|
||||
<Text fw={700} size="lg" lh={1.1} style={{ whiteSpace: "nowrap", color: "#0f172a" }}>
|
||||
{value}
|
||||
</Text>
|
||||
{hint ? (
|
||||
<Text size="xs" c="rgba(255,255,255,0.7)" truncate>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{hint}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@@ -5,13 +5,12 @@ import {
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Activity, RefreshCw } from "lucide-react";
|
||||
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
import type { OverviewRange } from "@/types/overview";
|
||||
import "./overview.css";
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ label: "7 days", value: "7d" },
|
||||
@@ -19,8 +18,6 @@ const RANGE_OPTIONS = [
|
||||
{ label: "90 days", value: "90d" },
|
||||
];
|
||||
|
||||
const HERO_GRADIENT = `linear-gradient(125deg, ${freightBrand.primaryDark} 0%, ${freightBrand.primary} 50%, ${freightBrand.primaryLight} 125%)`;
|
||||
|
||||
function formatRelativeTime(iso: string | undefined) {
|
||||
if (!iso) return "—";
|
||||
const diffMs = Date.now() - new Date(iso).getTime();
|
||||
@@ -32,7 +29,7 @@ function formatRelativeTime(iso: string | undefined) {
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
/** Decorative line-art locomotive + rails, sits faintly on the right of the hero. */
|
||||
/** Decorative line-art locomotive + rails — faint brand tint on the right. */
|
||||
function TrainArtwork() {
|
||||
return (
|
||||
<Box
|
||||
@@ -43,34 +40,23 @@ function TrainArtwork() {
|
||||
bottom: -8,
|
||||
width: 360,
|
||||
height: 200,
|
||||
opacity: 0.16,
|
||||
opacity: 0.06,
|
||||
pointerEvents: "none",
|
||||
color: "white",
|
||||
color: "var(--mantine-color-green-7)",
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 360 200" fill="none" width="100%" height="100%">
|
||||
{/* rails */}
|
||||
<path d="M0 168 H360" stroke="currentColor" strokeWidth="2" strokeDasharray="2 10" strokeLinecap="round" />
|
||||
<path d="M0 180 H360" stroke="currentColor" strokeWidth="2" />
|
||||
{/* locomotive body */}
|
||||
<path
|
||||
d="M70 60 H250 a14 14 0 0 1 14 14 V150 H56 V94 a34 34 0 0 1 14-28 Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
{/* cab windows */}
|
||||
<path d="M70 60 H250 a14 14 0 0 1 14 14 V150 H56 V94 a34 34 0 0 1 14-28 Z" stroke="currentColor" strokeWidth="3" />
|
||||
<rect x="84" y="80" width="40" height="30" rx="6" stroke="currentColor" strokeWidth="3" />
|
||||
<rect x="140" y="80" width="44" height="30" rx="6" stroke="currentColor" strokeWidth="3" />
|
||||
<rect x="200" y="80" width="44" height="30" rx="6" stroke="currentColor" strokeWidth="3" />
|
||||
{/* lower stripe */}
|
||||
<path d="M56 130 H264" stroke="currentColor" strokeWidth="3" />
|
||||
{/* wheels */}
|
||||
<circle cx="96" cy="150" r="16" stroke="currentColor" strokeWidth="3" />
|
||||
<circle cx="150" cy="150" r="16" stroke="currentColor" strokeWidth="3" />
|
||||
<circle cx="214" cy="150" r="16" stroke="currentColor" strokeWidth="3" />
|
||||
{/* coupling */}
|
||||
<path d="M264 120 H300 a8 8 0 0 1 8 8 V150 H300" stroke="currentColor" strokeWidth="3" />
|
||||
{/* headlight beam */}
|
||||
<path d="M56 100 l-28 -10 M56 112 l-30 0 M56 124 l-28 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</Box>
|
||||
@@ -98,50 +84,28 @@ export function OverviewPageHeader({
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
borderRadius: 20,
|
||||
padding: "28px 28px",
|
||||
background: HERO_GRADIENT,
|
||||
boxShadow: freightBrand.shadow,
|
||||
padding: "26px 28px",
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
|
||||
}}
|
||||
>
|
||||
{/* decorative glows */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -120,
|
||||
right: 120,
|
||||
width: 280,
|
||||
height: 280,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.10)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<TrainArtwork />
|
||||
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="lg" style={{ position: "relative" }}>
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<Group gap={8} align="center">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
background: "rgba(255,255,255,0.2)",
|
||||
}}
|
||||
>
|
||||
<Activity size={16} color="white" />
|
||||
</Box>
|
||||
<Text size="xs" fw={700} c="rgba(255,255,255,0.85)" tt="uppercase" style={{ letterSpacing: 1.2 }}>
|
||||
<ThemeIcon size={28} radius="md" variant="light" color="green">
|
||||
<Activity size={16} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" fw={700} c="green.7" tt="uppercase" style={{ letterSpacing: 1.2 }}>
|
||||
Freight Backoffice · Live
|
||||
</Text>
|
||||
</Group>
|
||||
<Title order={1} c="white" style={{ letterSpacing: "-0.03em", fontSize: 34, lineHeight: 1.1 }}>
|
||||
<Title order={1} style={{ letterSpacing: "-0.03em", fontSize: 34, lineHeight: 1.1, color: "#0f172a" }}>
|
||||
Operations Overview
|
||||
</Title>
|
||||
<Text size="sm" c="rgba(255,255,255,0.85)">
|
||||
<Text size="sm" c="dimmed">
|
||||
Real-time freight performance · updated {formatRelativeTime(generatedAt)}
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -153,14 +117,10 @@ export function OverviewPageHeader({
|
||||
data={RANGE_OPTIONS}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
classNames={{
|
||||
root: "ov-seg-root",
|
||||
indicator: "ov-seg-indicator",
|
||||
label: "ov-seg-label",
|
||||
}}
|
||||
color="green"
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="white"
|
||||
variant="light"
|
||||
color="green"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
|
||||
@@ -2,19 +2,34 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Radio,
|
||||
RingProgress,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Container as ContainerIcon,
|
||||
Eye,
|
||||
Flame,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
Route as RouteIcon,
|
||||
Train,
|
||||
Wallet,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
useAvailableLocomotives,
|
||||
@@ -46,10 +61,16 @@ import { shouldShowContainerPlacementStep } from "./schedulingContainerStep.util
|
||||
import { FleetAvailabilitySummary } from "./FleetAvailabilitySummary";
|
||||
import { ScheduleBookingsStep } from "./ScheduleBookingsStep";
|
||||
import { PreviewSummary, ScheduleWarningsAlert } from "./ScheduleWarningsAlert";
|
||||
import { SchedulingWorkflowHeader } from "./SchedulingWorkflowHeader";
|
||||
import { schedulingWorkflow } from "./schedulingWorkflow.styles";
|
||||
import { SchedulingStatusBadge } from "./ScheduleStatusBadge";
|
||||
import { FreightTypeBadge, SchedulingStatusBadge } from "./ScheduleStatusBadge";
|
||||
import {
|
||||
RouteCorridor,
|
||||
StatTile,
|
||||
StatusPill,
|
||||
scheduleBrand,
|
||||
} from "./scheduleVisuals";
|
||||
import { TrainCompositionDiagram } from "./TrainCompositionDiagram";
|
||||
import { WagonPlanGrid } from "./WagonPlanGrid";
|
||||
import { WorkflowRail, WorkflowStep } from "./WorkflowStep";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
@@ -115,9 +136,17 @@ export function AllocateBookingWizard({
|
||||
const eligibleQuery = useEligibleBookings(eligibleFilters, opened);
|
||||
const schedulesQuery = useScheduleList();
|
||||
const routesQuery = useRoutes();
|
||||
const locomotivesQuery = useAvailableLocomotives();
|
||||
const locomotivesQuery = useAvailableLocomotives(
|
||||
scheduleMode === "new" && routeId ? routeId : undefined,
|
||||
);
|
||||
const { create, preview, assign, finalize } = useScheduleMutations(selectedScheduleId ?? undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (scheduleMode === "new") {
|
||||
setLocomotiveId("");
|
||||
}
|
||||
}, [routeId, scheduleMode]);
|
||||
|
||||
const matchingSchedules = useMemo(
|
||||
() =>
|
||||
(schedulesQuery.data ?? []).filter(
|
||||
@@ -157,13 +186,6 @@ export function AllocateBookingWizard({
|
||||
const previewFreightType = previewResult?.summary?.freightMode as FreightType | undefined;
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
|
||||
const stepLabels = [
|
||||
"Bookings",
|
||||
"Wagon plan",
|
||||
...(hasContainerStep ? ["Containers"] : []),
|
||||
"Finalize",
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setActiveStep(0);
|
||||
@@ -216,6 +238,21 @@ export function AllocateBookingWizard({
|
||||
[routesQuery.data],
|
||||
);
|
||||
|
||||
const displayWagonPlan = useMemo(() => {
|
||||
const savedWagons = assignedSchedule?.trainSet?.wagons ?? [];
|
||||
const physicalBySeq = new Map(
|
||||
savedWagons.map((w) => [w.sequenceNo, w.physicalWagonNumber ?? null]),
|
||||
);
|
||||
if (previewResult?.wagonPlan?.length) {
|
||||
return previewResult.wagonPlan.map((slot) => ({
|
||||
...slot,
|
||||
physicalWagonNumber: physicalBySeq.get(slot.sequenceNo) ?? null,
|
||||
}));
|
||||
}
|
||||
if (savedWagons.length) return savedWagons;
|
||||
return [];
|
||||
}, [previewResult?.wagonPlan, assignedSchedule?.trainSet?.wagons]);
|
||||
|
||||
const ensureSchedule = async (): Promise<string> => {
|
||||
if (scheduleMode === "existing" && selectedScheduleId) return selectedScheduleId;
|
||||
if (!routeId || !scheduleDate || !locomotiveId) {
|
||||
@@ -357,305 +394,607 @@ export function AllocateBookingWizard({
|
||||
}
|
||||
};
|
||||
|
||||
const amount = Number(booking.totalAmount);
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const holdCountdown = formatCountdown(booking.holdExpiresAt);
|
||||
|
||||
const stepDescription =
|
||||
activeStep === 0
|
||||
? "Select & preview"
|
||||
: activeStep === 1
|
||||
? "Allocations"
|
||||
: hasContainerStep && activeStep === 2
|
||||
? "Map units"
|
||||
: "Depart";
|
||||
const containerComplete =
|
||||
hasContainerStep &&
|
||||
containerUnits.length > 0 &&
|
||||
validateLocalPlacements(containerUnits, containerPlacements).length === 0;
|
||||
|
||||
const stepIcon =
|
||||
activeStep === 0
|
||||
? "package"
|
||||
: activeStep === 1
|
||||
? "layout"
|
||||
: hasContainerStep && activeStep === 2
|
||||
? "container"
|
||||
: "check";
|
||||
const stepsMeta = [
|
||||
{
|
||||
key: "bookings",
|
||||
icon: Package,
|
||||
title: "Bookings",
|
||||
subtitle: "Select cargo & preview the plan",
|
||||
complete: Boolean(previewResult) || Boolean(assignedSchedule),
|
||||
},
|
||||
{
|
||||
key: "wagon",
|
||||
icon: LayoutGrid,
|
||||
title: "Wagon plan",
|
||||
subtitle: "Review generated allocations",
|
||||
complete: displayWagonPlan.length > 0,
|
||||
},
|
||||
...(hasContainerStep
|
||||
? [
|
||||
{
|
||||
key: "container",
|
||||
icon: ContainerIcon,
|
||||
title: "Containers",
|
||||
subtitle: "Map units to wagon slots",
|
||||
complete: containerComplete,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "finalize",
|
||||
icon: CheckCircle2,
|
||||
title: "Finalize",
|
||||
subtitle: "Lock the plan & dispatch",
|
||||
complete: allocationComplete,
|
||||
},
|
||||
];
|
||||
const completedCount = stepsMeta.filter((s) => s.complete).length;
|
||||
const progressPct = Math.round((completedCount / stepsMeta.length) * 100);
|
||||
const toggleStep = (i: number) => setActiveStep((cur) => (cur === i ? -1 : i));
|
||||
|
||||
const renderStepRightSlot = (key: string) => {
|
||||
if (key === "bookings") {
|
||||
if (previewResult) {
|
||||
return (
|
||||
<Badge variant="light" color={previewResult.valid ? "green" : "red"} radius="sm">
|
||||
{previewResult.valid ? "Plan valid" : "Has issues"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return allBookingIds.length ? (
|
||||
<Badge variant="light" color="green" radius="sm">
|
||||
{allBookingIds.length} selected
|
||||
</Badge>
|
||||
) : null;
|
||||
}
|
||||
if (key === "wagon" && displayWagonPlan.length) {
|
||||
return (
|
||||
<Badge variant="light" color="green" radius="sm">
|
||||
{displayWagonPlan.length} wagons
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (key === "container" && containerUnits.length) {
|
||||
return (
|
||||
<Badge variant="light" color={containerComplete ? "green" : "yellow"} radius="sm">
|
||||
{containerUnits.length} units
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (key === "finalize" && allocationComplete) {
|
||||
return <StatusPill status="SCHEDULED" />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderStepBody = (key: string) => {
|
||||
if (key === "bookings") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Paper p="md" radius="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} size="sm">
|
||||
Train schedule
|
||||
</Text>
|
||||
<Radio.Group
|
||||
value={scheduleMode}
|
||||
onChange={(v) => setScheduleMode(v as "existing" | "new")}
|
||||
>
|
||||
<Group gap="lg">
|
||||
<Radio value="existing" label="Use existing draft schedule" />
|
||||
<Radio value="new" label="Create new schedule" />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
|
||||
{scheduleMode === "existing" ? (
|
||||
<Select
|
||||
label="Draft schedule"
|
||||
data={matchingSchedules.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.routeName ?? "Schedule"} · ${new Date(s.scheduleDate).toLocaleDateString()} · ${s.freightType ?? "MIXED"}`,
|
||||
}))}
|
||||
value={selectedScheduleId}
|
||||
onChange={setSelectedScheduleId}
|
||||
searchable
|
||||
/>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<Select
|
||||
label="Route"
|
||||
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
|
||||
value={routeId || null}
|
||||
onChange={(v) => setRouteId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Locomotive"
|
||||
placeholder={routeId ? "Select locomotive" : "Select a route first"}
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: `${l.code} · ${
|
||||
l.readiness === "EXPORT_READY" ? "Export-ready" : "Import-ready"
|
||||
}`,
|
||||
}))}
|
||||
value={locomotiveId || null}
|
||||
onChange={(v) => setLocomotiveId(v ?? "")}
|
||||
searchable
|
||||
disabled={!routeId}
|
||||
nothingFoundMessage={
|
||||
routeId ? "No available locomotives for this corridor" : "Select a route first"
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{holdCountdown ? (
|
||||
<Text size="xs" c={holdCountdown.includes("expired") ? "red" : "yellow.8"}>
|
||||
Hold window: {holdCountdown}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<ScheduleBookingsStep
|
||||
assignedBookings={(assignedSchedule?.bookings ?? []).map((b) => ({
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
weightTons: b.weightTons,
|
||||
}))}
|
||||
eligibleItems={eligibleQuery.data?.items ?? []}
|
||||
eligibleLoading={eligibleQuery.isLoading}
|
||||
selectedIds={allBookingIds}
|
||||
onSelectionChange={(ids) => {
|
||||
setExtraBookingIds(ids.filter((id) => id !== booking.id));
|
||||
}}
|
||||
freightType={bookingFreightType}
|
||||
/>
|
||||
|
||||
<Group
|
||||
align="center"
|
||||
justify="space-between"
|
||||
wrap="wrap"
|
||||
gap="md"
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
label="Force assign (bypass hold / overweight warnings)"
|
||||
checked={forceAssign}
|
||||
onChange={(e) => setForceAssign(e.currentTarget.checked)}
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
leftSection={<Eye size={16} />}
|
||||
loading={preview.isPending}
|
||||
onClick={handlePreview}
|
||||
>
|
||||
Preview plan
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{previewResult ? (
|
||||
<Stack gap="sm">
|
||||
<ScheduleWarningsAlert
|
||||
violations={previewResult.violations}
|
||||
warnings={previewResult.warnings}
|
||||
/>
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult.fleetAvailability}
|
||||
deferredBookings={previewResult.deferredBookings}
|
||||
/>
|
||||
<PreviewSummary summary={previewResult.summary} />
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (key === "wagon") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{!displayWagonPlan.length && !previewResult ? (
|
||||
<Paper p="md" radius="lg" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Run a preview from the Bookings step to generate the wagon plan.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : null}
|
||||
{reschedulePlan?.displaced.length ? (
|
||||
<Paper p="md" radius="lg" withBorder style={{ borderColor: "var(--mantine-color-orange-2)", background: "var(--mantine-color-orange-0)" }}>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} size="sm" c="orange.8">
|
||||
Government preempt — bookings to displace
|
||||
</Text>
|
||||
{reschedulePlan.displaced.map((b) => (
|
||||
<Text key={b.id} size="sm">
|
||||
{b.reference} (priority {b.priorityScore})
|
||||
</Text>
|
||||
))}
|
||||
<Checkbox
|
||||
label="I confirm displacing the bookings listed above"
|
||||
checked={confirmPreempt}
|
||||
onChange={(e) => setConfirmPreempt(e.currentTarget.checked)}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
<ScheduleWarningsAlert
|
||||
violations={previewResult?.violations}
|
||||
warnings={previewResult?.warnings}
|
||||
/>
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult?.fleetAvailability}
|
||||
deferredBookings={previewResult?.deferredBookings}
|
||||
/>
|
||||
<WagonPlanGrid
|
||||
wagonPlan={displayWagonPlan}
|
||||
freightType={previewFreightType ?? bookingFreightType}
|
||||
/>
|
||||
<Group>
|
||||
{!hasContainerStep ? (
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
loading={assign.isPending || create.isPending}
|
||||
onClick={handleAssign}
|
||||
>
|
||||
Assign bookings
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
rightSection={<ContainerIcon size={16} />}
|
||||
onClick={() => setActiveStep(2)}
|
||||
>
|
||||
Continue to containers
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" radius="md" onClick={handlePreview}>
|
||||
Refresh preview
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (key === "container") {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{!containerUnits.length ? (
|
||||
<Paper p="md" radius="lg" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Run preview from the Bookings step to load container units for numbering.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<ContainerPlacementGrid
|
||||
units={containerUnits}
|
||||
containerSlots={containerSlots}
|
||||
placements={containerPlacements}
|
||||
onChange={setContainerPlacements}
|
||||
/>
|
||||
)}
|
||||
<Group>
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
loading={assign.isPending || create.isPending}
|
||||
onClick={handleAssign}
|
||||
>
|
||||
Assign bookings
|
||||
</Button>
|
||||
<Button variant="default" radius="md" onClick={() => setActiveStep(finalizeStep)}>
|
||||
Skip to finalize
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// finalize
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{displayWagonPlan.length || assignedSchedule?.trainSet?.wagons?.length ? (
|
||||
<TrainCompositionDiagram
|
||||
locomotive={assignedSchedule?.trainSet?.locomotive}
|
||||
wagons={
|
||||
assignedSchedule?.trainSet?.wagons?.length
|
||||
? assignedSchedule.trainSet.wagons
|
||||
: displayWagonPlan
|
||||
}
|
||||
freightType={previewFreightType ?? bookingFreightType}
|
||||
trainNumber={assignedSchedule?.trainNumber}
|
||||
totalLengthMeters={assignedSchedule?.trainSet?.totalLengthMeters}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{allocationComplete ? (
|
||||
<Paper
|
||||
p="lg"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ background: scheduleBrand.softSurface, borderColor: scheduleBrand.mutedBorder }}
|
||||
>
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon size={44} radius="md" variant="light" color="green">
|
||||
<CheckCircle2 size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
<Text fw={700} size="lg">
|
||||
Allocation complete
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Booking {booking.reference} is scheduled on train{" "}
|
||||
<Text span fw={600} c="green.7">
|
||||
{assignedSchedule?.trainSet?.locomotive?.code ?? "—"}
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
<Group mt="sm">
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
if (assignedSchedule?.id) {
|
||||
navigate(
|
||||
`/dashboard/operations/train-scheduling-v2/${assignedSchedule.id}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
View schedule
|
||||
</Button>
|
||||
<Button variant="default" radius="md" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : (
|
||||
<>
|
||||
<Paper
|
||||
p="lg"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ background: scheduleBrand.softSurface, borderColor: scheduleBrand.mutedBorder }}
|
||||
>
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon size={44} radius="md" variant="light" color="green">
|
||||
<CheckCircle2 size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600}>Ready to finalize</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Finalizing locks the plan, moves the schedule to{" "}
|
||||
<Text span fw={600} c="green.7">
|
||||
SCHEDULED
|
||||
</Text>
|
||||
, and completes the booking allocation.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Group>
|
||||
<Button
|
||||
color="green"
|
||||
size="md"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={18} />}
|
||||
loading={finalize.isPending}
|
||||
onClick={handleFinalize}
|
||||
>
|
||||
Finalize schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Text fw={600}>Allocate booking — {booking.reference}</Text>}
|
||||
withCloseButton
|
||||
size="90%"
|
||||
radius="xl"
|
||||
radius="lg"
|
||||
centered
|
||||
styles={{ content: { maxWidth: 1200 } }}
|
||||
padding="lg"
|
||||
styles={{ content: { maxWidth: 1200 }, body: { paddingTop: 8 } }}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<SchedulingWorkflowHeader
|
||||
title="Allocation workflow"
|
||||
subtitle={`${booking.reference} · ${booking.originYard?.name ?? "Origin"} → ${booking.destinationYard?.name ?? "Destination"}`}
|
||||
activeStep={activeStep}
|
||||
totalSteps={stepLabels.length}
|
||||
stepLabel={stepLabels[activeStep] ?? ""}
|
||||
stepDescription={stepDescription}
|
||||
stepIcon={stepIcon}
|
||||
/>
|
||||
|
||||
<Stepper
|
||||
active={activeStep}
|
||||
onStepClick={setActiveStep}
|
||||
color={schedulingWorkflow.stepper.color}
|
||||
iconSize={schedulingWorkflow.stepper.iconSize}
|
||||
size={schedulingWorkflow.stepper.size}
|
||||
{/* Hero */}
|
||||
<Paper
|
||||
radius="xl"
|
||||
p="xl"
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: scheduleBrand.heroGradient,
|
||||
boxShadow: scheduleBrand.shadow,
|
||||
}}
|
||||
>
|
||||
<Stepper.Step label="Bookings" description="Select & preview">
|
||||
<Stack gap="md" mt="lg">
|
||||
<Card withBorder padding="md" radius="xl">
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{booking.reference}</Text>
|
||||
<SchedulingStatusBadge status={booking.schedulingStatus} />
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -90,
|
||||
right: -50,
|
||||
width: 280,
|
||||
height: 280,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.10)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={56}
|
||||
radius="lg"
|
||||
variant="white"
|
||||
style={{ color: "var(--mantine-color-green-7)" }}
|
||||
>
|
||||
<Train size={28} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={6}>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={2} c="white" fw={700}>
|
||||
Allocate {booking.reference}
|
||||
</Title>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{booking.freightType} · {booking.cargoTotalWeightVgm}T
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{booking.originYard?.name ?? "Origin"} →{" "}
|
||||
{booking.destinationYard?.name ?? "Destination"}
|
||||
</Text>
|
||||
{booking.freightType === "CONTAINER" && booking.bookingContainers?.length ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{booking.bookingContainers.map((c) => `${c.quantity}× container`).join(", ")}
|
||||
</Text>
|
||||
) : null}
|
||||
{holdCountdown ? (
|
||||
<Text size="sm" c={holdCountdown.includes("expired") ? "red" : "yellow"}>
|
||||
Hold window: {holdCountdown}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Paper p="md" radius="xl" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text fw={600} size="sm">
|
||||
Train schedule
|
||||
</Text>
|
||||
<Radio.Group
|
||||
value={scheduleMode}
|
||||
onChange={(v) => setScheduleMode(v as "existing" | "new")}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Radio value="existing" label="Use existing draft schedule" />
|
||||
<Radio value="new" label="Create new schedule" />
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
|
||||
{scheduleMode === "existing" ? (
|
||||
<Select
|
||||
label="Draft schedule"
|
||||
data={matchingSchedules.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.routeName ?? "Schedule"} · ${new Date(s.scheduleDate).toLocaleDateString()} · ${s.freightType ?? "MIXED"}`,
|
||||
}))}
|
||||
value={selectedScheduleId}
|
||||
onChange={setSelectedScheduleId}
|
||||
searchable
|
||||
<Box maw={360}>
|
||||
<RouteCorridor
|
||||
onDark
|
||||
origin={booking.originYard?.name ?? booking.originYard?.label}
|
||||
destination={
|
||||
booking.destinationYard?.name ?? booking.destinationYard?.label
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label="Route"
|
||||
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
|
||||
value={routeId || null}
|
||||
onChange={(v) => setRouteId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Locomotive"
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: l.code,
|
||||
}))}
|
||||
value={locomotiveId || null}
|
||||
onChange={(v) => setLocomotiveId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
<Group gap="sm" align="center">
|
||||
<FreightTypeBadge freightType={booking.freightType} />
|
||||
{booking.schedulingStatus ? (
|
||||
<SchedulingStatusBadge status={booking.schedulingStatus} />
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<ScheduleBookingsStep
|
||||
assignedBookings={(assignedSchedule?.bookings ?? []).map((b) => ({
|
||||
id: b.id,
|
||||
reference: b.reference ?? b.id.slice(0, 8),
|
||||
weightTons: b.weightTons,
|
||||
}))}
|
||||
eligibleItems={eligibleQuery.data?.items ?? []}
|
||||
eligibleLoading={eligibleQuery.isLoading}
|
||||
selectedIds={allBookingIds}
|
||||
onSelectionChange={(ids) => {
|
||||
setExtraBookingIds(ids.filter((id) => id !== booking.id));
|
||||
}}
|
||||
freightType={bookingFreightType}
|
||||
/>
|
||||
|
||||
<Group align="center" wrap="wrap">
|
||||
<Button loading={preview.isPending} onClick={handlePreview}>
|
||||
Preview plan
|
||||
</Button>
|
||||
<Checkbox
|
||||
label="Force assign (bypass hold/overweight warnings)"
|
||||
checked={forceAssign}
|
||||
onChange={(e) => setForceAssign(e.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{previewResult ? (
|
||||
<Stack gap="sm">
|
||||
<ScheduleWarningsAlert
|
||||
violations={previewResult.violations}
|
||||
warnings={previewResult.warnings}
|
||||
/>
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult.fleetAvailability}
|
||||
deferredBookings={previewResult.deferredBookings}
|
||||
/>
|
||||
<PreviewSummary summary={previewResult.summary} />
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step label="Wagon plan" description="Allocations">
|
||||
<Stack gap="md" mt="lg">
|
||||
<ScheduleWarningsAlert
|
||||
violations={previewResult?.violations}
|
||||
warnings={previewResult?.warnings}
|
||||
/>
|
||||
{reschedulePlan?.displaced.length ? (
|
||||
<Card withBorder padding="md" radius="xl">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} size="sm" c="orange">
|
||||
Government preempt — bookings to displace
|
||||
</Text>
|
||||
{reschedulePlan.displaced.map((b) => (
|
||||
<Text key={b.id} size="sm">
|
||||
{b.reference} (priority {b.priorityScore})
|
||||
</Text>
|
||||
))}
|
||||
<Checkbox
|
||||
label="I confirm displacing the bookings listed above"
|
||||
checked={confirmPreempt}
|
||||
onChange={(e) => setConfirmPreempt(e.currentTarget.checked)}
|
||||
<Badge
|
||||
size="lg"
|
||||
radius="sm"
|
||||
variant="white"
|
||||
c={previewResult.valid ? "green.8" : "red.7"}
|
||||
leftSection={
|
||||
<Box
|
||||
w={8}
|
||||
h={8}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: previewResult.valid
|
||||
? "var(--mantine-color-green-6)"
|
||||
: "var(--mantine-color-red-6)",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
Preview {previewResult.valid ? "valid" : "has issues"}
|
||||
</Badge>
|
||||
) : null}
|
||||
<PreviewSummary summary={previewResult?.summary} />
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult?.fleetAvailability}
|
||||
deferredBookings={previewResult?.deferredBookings}
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<StatTile
|
||||
onDark
|
||||
icon={Wallet}
|
||||
label="Total value"
|
||||
value={`${booking.paymentCurrency} ${amount.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
})}`}
|
||||
hint={booking.paymentStatus}
|
||||
/>
|
||||
<WagonPlanGrid
|
||||
wagonPlan={previewResult?.wagonPlan ?? []}
|
||||
freightType={previewFreightType ?? bookingFreightType}
|
||||
<StatTile onDark icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" />
|
||||
<StatTile
|
||||
onDark
|
||||
icon={ContainerIcon}
|
||||
label="Containers"
|
||||
value={containerCount || "—"}
|
||||
hint={`${containers.length} line${containers.length === 1 ? "" : "s"}`}
|
||||
/>
|
||||
<Group>
|
||||
{!hasContainerStep ? (
|
||||
<Button color="teal" loading={assign.isPending || create.isPending} onClick={handleAssign}>
|
||||
Assign bookings
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="light" onClick={() => setActiveStep(2)}>
|
||||
Continue to containers
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" onClick={handlePreview}>
|
||||
Refresh preview
|
||||
</Button>
|
||||
<StatTile
|
||||
onDark
|
||||
icon={Flame}
|
||||
label="Priority"
|
||||
value={booking.priorityScore ?? 0}
|
||||
hint={booking.tradeDirection}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Workflow */}
|
||||
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={44}
|
||||
radius="md"
|
||||
variant="gradient"
|
||||
gradient={{ from: "green", to: "teal", deg: 135 }}
|
||||
>
|
||||
<RouteIcon size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Title order={4} fw={700}>
|
||||
Allocation workflow
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{completedCount} of {stepsMeta.length} steps complete · expand any step
|
||||
to edit
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
<RingProgress
|
||||
size={64}
|
||||
thickness={6}
|
||||
roundCaps
|
||||
sections={[{ value: progressPct, color: "green" }]}
|
||||
label={
|
||||
<Text ta="center" size="xs" fw={700} c="green.7">
|
||||
{progressPct}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{hasContainerStep ? (
|
||||
<Stepper.Step label="Containers" description="Map units">
|
||||
<Stack gap="md" mt="lg">
|
||||
{!containerUnits.length ? (
|
||||
<Paper p="md" radius="xl" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Run preview from the Bookings step to load container units for numbering.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<ContainerPlacementGrid
|
||||
units={containerUnits}
|
||||
containerSlots={containerSlots}
|
||||
placements={containerPlacements}
|
||||
onChange={setContainerPlacements}
|
||||
/>
|
||||
)}
|
||||
<Group>
|
||||
<Button color="teal" loading={assign.isPending || create.isPending} onClick={handleAssign}>
|
||||
Assign bookings
|
||||
</Button>
|
||||
<Button variant="light" onClick={() => setActiveStep(finalizeStep)}>
|
||||
Skip to finalize
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
) : null}
|
||||
|
||||
<Stepper.Step label="Finalize" description="Depart">
|
||||
<Stack gap="md" mt="lg">
|
||||
{allocationComplete ? (
|
||||
<Paper p="lg" radius="xl" withBorder bg="teal.0">
|
||||
<Stack gap="md" align="center">
|
||||
<CheckCircle2 size={40} color="var(--mantine-color-teal-7)" />
|
||||
<Text fw={700} size="lg">
|
||||
Allocation complete
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Booking {booking.reference} is scheduled on train{" "}
|
||||
{assignedSchedule?.trainSet?.locomotive?.code ?? "—"}.
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
color="teal"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
if (assignedSchedule?.id) {
|
||||
navigate(
|
||||
`/dashboard/operations/train-scheduling-v2/${assignedSchedule.id}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
View schedule
|
||||
</Button>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<>
|
||||
<Paper p="md" radius="xl" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Finalize moves the schedule to SCHEDULED and completes the booking
|
||||
allocation.
|
||||
</Text>
|
||||
</Paper>
|
||||
<Group>
|
||||
<Button color="teal" loading={finalize.isPending} onClick={handleFinalize}>
|
||||
Finalize schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
<WorkflowRail>
|
||||
{stepsMeta.map((step, index) => (
|
||||
<WorkflowStep
|
||||
key={step.key}
|
||||
index={index}
|
||||
icon={step.icon}
|
||||
title={step.title}
|
||||
subtitle={step.subtitle}
|
||||
state={
|
||||
activeStep === index
|
||||
? "active"
|
||||
: step.complete
|
||||
? "complete"
|
||||
: "upcoming"
|
||||
}
|
||||
open={activeStep === index}
|
||||
onToggle={() => toggleStep(index)}
|
||||
rightSlot={renderStepRightSlot(step.key)}
|
||||
>
|
||||
{renderStepBody(step.key)}
|
||||
</WorkflowStep>
|
||||
))}
|
||||
</WorkflowRail>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Fragment } from "react";
|
||||
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { Check, Flag, MapPin, Train } from "lucide-react";
|
||||
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
import type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling";
|
||||
|
||||
export interface RouteCorridorTrackProps {
|
||||
stations: TrackStation[];
|
||||
/** Highest sequenceNo reached so far (−1 = not yet departed). */
|
||||
currentSequenceNo: number;
|
||||
checkpoints: TrainCheckpoint[];
|
||||
/** True when the train is DISPATCHED and staff may log progress. */
|
||||
canLog: boolean;
|
||||
loggingSeq?: number | null;
|
||||
onLogCheckpoint?: (sequenceNo: number) => void;
|
||||
}
|
||||
|
||||
const COLUMN_WIDTH = 150;
|
||||
const PASSED = freightBrand.primary;
|
||||
const UPCOMING = "var(--mantine-color-gray-3)";
|
||||
|
||||
function railColor(active: boolean) {
|
||||
return active ? PASSED : UPCOMING;
|
||||
}
|
||||
|
||||
export function RouteCorridorTrack({
|
||||
stations,
|
||||
currentSequenceNo,
|
||||
checkpoints,
|
||||
canLog,
|
||||
loggingSeq,
|
||||
onLogCheckpoint,
|
||||
}: RouteCorridorTrackProps) {
|
||||
const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c]));
|
||||
const lastIndex = stations.length - 1;
|
||||
|
||||
return (
|
||||
<Box style={{ overflowX: "auto", paddingBottom: 4 }}>
|
||||
<Group
|
||||
gap={0}
|
||||
wrap="nowrap"
|
||||
align="flex-start"
|
||||
style={{ minWidth: stations.length * COLUMN_WIDTH }}
|
||||
>
|
||||
{stations.map((station, index) => {
|
||||
const passed = station.sequenceNo <= currentSequenceNo;
|
||||
const isCurrent = station.sequenceNo === currentSequenceNo;
|
||||
const isFinal = index === lastIndex;
|
||||
const isNext = canLog && station.sequenceNo === currentSequenceNo + 1;
|
||||
const checkpoint = bySeq.get(station.sequenceNo);
|
||||
// left rail solid once this node is reached; right rail solid once the next node is reached
|
||||
const leftActive = station.sequenceNo <= currentSequenceNo;
|
||||
const rightActive = station.sequenceNo + 1 <= currentSequenceNo;
|
||||
|
||||
return (
|
||||
<Fragment key={station.sequenceNo}>
|
||||
<Stack gap={6} align="center" style={{ width: COLUMN_WIDTH, flexShrink: 0 }}>
|
||||
{/* rail + node */}
|
||||
<Box style={{ position: "relative", height: 44, width: "100%" }}>
|
||||
{index > 0 && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 21,
|
||||
left: 0,
|
||||
width: "50%",
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
background: railColor(leftActive),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{index < lastIndex && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 21,
|
||||
left: "50%",
|
||||
width: "50%",
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
background: railColor(rightActive),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* train marker hovering over the current node */}
|
||||
{isCurrent && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
color: freightBrand.primaryDark,
|
||||
}}
|
||||
>
|
||||
<Train size={18} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* node */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: passed ? PASSED : "white",
|
||||
border: `2px solid ${
|
||||
passed
|
||||
? PASSED
|
||||
: isNext
|
||||
? freightBrand.primaryLight
|
||||
: "var(--mantine-color-gray-4)"
|
||||
}`,
|
||||
boxShadow: isCurrent ? `0 0 0 4px ${freightBrand.ring}` : "none",
|
||||
color: "white",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{passed ? (
|
||||
<Check size={13} />
|
||||
) : isFinal ? (
|
||||
<Flag size={12} color="var(--mantine-color-gray-5)" />
|
||||
) : (
|
||||
<MapPin size={12} color="var(--mantine-color-gray-5)" />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* label */}
|
||||
<Stack gap={0} align="center" style={{ minWidth: 0, padding: "0 6px" }}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={passed ? 700 : 600}
|
||||
ta="center"
|
||||
lineClamp={2}
|
||||
c={passed ? "green.8" : "dimmed"}
|
||||
>
|
||||
{station.label}
|
||||
</Text>
|
||||
{index === 0 ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
Origin
|
||||
</Badge>
|
||||
) : isFinal ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
Destination
|
||||
</Badge>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{/* checkpoint time or action */}
|
||||
{checkpoint ? (
|
||||
<Text size="10px" c="dimmed" ta="center">
|
||||
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</Text>
|
||||
) : isNext ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
radius="md"
|
||||
color={isFinal ? "teal" : "green"}
|
||||
variant={isFinal ? "filled" : "light"}
|
||||
loading={loggingSeq === station.sequenceNo}
|
||||
onClick={() => onLogCheckpoint?.(station.sequenceNo)}
|
||||
>
|
||||
{isFinal ? "Mark arrived" : "Log pass"}
|
||||
</Button>
|
||||
) : (
|
||||
<Box style={{ height: 22 }} />
|
||||
)}
|
||||
</Stack>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { CheckCircle2, Layers, Lock, LockOpen, PlayCircle, Repeat, XCircle } from "lucide-react";
|
||||
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import {
|
||||
useBatchActions,
|
||||
useBookableSchedules,
|
||||
} from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
|
||||
interface ScheduleBatchPanelProps {
|
||||
schedule: TrainScheduleDetail;
|
||||
}
|
||||
|
||||
const windowColor: Record<string, string> = {
|
||||
OPEN: "green",
|
||||
FULL: "orange",
|
||||
CLOSED: "gray",
|
||||
};
|
||||
|
||||
export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
|
||||
const { toast } = useToast();
|
||||
const actions = useBatchActions(schedule.id);
|
||||
const windowStatus = (schedule as { bookingWindowStatus?: string }).bookingWindowStatus ?? "OPEN";
|
||||
const locked = schedule.status === "DISPATCHED" || schedule.status === "ARRIVED";
|
||||
|
||||
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
|
||||
const [moveTarget, setMoveTarget] = useState<string | null>(null);
|
||||
|
||||
const { data: targets } = useBookableSchedules(
|
||||
schedule.originStation?.id,
|
||||
schedule.destinationStation?.id,
|
||||
);
|
||||
const moveOptions = useMemo(
|
||||
() =>
|
||||
(targets ?? [])
|
||||
.filter((s) => s.id !== schedule.id)
|
||||
.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.routeName ?? `${s.origin} → ${s.destination}`} · ${new Date(
|
||||
s.scheduleDate,
|
||||
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} free`,
|
||||
})),
|
||||
[targets, schedule.id],
|
||||
);
|
||||
|
||||
const bookings = schedule.bookings ?? [];
|
||||
|
||||
const run = (fn: Promise<unknown>, ok: string) =>
|
||||
fn
|
||||
.then(() => toast({ title: ok }))
|
||||
.catch(() => toast({ title: "Action failed", variant: "destructive" }));
|
||||
|
||||
return (
|
||||
<Paper radius="lg" withBorder p="lg" style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Group justify="space-between" align="center" mb="md" wrap="wrap">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={36} radius="md" variant="light" color="green">
|
||||
<Layers size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Batch allocation</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{bookings.length} allocated · {schedule.trainSet?.wagonCount ?? 0} wagons used
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Badge color={windowColor[windowStatus] ?? "gray"} variant="light" radius="sm" size="lg">
|
||||
Window: {windowStatus}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!locked && (
|
||||
<Group gap="sm" mb="md">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<PlayCircle size={15} />}
|
||||
loading={actions.runBatch.isPending}
|
||||
onClick={() => run(actions.runBatch.mutateAsync(schedule.id), "Batch fill run")}
|
||||
>
|
||||
Run batch fill
|
||||
</Button>
|
||||
{windowStatus === "CLOSED" ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
leftSection={<LockOpen size={15} />}
|
||||
loading={actions.setWindow.isPending}
|
||||
onClick={() =>
|
||||
run(
|
||||
actions.setWindow.mutateAsync({ id: schedule.id, status: "OPEN" }),
|
||||
"Window opened",
|
||||
)
|
||||
}
|
||||
>
|
||||
Open window
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
leftSection={<Lock size={15} />}
|
||||
loading={actions.setWindow.isPending}
|
||||
onClick={() =>
|
||||
run(
|
||||
actions.setWindow.mutateAsync({ id: schedule.id, status: "CLOSED" }),
|
||||
"Window closed",
|
||||
)
|
||||
}
|
||||
>
|
||||
Close window
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{bookings.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No bookings allocated yet. The batch cron fills this schedule by priority; paid bookings are
|
||||
assigned automatically.
|
||||
</Text>
|
||||
) : (
|
||||
<Table verticalSpacing="xs" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{bookings.map((b) => (
|
||||
<Table.Tr key={b.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{b.reference ?? b.id.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{b.customer ?? "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingStatusBadge status={b.status ?? ""} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{!locked && (
|
||||
<Group gap={6} justify="flex-end" wrap="nowrap">
|
||||
{b.status !== "PAID" && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<CheckCircle2 size={13} />}
|
||||
onClick={() => run(actions.markPaid.mutateAsync(b.id), "Marked paid")}
|
||||
>
|
||||
Mark paid
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
leftSection={<Repeat size={13} />}
|
||||
onClick={() => {
|
||||
setMoveBookingId(b.id);
|
||||
setMoveTarget(null);
|
||||
}}
|
||||
>
|
||||
Move
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<XCircle size={13} />}
|
||||
onClick={() => run(actions.expire.mutateAsync(b.id), "Reservation expired")}
|
||||
>
|
||||
Expire
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={Boolean(moveBookingId)}
|
||||
onClose={() => setMoveBookingId(null)}
|
||||
title="Move booking to another schedule"
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Target schedule (same route)"
|
||||
placeholder="Select an OPEN schedule"
|
||||
data={moveOptions}
|
||||
value={moveTarget}
|
||||
onChange={setMoveTarget}
|
||||
searchable
|
||||
nothingFoundMessage="No other open schedules on this route"
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setMoveBookingId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
disabled={!moveTarget}
|
||||
loading={actions.moveSchedule.isPending}
|
||||
onClick={() => {
|
||||
if (!moveBookingId || !moveTarget) return;
|
||||
run(
|
||||
actions.moveSchedule.mutateAsync({
|
||||
bookingId: moveBookingId,
|
||||
trainScheduleId: moveTarget,
|
||||
}),
|
||||
"Booking moved",
|
||||
).then(() => setMoveBookingId(null));
|
||||
}}
|
||||
>
|
||||
Move booking
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -115,7 +115,7 @@ export function StatTile({
|
||||
backdropFilter: "blur(6px)",
|
||||
}
|
||||
: {
|
||||
background: "white",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +43,15 @@ export const QUERY_KEYS = {
|
||||
ROOT: ["train-scheduling"] as const,
|
||||
eligible: (freightType?: string, filters?: TrainScheduleFilters) =>
|
||||
["train-scheduling", "eligible-bookings", freightType ?? "CONTAINER", filters ?? {}] as const,
|
||||
locomotives: () => ["train-scheduling", "locomotives"] as const,
|
||||
locomotives: (routeId?: string) =>
|
||||
["train-scheduling", "locomotives", routeId ?? "all"] as const,
|
||||
stations: () => ["train-scheduling", "stations"] as const,
|
||||
schedules: () => ["train-scheduling", "schedules"] as const,
|
||||
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
|
||||
track: (id: string) => ["train-scheduling", "track", id] as const,
|
||||
batchBoard: () => ["train-scheduling", "batch-board"] as const,
|
||||
batchBoardDetail: (scheduleId: string) =>
|
||||
["train-scheduling", "batch-board", scheduleId] as const,
|
||||
},
|
||||
|
||||
FLEET: {
|
||||
|
||||
@@ -69,6 +69,11 @@ export const URL_CONSTANTS = {
|
||||
BOOKINGS: (id: string | number) => `/customers/${id}/bookings`,
|
||||
},
|
||||
|
||||
COMPANIES: {
|
||||
BASE: "/companies",
|
||||
BY_ID: (id: string | number) => `/companies/${id}`,
|
||||
},
|
||||
|
||||
CUSTOMERS_API: {
|
||||
BASE: "/api/customers",
|
||||
BY_ID: (id: string) => `/api/customers/${id}`,
|
||||
@@ -132,6 +137,20 @@ export const URL_CONSTANTS = {
|
||||
|
||||
TRAIN_SCHEDULING: {
|
||||
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
|
||||
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
|
||||
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
|
||||
BATCH_BOARD: "/train-scheduling/batch-board",
|
||||
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
||||
`/train-scheduling/batch-board/${scheduleId}`,
|
||||
RUN_BATCH: (id: string) => `/train-scheduling/schedules/${id}/run-batch`,
|
||||
RUN_ALLOCATION: (id: string) => `/train-scheduling/schedules/${id}/run-allocation`,
|
||||
BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`,
|
||||
MARK_BOOKING_PAID: (bookingId: string) =>
|
||||
`/train-scheduling/bookings/${bookingId}/mark-paid`,
|
||||
EXPIRE_BOOKING: (bookingId: string) =>
|
||||
`/train-scheduling/bookings/${bookingId}/expire`,
|
||||
MOVE_BOOKING_SCHEDULE: (bookingId: string) =>
|
||||
`/train-scheduling/bookings/${bookingId}/move-schedule`,
|
||||
GLOBAL_RULES: "/train-scheduling/global-rules",
|
||||
PREVIEW: "/train-scheduling/preview",
|
||||
ASSIGN_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/assign-bookings`,
|
||||
@@ -160,6 +179,8 @@ export const URL_CONSTANTS = {
|
||||
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
||||
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
||||
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
||||
CHECKPOINTS: (id: string) => `/train-scheduling/schedules/${id}/checkpoints`,
|
||||
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
||||
RESCHEDULE_PREVIEW: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/reschedule/preview`,
|
||||
RESCHEDULE_EXECUTE: (id: string) =>
|
||||
|
||||
@@ -154,6 +154,24 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
||||
color: "text-amber-700",
|
||||
stage: 3,
|
||||
},
|
||||
SELECTED_FOR_BATCH: {
|
||||
title: "Selected for Batch",
|
||||
description: "Picked from the batch pool — pay within the window to secure the slot.",
|
||||
color: "text-amber-600",
|
||||
stage: 3,
|
||||
},
|
||||
AWAITING_PAYMENT: {
|
||||
title: "Selected for Batch",
|
||||
description: "Picked from the batch pool — pay within the window to secure the slot.",
|
||||
color: "text-amber-600",
|
||||
stage: 3,
|
||||
},
|
||||
EXPIRED: {
|
||||
title: "Expired",
|
||||
description: "Pay window missed — move to another schedule or cancel.",
|
||||
color: "text-red-600",
|
||||
stage: 3,
|
||||
},
|
||||
PAID: {
|
||||
title: "Paid",
|
||||
description: "Payment confirmed; ready for operations.",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||
import type { FleetListFilters, FleetResourceSlug } from "@/services/fleet/fleet.service";
|
||||
import { fleetService } from "@/services/fleet/fleet.service";
|
||||
|
||||
export function useFleetList(slug: FleetResourceSlug) {
|
||||
export function useFleetList(slug: FleetResourceSlug, filters?: FleetListFilters) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.FLEET.list(slug),
|
||||
queryFn: () => fleetService.list(slug),
|
||||
queryKey: [...QUERY_KEYS.FLEET.list(slug), filters ?? {}],
|
||||
queryFn: () => fleetService.list(slug, filters),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CreateTrainSchedulePayload,
|
||||
FreightType,
|
||||
PinWagonsPayload,
|
||||
RecordCheckpointPayload,
|
||||
TrainScheduleFilters,
|
||||
TrainSchedulePreviewPayload,
|
||||
} from "@/types/trainScheduling";
|
||||
@@ -17,6 +18,34 @@ export const useScheduleList = (freightType?: FreightType) =>
|
||||
queryFn: () => trainSchedulingService.listSchedules(freightType),
|
||||
});
|
||||
|
||||
export const useBatchBoard = () =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
|
||||
queryFn: () => trainSchedulingService.getBatchBoard(),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
export const useBatchBoardDetail = (scheduleId: string | undefined) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId ?? ""),
|
||||
queryFn: () => trainSchedulingService.getBatchBoardDetail(scheduleId!),
|
||||
enabled: Boolean(scheduleId),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
export const useRunAllocation = (scheduleId: string) => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => trainSchedulingService.runAllocation(scheduleId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
|
||||
});
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard() });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useScheduleDetail = (id: string | undefined, freightType?: FreightType) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(id ?? ""),
|
||||
@@ -35,10 +64,79 @@ export const useEligibleBookings = (
|
||||
enabled,
|
||||
});
|
||||
|
||||
export const useAvailableLocomotives = () =>
|
||||
export const useAvailableLocomotives = (routeId?: string) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(),
|
||||
queryFn: () => trainSchedulingService.getAvailableLocomotives(),
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(routeId),
|
||||
queryFn: () => trainSchedulingService.getAvailableLocomotives(routeId),
|
||||
enabled: routeId ? Boolean(routeId) : true,
|
||||
});
|
||||
|
||||
export const useBatchActions = (scheduleId?: string) => {
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard() });
|
||||
if (scheduleId) {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId),
|
||||
});
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const runBatch = useMutation({
|
||||
mutationFn: (id: string) => trainSchedulingService.runBatch(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const setWindow = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: "OPEN" | "CLOSED" }) =>
|
||||
trainSchedulingService.setBookingWindow(id, status),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const markPaid = useMutation({
|
||||
mutationFn: (bookingId: string) => trainSchedulingService.markBookingPaid(bookingId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const expire = useMutation({
|
||||
mutationFn: (bookingId: string) => trainSchedulingService.expireBooking(bookingId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const moveSchedule = useMutation({
|
||||
mutationFn: ({ bookingId, trainScheduleId }: { bookingId: string; trainScheduleId: string }) =>
|
||||
trainSchedulingService.moveBookingSchedule(bookingId, trainScheduleId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { runBatch, setWindow, markPaid, expire, moveSchedule, invalidate };
|
||||
};
|
||||
|
||||
export const useBookableSchedules = (
|
||||
originYardId?: string | null,
|
||||
destinationYardId?: string | null,
|
||||
) =>
|
||||
useQuery({
|
||||
queryKey: [
|
||||
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||
"bookable",
|
||||
originYardId ?? "",
|
||||
destinationYardId ?? "",
|
||||
],
|
||||
queryFn: () =>
|
||||
trainSchedulingService.getBookableSchedules(
|
||||
originYardId ?? undefined,
|
||||
destinationYardId ?? undefined,
|
||||
),
|
||||
enabled: Boolean(originYardId && destinationYardId),
|
||||
});
|
||||
|
||||
export const useTrainTrack = (id: string | undefined) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""),
|
||||
queryFn: () => trainSchedulingService.getTrack(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
export const useScheduleMutations = (scheduleId?: string) => {
|
||||
@@ -52,6 +150,9 @@ export const useScheduleMutations = (scheduleId?: string) => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId),
|
||||
});
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(scheduleId),
|
||||
});
|
||||
}
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT });
|
||||
};
|
||||
@@ -118,5 +219,28 @@ export const useScheduleMutations = (scheduleId?: string) => {
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, preview, assign, unassign, pin, finalize, dispatch, cancel, invalidate };
|
||||
const recordCheckpoint = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: RecordCheckpointPayload }) =>
|
||||
trainSchedulingService.recordCheckpoint(id, payload),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const arrive = useMutation({
|
||||
mutationFn: (id: string) => trainSchedulingService.arriveSchedule(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return {
|
||||
create,
|
||||
preview,
|
||||
assign,
|
||||
unassign,
|
||||
pin,
|
||||
finalize,
|
||||
dispatch,
|
||||
cancel,
|
||||
recordCheckpoint,
|
||||
arrive,
|
||||
invalidate,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { wagonService } from '@/services/wagon.service';
|
||||
|
||||
export type WagonListFilters = import('@/services/wagon.service').WagonListFilters;
|
||||
|
||||
export const wagonKeys = {
|
||||
all: ['wagons'] as const,
|
||||
list: (filters?: WagonListFilters) => [...wagonKeys.all, 'list', filters ?? {}] as const,
|
||||
byTrain: (trainId: string) => [...wagonKeys.all, 'train', trainId] as const,
|
||||
details: () => [...wagonKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...wagonKeys.details(), id] as const,
|
||||
};
|
||||
|
||||
export function useWagons() {
|
||||
return useQuery({ queryKey: wagonKeys.all, queryFn: () => wagonService.getAll().then(res => res.data) });
|
||||
export function useWagons(filters?: WagonListFilters) {
|
||||
return useQuery({
|
||||
queryKey: wagonKeys.list(filters),
|
||||
queryFn: () => wagonService.getAll(filters ?? {}).then((res) => res.data),
|
||||
});
|
||||
}
|
||||
|
||||
export const useGetWagons = useWagons;
|
||||
|
||||
@@ -53,10 +53,10 @@ const LOGIN_IMAGE = "/assets/login.png";
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
const fieldClass =
|
||||
"h-11 w-full rounded-lg border border-gray-200 bg-[#eef4f8] px-4 text-sm text-gray-900 placeholder:text-gray-400 outline-none transition-colors focus:border-primary focus:ring-2 focus:ring-primary/15";
|
||||
"h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10";
|
||||
|
||||
const primaryButtonClass =
|
||||
"h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60";
|
||||
"h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none";
|
||||
|
||||
const LeftPanelDecor = () => (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
|
||||
@@ -89,7 +89,7 @@ const RightPanelDecor = () => (
|
||||
);
|
||||
|
||||
const LeftPanel = () => (
|
||||
<div className="relative flex h-36 shrink-0 flex-col overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.1)] sm:h-44 md:h-52 lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]">
|
||||
<div className="relative hidden shrink-0 flex-col overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.1)] lg:flex lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]">
|
||||
<img
|
||||
src={LOGIN_IMAGE}
|
||||
alt="Ethio Djibouti Railway"
|
||||
@@ -274,7 +274,7 @@ const LoginPage = () => {
|
||||
<label className="flex cursor-pointer items-start gap-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300 text-primary focus:ring-primary/20 focus:ring-offset-0"
|
||||
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer rounded-md border-gray-300 text-primary transition-colors focus:ring-2 focus:ring-primary/20 focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-sm leading-snug text-gray-600">
|
||||
I agree to EDR Freight{" "}
|
||||
@@ -386,7 +386,7 @@ const LoginPage = () => {
|
||||
|
||||
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="flex min-h-full justify-center px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
|
||||
<div className="my-auto w-full rounded-2xl border border-gray-100/80 bg-white px-5 py-6 shadow-[0_4px_24px_rgba(15,23,42,0.06)] sm:px-7 sm:py-8 lg:px-9 lg:py-9">
|
||||
<div className="my-auto w-full max-w-xl rounded-3xl border border-gray-100/80 bg-white px-5 py-6 shadow-[0_4px_24px_rgba(15,23,42,0.06)] sm:px-7 sm:py-8 lg:px-9 lg:py-9">
|
||||
{!needsMfa ? loginForm : mfaForm}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet";
|
||||
import { useContainers } from "@/hooks/useContainers";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useWagons } from "@/hooks/useWagons";
|
||||
import type { FleetListFilters } from "@/services/fleet/fleet.service";
|
||||
import {
|
||||
FLEET_SELECT_NONE,
|
||||
getFleetResource,
|
||||
@@ -38,12 +39,30 @@ const FleetResourcePage = () => {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||
const [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
||||
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
|
||||
const { viewMode, setViewMode } = useFleetViewMode(slug);
|
||||
|
||||
const { data: allRows = [], isLoading, isError, error } = useFleetList(slug);
|
||||
const serverListFilters = useMemo((): FleetListFilters | undefined => {
|
||||
if (slug !== "wagons" && slug !== "locomotives") return undefined;
|
||||
const filters: FleetListFilters = {};
|
||||
const status = listFilterValues.status;
|
||||
const readiness = listFilterValues.readiness;
|
||||
if (status && status !== "ALL") {
|
||||
filters.status = status as FleetListFilters["status"];
|
||||
}
|
||||
if (readiness && readiness !== "ALL") {
|
||||
filters.readiness = readiness as FleetListFilters["readiness"];
|
||||
}
|
||||
if (slug === "wagons" && search.trim()) {
|
||||
filters.search = search.trim();
|
||||
}
|
||||
return filters;
|
||||
}, [slug, listFilterValues, search]);
|
||||
|
||||
const { data: allRows = [], isLoading, isError, error } = useFleetList(slug, serverListFilters);
|
||||
const { create, update, remove } = useFleetMutations(slug);
|
||||
|
||||
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes();
|
||||
@@ -56,12 +75,18 @@ const FleetResourcePage = () => {
|
||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||
setSearch("");
|
||||
setStatusFilter("ALL");
|
||||
setListFilterValues({});
|
||||
}, [slug, setPagination]);
|
||||
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||
}, [search, listFilterValues, setPagination]);
|
||||
|
||||
const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status"));
|
||||
const usesServerListFilters = Boolean(config?.listFilters?.length);
|
||||
|
||||
const statusFilterOptions = useMemo(() => {
|
||||
if (!hasStatusColumn) return [];
|
||||
if (!hasStatusColumn || usesServerListFilters) return [];
|
||||
const statuses = new Set(
|
||||
allRows
|
||||
.map((row) => String((row as unknown as Record<string, unknown>).status ?? ""))
|
||||
@@ -71,7 +96,19 @@ const FleetResourcePage = () => {
|
||||
{ value: "ALL", label: "All statuses" },
|
||||
...[...statuses].sort().map((status) => ({ value: status, label: status })),
|
||||
];
|
||||
}, [allRows, hasStatusColumn]);
|
||||
}, [allRows, hasStatusColumn, usesServerListFilters]);
|
||||
|
||||
const listFilterSelects = useMemo(() => {
|
||||
if (!config?.listFilters?.length) return null;
|
||||
return config.listFilters.map((filter) => ({
|
||||
...filter,
|
||||
value: listFilterValues[filter.key] ?? "ALL",
|
||||
data: [
|
||||
{ value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` },
|
||||
...filter.options.map((opt) => ({ value: opt.value, label: opt.label })),
|
||||
],
|
||||
}));
|
||||
}, [config?.listFilters, listFilterValues]);
|
||||
|
||||
const dynamicOptions = useMemo(() => {
|
||||
const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map(
|
||||
@@ -125,6 +162,7 @@ const FleetResourcePage = () => {
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (!config) return allRows;
|
||||
if (usesServerListFilters) return allRows;
|
||||
const term = search.trim().toLowerCase();
|
||||
return allRows.filter((row) => {
|
||||
const record = row as unknown as Record<string, unknown>;
|
||||
@@ -138,7 +176,7 @@ const FleetResourcePage = () => {
|
||||
.includes(term),
|
||||
);
|
||||
});
|
||||
}, [allRows, search, statusFilter, config]);
|
||||
}, [allRows, search, statusFilter, config, usesServerListFilters]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
|
||||
const pagedRows = useMemo(() => {
|
||||
@@ -247,7 +285,27 @@ const FleetResourcePage = () => {
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
filters={
|
||||
hasStatusColumn && statusFilterOptions.length > 1 ? (
|
||||
listFilterSelects ? (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{listFilterSelects.map((filter) => (
|
||||
<Select
|
||||
key={filter.key}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
label={filter.label}
|
||||
value={filter.value}
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
setListFilterValues((prev) => ({ ...prev, [filter.key]: v }));
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={filter.data}
|
||||
w={170}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
|
||||
@@ -11,6 +11,10 @@ export type FleetResourceSlug =
|
||||
|
||||
export const FLEET_SELECT_NONE = "__none__";
|
||||
|
||||
import type { WagonListFilters } from "@/services/wagon.service";
|
||||
|
||||
export type FleetListFilters = WagonListFilters;
|
||||
|
||||
export type FleetDynamicOptions =
|
||||
| "wagonTypes"
|
||||
| "containerTypes"
|
||||
@@ -30,6 +34,13 @@ export interface FleetFormFieldDef extends FormFieldDef {
|
||||
noneOption?: boolean;
|
||||
}
|
||||
|
||||
export interface FleetListFilterDef {
|
||||
key: "status" | "readiness" | "wagonTypeId" | "trainId";
|
||||
label: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
allLabel?: string;
|
||||
}
|
||||
|
||||
export interface FleetResourceConfig {
|
||||
slug: FleetResourceSlug;
|
||||
label: string;
|
||||
@@ -39,6 +50,8 @@ export interface FleetResourceConfig {
|
||||
entityLabel: string;
|
||||
searchPlaceholder: string;
|
||||
supportsSearch: boolean;
|
||||
/** Server-side list filters (e.g. wagon status / readiness). */
|
||||
listFilters?: FleetListFilterDef[];
|
||||
columns: FleetResourceColumn[];
|
||||
formFields: FleetFormFieldDef[];
|
||||
emptyValues: Record<string, unknown>;
|
||||
@@ -101,12 +114,27 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
removeSuccessMessage: "Locomotive decommissioned",
|
||||
cardTitleKey: "name",
|
||||
cardCodeKey: "code",
|
||||
cardSubtitleKey: "locomotiveType",
|
||||
searchKeys: ["code", "name", "locomotiveType", "status"],
|
||||
listFilters: [
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
allLabel: "All statuses",
|
||||
options: LOCOMOTIVE_STATUS_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "readiness",
|
||||
label: "Readiness",
|
||||
allLabel: "All readiness",
|
||||
options: WAGON_READINESS_OPTIONS,
|
||||
},
|
||||
],
|
||||
cardSubtitleKey: "readiness",
|
||||
searchKeys: ["code", "name", "locomotiveType", "status", "readiness"],
|
||||
columns: [
|
||||
{ id: "code", header: "Code", accessorKey: "code", format: "code" },
|
||||
{ id: "name", header: "Name", accessorKey: "name" },
|
||||
{ id: "locomotiveType", header: "Type", accessorKey: "locomotiveType" },
|
||||
{ id: "readiness", header: "Readiness", accessorKey: "readiness", format: "statusBadge" },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
|
||||
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
|
||||
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
|
||||
@@ -116,6 +144,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ name: "name", label: "Name", type: "text" },
|
||||
{ name: "locomotiveType", label: "Locomotive type", type: "select", required: true, options: LOCOMOTIVE_TYPE_OPTIONS },
|
||||
{ name: "status", label: "Status", type: "select", required: true, options: LOCOMOTIVE_STATUS_OPTIONS },
|
||||
{ name: "readiness", label: "Readiness", type: "select", required: true, options: WAGON_READINESS_OPTIONS },
|
||||
{ name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true },
|
||||
{ name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true },
|
||||
{ name: "powerKw", label: "Power (kW)", type: "number" },
|
||||
@@ -127,6 +156,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
name: "",
|
||||
locomotiveType: "DIESEL",
|
||||
status: "AVAILABLE",
|
||||
readiness: Freight.WagonReadiness.ImportReady,
|
||||
maxPullWeightTons: 0,
|
||||
maxTrainLengthMeters: 760,
|
||||
powerKw: "",
|
||||
@@ -187,6 +217,20 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
searchPlaceholder: "Search wagons…",
|
||||
supportsSearch: true,
|
||||
removeAction: "delete",
|
||||
listFilters: [
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
allLabel: "All statuses",
|
||||
options: WAGON_STATUS_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "readiness",
|
||||
label: "Readiness",
|
||||
allLabel: "All readiness",
|
||||
options: WAGON_READINESS_OPTIONS,
|
||||
},
|
||||
],
|
||||
cardTitleKey: "wagonNumber",
|
||||
cardSubtitleKey: "readiness",
|
||||
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "readiness"],
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowRight,
|
||||
LayoutGrid,
|
||||
RefreshCw,
|
||||
Ruler,
|
||||
Train,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import type { BatchBoardSchedule } from "@/types/trainScheduling";
|
||||
|
||||
const fmtTons = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
|
||||
|
||||
const fmtMeters = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
|
||||
|
||||
function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
const navigate = useNavigate();
|
||||
const { capacity, counts, locomotive } = schedule;
|
||||
const lengthPct =
|
||||
capacity.maxLengthMeters && capacity.maxLengthMeters > 0
|
||||
? (capacity.allocatedLengthMeters / capacity.maxLengthMeters) * 100
|
||||
: 0;
|
||||
const weightPct =
|
||||
capacity.maxWeightTons && capacity.maxWeightTons > 0
|
||||
? (capacity.usedWeightTons / capacity.maxWeightTons) * 100
|
||||
: 0;
|
||||
|
||||
const windowColor =
|
||||
schedule.bookingWindowStatus === "OPEN"
|
||||
? "green"
|
||||
: schedule.bookingWindowStatus === "FULL"
|
||||
? "orange"
|
||||
: "gray";
|
||||
|
||||
const totalBookings =
|
||||
counts.allocated +
|
||||
counts.selectedForBatch +
|
||||
counts.ready +
|
||||
counts.waiting +
|
||||
counts.pendingContract +
|
||||
counts.expired;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-gray-2)",
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
|
||||
}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
height: 3,
|
||||
background:
|
||||
"linear-gradient(90deg, var(--mantine-color-green-5), var(--mantine-color-teal-7))",
|
||||
}}
|
||||
/>
|
||||
<Stack gap="sm" p="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon size={38} radius="md" variant="light" color="green">
|
||||
<Train size={19} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={700} size="sm" truncate>
|
||||
{schedule.trainNumber ?? schedule.routeName ?? "Schedule"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{schedule.origin ?? "—"} → {schedule.destination ?? "—"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{schedule.scheduleDate
|
||||
? new Date(schedule.scheduleDate).toLocaleString()
|
||||
: "No date"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Stack gap={4} align="flex-end">
|
||||
<Badge variant="light" color={windowColor} radius="sm">
|
||||
{schedule.bookingWindowStatus}
|
||||
</Badge>
|
||||
<Badge variant="outline" color="gray" radius="sm">
|
||||
{schedule.status}
|
||||
</Badge>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{locomotive ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Loco {locomotive.code} · max {fmtTons(locomotive.maxPullWeightTons)} ·{" "}
|
||||
{locomotive.maxTrainLengthMeters} m
|
||||
</Text>
|
||||
) : (
|
||||
<Badge variant="light" color="red" radius="sm">
|
||||
No locomotive — won't allocate
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Group justify="space-between" mb={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Allocated wagons
|
||||
</Text>
|
||||
<Text size="xs" fw={600}>
|
||||
{capacity.allocatedWagons}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
{capacity.maxLengthMeters ? (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={2}>
|
||||
<Group gap={4}>
|
||||
<Ruler size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
Train length
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" fw={600}>
|
||||
{fmtMeters(capacity.allocatedLengthMeters)}/{fmtMeters(capacity.maxLengthMeters)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={lengthPct}
|
||||
color={lengthPct >= 100 ? "orange" : "blue"}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
{capacity.maxWeightTons ? (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={2}>
|
||||
<Group gap={4}>
|
||||
<Weight size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
Weight
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" fw={600}>
|
||||
{fmtTons(capacity.usedWeightTons)}/{fmtTons(capacity.maxWeightTons)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={weightPct}
|
||||
color={weightPct >= 100 ? "red" : "teal"}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Group gap={6}>
|
||||
<Tooltip label="Allocated to the train">
|
||||
<Badge variant="light" color="green" radius="sm">
|
||||
{counts.allocated} allocated
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Tooltip label="Picked by batch — customer notified to pay">
|
||||
<Badge variant="light" color="orange" radius="sm">
|
||||
{counts.selectedForBatch} selected
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Tooltip label="Contract signed — waiting for batch pick">
|
||||
<Badge variant="light" color="teal" radius="sm">
|
||||
{counts.ready} ready
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Tooltip label="Paid, waiting for a slot">
|
||||
<Badge variant="light" color="blue" radius="sm">
|
||||
{counts.waiting} waiting
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
{counts.expired ? (
|
||||
<Badge variant="light" color="red" radius="sm">
|
||||
{counts.expired} expired
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{totalBookings
|
||||
? `${totalBookings} booking${totalBookings === 1 ? "" : "s"} · click for batch windows`
|
||||
: "No bookings yet · click to open"}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
radius="md"
|
||||
size="compact-sm"
|
||||
rightSection={<ArrowRight size={15} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`);
|
||||
}}
|
||||
>
|
||||
View batch windows
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BatchBoardPage() {
|
||||
const { data, isLoading, isFetching, refetch } = useBatchBoard();
|
||||
const schedules = data ?? [];
|
||||
|
||||
return (
|
||||
<Container size="xl" py="lg">
|
||||
<Breadcrumbs items={[{ label: "Operations" }, { label: "Batch board" }]} />
|
||||
|
||||
<Paper
|
||||
radius="xl"
|
||||
p="xl"
|
||||
mt="md"
|
||||
style={{
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={54} radius="lg" variant="light" color="green">
|
||||
<LayoutGrid size={26} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" fw={700} c="green.7" tt="uppercase" style={{ letterSpacing: 1 }}>
|
||||
Allocation · Live
|
||||
</Text>
|
||||
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
||||
Batch board
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" maw={560}>
|
||||
Active schedules — click a card to see EAT 3-hour batch windows, bookings, and
|
||||
wagon allocation status.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="lg"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader color="green" />
|
||||
</Group>
|
||||
) : schedules.length === 0 ? (
|
||||
<Paper radius="lg" withBorder p="xl" mt="lg" bg="gray.0">
|
||||
<Text ta="center" c="dimmed">
|
||||
No active schedules to show.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg" mt="lg">
|
||||
{schedules.map((s) => (
|
||||
<ScheduleCard key={s.scheduleId} schedule={s} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Hourglass,
|
||||
Layers,
|
||||
PlayCircle,
|
||||
RefreshCw,
|
||||
Train,
|
||||
Weight,
|
||||
Ruler,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import {
|
||||
useBatchBoardDetail,
|
||||
useRunAllocation,
|
||||
useScheduleDetail,
|
||||
} from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
BatchBoardBookingDetail,
|
||||
BatchBoardBookingState,
|
||||
BatchWindowGroup,
|
||||
BookingAllocationStatus,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
const STATE_META: Record<
|
||||
BatchBoardBookingState,
|
||||
{ label: string; color: string; icon: typeof CheckCircle2 }
|
||||
> = {
|
||||
ALLOCATED: { label: "Allocated", color: "green", icon: CheckCircle2 },
|
||||
SELECTED_FOR_BATCH: { label: "Selected for batch", color: "orange", icon: Clock },
|
||||
READY: { label: "Ready for batch", color: "teal", icon: Hourglass },
|
||||
WAITING: { label: "Paid · waiting", color: "blue", icon: Hourglass },
|
||||
PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass },
|
||||
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
|
||||
};
|
||||
|
||||
const ALLOC_META: Record<
|
||||
BookingAllocationStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
ASSIGNED: { label: "Wagons assigned", color: "green" },
|
||||
NOT_ATTEMPTED: { label: "Not allocated", color: "gray" },
|
||||
DEFERRED: { label: "Deferred", color: "orange" },
|
||||
FAILED: { label: "Allocation failed", color: "red" },
|
||||
};
|
||||
|
||||
const fmtTons = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
|
||||
|
||||
const fmtMeters = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
|
||||
|
||||
const fmtDateTime = (iso: string | null) =>
|
||||
iso
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
function StateBadge({ state }: { state: BatchBoardBookingState }) {
|
||||
const meta = STATE_META[state];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<Badge variant="light" color={meta.color} radius="sm" leftSection={<Icon size={11} />}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function AllocationBadge({
|
||||
status,
|
||||
issue,
|
||||
}: {
|
||||
status: BookingAllocationStatus;
|
||||
issue: string | null;
|
||||
}) {
|
||||
const meta = ALLOC_META[status];
|
||||
const badge = (
|
||||
<Badge variant="light" color={meta.color} radius="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
if (!issue) return badge;
|
||||
return (
|
||||
<Tooltip label={issue} multiline maw={320} withArrow>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{badge}
|
||||
<AlertTriangle size={14} color="var(--mantine-color-red-6)" />
|
||||
</Group>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) {
|
||||
if (!bookings.length) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" py="sm" ta="center">
|
||||
No bookings in this batch window.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Reference</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Contract signed</Table.Th>
|
||||
<Table.Th>Selected for batch</Table.Th>
|
||||
<Table.Th>Capacity</Table.Th>
|
||||
<Table.Th>Batch state</Table.Th>
|
||||
<Table.Th>Wagon allocation</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{bookings.map((b) => (
|
||||
<Table.Tr key={b.id}>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{b.reference}
|
||||
</Text>
|
||||
{b.isGovernment ? (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
Gov
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{b.company}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{fmtDateTime(b.fullyExecutedAt)} EAT</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{b.selectedForBatchAt ? (
|
||||
<>
|
||||
<Text size="sm">{fmtDateTime(b.selectedForBatchAt)} EAT</Text>
|
||||
{b.paymentDeadline ? (
|
||||
<Text size="xs" c="orange">
|
||||
Pay by {fmtDateTime(b.paymentDeadline)} EAT
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{b.wagons}w · {fmtTons(b.weightTons)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StateBadge state={b.state} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<AllocationBadge status={b.allocationStatus} issue={b.allocationIssue} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
|
||||
const total = window.bookings.length;
|
||||
const hasIssues = window.bookings.some(
|
||||
(b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED",
|
||||
);
|
||||
|
||||
return (
|
||||
<Accordion.Item value={window.key}>
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md">
|
||||
<Text fw={600} size="sm">
|
||||
{window.label}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{hasIssues ? (
|
||||
<Badge variant="light" color="red" size="sm">
|
||||
Issues
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="outline" color="gray" size="sm">
|
||||
{total} booking{total === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<BookingTable bookings={window.bookings} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BatchScheduleDetailPage() {
|
||||
const { scheduleId } = useParams<{ scheduleId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading, isFetching, refetch } = useBatchBoardDetail(scheduleId);
|
||||
const runAllocation = useRunAllocation(scheduleId ?? "");
|
||||
|
||||
const hasAssignedWagons = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
data?.windows.some((w) =>
|
||||
w.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
|
||||
) ||
|
||||
data?.pendingContract.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
|
||||
),
|
||||
[data],
|
||||
);
|
||||
|
||||
const scheduleDetailQuery = useScheduleDetail(
|
||||
hasAssignedWagons ? scheduleId : undefined,
|
||||
"CONTAINER",
|
||||
);
|
||||
|
||||
const defaultOpen = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const withBookings = data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key);
|
||||
if (data.pendingContract.bookings.length) withBookings.push("pending-contract");
|
||||
return withBookings.length ? withBookings : [data.windows[0]?.key].filter(Boolean);
|
||||
}, [data]);
|
||||
|
||||
const handleRunAllocation = () => {
|
||||
runAllocation
|
||||
.mutateAsync()
|
||||
.then((result) => {
|
||||
const failed = result.issues.filter((i) => i.status === "FAILED").length;
|
||||
const deferred = result.deferred.length;
|
||||
toast({
|
||||
title: "Allocation run complete",
|
||||
description:
|
||||
failed || deferred
|
||||
? `${result.assignedBookingIds.length} assigned · ${deferred} deferred · ${failed} failed`
|
||||
: `${result.assignedBookingIds.length} booking(s) assigned to wagons`,
|
||||
variant: failed ? "destructive" : "default",
|
||||
});
|
||||
void refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast({ title: "Allocation failed", variant: "destructive" });
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Container size="xl" py="lg">
|
||||
<Group justify="center" py="xl">
|
||||
<Loader color="green" />
|
||||
</Group>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const lengthPct =
|
||||
data.capacity.maxLengthMeters && data.capacity.maxLengthMeters > 0
|
||||
? (data.capacity.allocatedLengthMeters / data.capacity.maxLengthMeters) * 100
|
||||
: 0;
|
||||
const weightPct =
|
||||
data.capacity.maxWeightTons && data.capacity.maxWeightTons > 0
|
||||
? (data.capacity.usedWeightTons / data.capacity.maxWeightTons) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Container size="xl" py="lg">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Operations" },
|
||||
{ label: "Batch board", href: "/dashboard/operations/batch-board" },
|
||||
{ label: data.trainNumber ?? data.routeName ?? "Schedule" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Paper radius="xl" p="xl" mt="md" withBorder>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/operations/batch-board")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Stack gap={4}>
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={44} radius="md" variant="light" color="green">
|
||||
<Train size={22} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>
|
||||
{data.trainNumber ?? data.routeName ?? "Schedule"}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{data.origin ?? "—"} → {data.destination ?? "—"} ·{" "}
|
||||
{data.scheduleDate
|
||||
? new Date(data.scheduleDate).toLocaleString()
|
||||
: "No date"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
<Badge variant="light" color="green">
|
||||
{data.bookingWindowStatus}
|
||||
</Badge>
|
||||
<Badge variant="outline" color="gray">
|
||||
{data.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<RefreshCw size={16} />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
leftSection={<PlayCircle size={16} />}
|
||||
loading={runAllocation.isPending}
|
||||
onClick={handleRunAllocation}
|
||||
>
|
||||
Run allocation
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Layers size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${data.scheduleId}`)
|
||||
}
|
||||
>
|
||||
Open schedule
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{data.locomotive ? (
|
||||
<Text size="sm" c="dimmed" mt="md">
|
||||
Loco {data.locomotive.code} · max {fmtTons(data.locomotive.maxPullWeightTons)} ·{" "}
|
||||
{data.locomotive.maxTrainLengthMeters} m
|
||||
</Text>
|
||||
) : (
|
||||
<Alert color="red" mt="md" icon={<AlertTriangle size={16} />}>
|
||||
No locomotive assigned — wagon allocation cannot run.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="md" mt="md">
|
||||
<Box>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="sm" c="dimmed">
|
||||
Allocated wagons
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{data.capacity.allocatedWagons}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
{data.capacity.maxLengthMeters ? (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Group gap={4}>
|
||||
<Ruler size={14} />
|
||||
<Text size="sm" c="dimmed">
|
||||
Train length
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600}>
|
||||
{fmtMeters(data.capacity.allocatedLengthMeters)}/
|
||||
{fmtMeters(data.capacity.maxLengthMeters)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={lengthPct}
|
||||
color={lengthPct >= 100 ? "orange" : "blue"}
|
||||
radius="xl"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
{data.capacity.maxWeightTons ? (
|
||||
<Box>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Group gap={4}>
|
||||
<Weight size={14} />
|
||||
<Text size="sm" c="dimmed">
|
||||
Weight
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600}>
|
||||
{fmtTons(data.capacity.usedWeightTons)}/{fmtTons(data.capacity.maxWeightTons)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={weightPct}
|
||||
color={weightPct >= 100 ? "red" : "teal"}
|
||||
radius="xl"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</SimpleGrid>
|
||||
|
||||
<Group gap={6} mt="md">
|
||||
<Badge variant="light" color="green">
|
||||
{data.counts.allocated} allocated
|
||||
</Badge>
|
||||
<Badge variant="light" color="orange">
|
||||
{data.counts.selectedForBatch} selected
|
||||
</Badge>
|
||||
<Badge variant="light" color="teal">
|
||||
{data.counts.ready} ready
|
||||
</Badge>
|
||||
<Badge variant="light" color="blue">
|
||||
{data.counts.waiting} waiting
|
||||
</Badge>
|
||||
<Badge variant="light" color="gray">
|
||||
{data.counts.pendingContract} pending contract
|
||||
</Badge>
|
||||
{data.counts.expired ? (
|
||||
<Badge variant="light" color="red">
|
||||
{data.counts.expired} expired
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{data.allocationViolations.length ? (
|
||||
<Alert color="red" mt="md" icon={<AlertTriangle size={16} />} title="Allocation constraints">
|
||||
<Stack gap={4}>
|
||||
{data.allocationViolations.map((v) => (
|
||||
<Text key={v} size="sm">
|
||||
{v}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Paper radius="lg" withBorder p="lg" mt="lg">
|
||||
<Title order={4} mb="md">
|
||||
Batch windows (EAT)
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Bookings are grouped by contract signing time (<code>fullyExecutedAt</code>). Expand a
|
||||
window to see bookings and wagon allocation issues.
|
||||
</Text>
|
||||
|
||||
<Accordion multiple defaultValue={defaultOpen} variant="separated">
|
||||
{data.windows.map((window) => (
|
||||
<WindowAccordionItem key={window.key} window={window} />
|
||||
))}
|
||||
{data.pendingContract.bookings.length ? (
|
||||
<Accordion.Item value="pending-contract">
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md">
|
||||
<Text fw={600} size="sm">
|
||||
Pending contract
|
||||
</Text>
|
||||
<Badge variant="outline" color="gray" size="sm">
|
||||
{data.pendingContract.bookings.length} booking
|
||||
{data.pendingContract.bookings.length === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<BookingTable bookings={data.pendingContract.bookings} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
) : null}
|
||||
</Accordion>
|
||||
</Paper>
|
||||
|
||||
{hasAssignedWagons && scheduleDetailQuery.data ? (
|
||||
<Paper radius="lg" withBorder p="lg" mt="lg">
|
||||
<Title order={4} mb="md">
|
||||
Train composition
|
||||
</Title>
|
||||
<TrainCompositionDiagram
|
||||
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
|
||||
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
|
||||
freightType="CONTAINER"
|
||||
trainNumber={scheduleDetailQuery.data.trainNumber}
|
||||
totalLengthMeters={scheduleDetailQuery.data.trainSet?.totalLengthMeters}
|
||||
/>
|
||||
</Paper>
|
||||
) : null}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Flag,
|
||||
MapPin,
|
||||
Navigation,
|
||||
Train,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Timeline,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
|
||||
import {
|
||||
RouteCorridor,
|
||||
StatTile,
|
||||
StatusPill,
|
||||
scheduleBrand,
|
||||
} from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { useTrainTrack, useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const data = error.response?.data as Record<string, unknown> | undefined;
|
||||
const message = data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
function formatDateTime(iso?: string | null) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function TrainScheduleTrackPage() {
|
||||
const { scheduleId } = useParams<{ scheduleId: string }>();
|
||||
const { toast } = useToast();
|
||||
const trackQuery = useTrainTrack(scheduleId);
|
||||
const { recordCheckpoint } = useScheduleMutations(scheduleId);
|
||||
|
||||
if (trackQuery.isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const track = trackQuery.data;
|
||||
if (!track || !scheduleId) {
|
||||
return (
|
||||
<Text c="dimmed" py="xl">
|
||||
Tracking data not found.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const canLog = track.status === "DISPATCHED";
|
||||
const totalStations = track.stations.length;
|
||||
const reached = Math.min(track.currentSequenceNo + 1, totalStations);
|
||||
const progressLabel = `${reached} / ${totalStations}`;
|
||||
|
||||
const handleLog = (sequenceNo: number) => {
|
||||
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
|
||||
recordCheckpoint.mutate(
|
||||
{ id: scheduleId, payload: { sequenceNo } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: isFinal
|
||||
? "Train arrived — assets freed, readiness flipped"
|
||||
: "Checkpoint logged",
|
||||
});
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Could not log checkpoint",
|
||||
description: parseError(err, "Please try again"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
w="fit-content"
|
||||
>
|
||||
Back to schedule
|
||||
</Button>
|
||||
|
||||
{/* Hero */}
|
||||
<Paper
|
||||
radius="xl"
|
||||
p="xl"
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: scheduleBrand.heroGradient,
|
||||
boxShadow: scheduleBrand.shadow,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -90,
|
||||
right: -50,
|
||||
width: 280,
|
||||
height: 280,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.10)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon size={56} radius="lg" variant="white" style={{ color: "var(--mantine-color-green-7)" }}>
|
||||
<Navigation size={28} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={6}>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={2} c="white" fw={700}>
|
||||
Track train
|
||||
</Title>
|
||||
{track.trainNumber ? (
|
||||
<Badge variant="white" c="green.8" radius="sm" style={{ fontWeight: 600 }}>
|
||||
{track.trainNumber}
|
||||
</Badge>
|
||||
) : null}
|
||||
{track.direction ? (
|
||||
<Badge variant="white" c="green.8" radius="sm">
|
||||
{track.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Box maw={340}>
|
||||
<RouteCorridor onDark origin={track.origin} destination={track.destination} />
|
||||
</Box>
|
||||
<StatusPill status={track.status} />
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<StatTile onDark icon={Train} label="Progress" value={progressLabel} hint="stations reached" />
|
||||
<StatTile onDark icon={MapPin} label="Current" value={track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—"} />
|
||||
<StatTile onDark icon={CalendarClock} label="Departed" value={formatDateTime(track.actualDepartureAt)} />
|
||||
<StatTile onDark icon={Flag} label="Arrived" value={formatDateTime(track.actualArrivalAt)} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Corridor */}
|
||||
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={44} radius="md" variant="gradient" gradient={{ from: "green", to: "teal", deg: 135 }}>
|
||||
<Navigation size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Title order={4} fw={700}>
|
||||
Route corridor
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{canLog
|
||||
? "Log the train passing each station; the final station marks arrival."
|
||||
: track.status === "ARRIVED"
|
||||
? "This train has arrived at its destination."
|
||||
: "Tracking becomes available once the train is dispatched."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<RouteCorridorTrack
|
||||
stations={track.stations}
|
||||
currentSequenceNo={track.currentSequenceNo}
|
||||
checkpoints={track.checkpoints}
|
||||
canLog={canLog}
|
||||
loggingSeq={
|
||||
recordCheckpoint.isPending ? recordCheckpoint.variables?.payload.sequenceNo : null
|
||||
}
|
||||
onLogCheckpoint={handleLog}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Timeline */}
|
||||
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
<Stack gap="md">
|
||||
<Title order={5} fw={700}>
|
||||
Checkpoint log
|
||||
</Title>
|
||||
{track.checkpoints.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No checkpoints logged yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Timeline active={track.checkpoints.length} bulletSize={22} lineWidth={2} color="green">
|
||||
{track.checkpoints.map((cp) => (
|
||||
<Timeline.Item
|
||||
key={cp.id}
|
||||
bullet={cp.kind === "ARRIVED" ? <CheckCircle2 size={13} /> : <MapPin size={12} />}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<Text fw={600} size="sm">
|
||||
{cp.label ?? `Station ${cp.sequenceNo}`}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={cp.kind === "ARRIVED" ? "teal" : cp.kind === "DEPARTED" ? "blue" : "green"}
|
||||
>
|
||||
{cp.kind}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDateTime(cp.occurredAt)}
|
||||
</Text>
|
||||
{cp.note ? <Text size="xs">{cp.note}</Text> : null}
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Container as ContainerIcon,
|
||||
Eye,
|
||||
LayoutGrid,
|
||||
Navigation,
|
||||
Package,
|
||||
Route as RouteIcon,
|
||||
Send,
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
scheduleBrand,
|
||||
} from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
||||
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
|
||||
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
|
||||
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
||||
@@ -100,9 +102,11 @@ export default function TrainScheduleV2DetailPage() {
|
||||
? {
|
||||
originStationId: schedule.originStation?.id,
|
||||
destinationStationId: schedule.destinationStation?.id,
|
||||
// Only this schedule's own bookings are eligible — same rule as the auto batch.
|
||||
trainScheduleId: scheduleId,
|
||||
}
|
||||
: undefined,
|
||||
[schedule],
|
||||
[schedule, scheduleId],
|
||||
);
|
||||
|
||||
const eligibleFreightType =
|
||||
@@ -710,52 +714,30 @@ export default function TrainScheduleV2DetailPage() {
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: scheduleBrand.heroGradient,
|
||||
boxShadow: scheduleBrand.shadow,
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -90,
|
||||
right: -50,
|
||||
width: 280,
|
||||
height: 280,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.10)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={56}
|
||||
radius="lg"
|
||||
variant="white"
|
||||
style={{ color: "var(--mantine-color-green-7)" }}
|
||||
>
|
||||
<ThemeIcon size={56} radius="lg" variant="light" color="green">
|
||||
<Train size={28} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={6}>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={2} c="white" fw={700}>
|
||||
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
||||
{schedule.route?.name ?? "Train schedule"}
|
||||
</Title>
|
||||
{schedule.trainNumber ? (
|
||||
<Badge
|
||||
variant="white"
|
||||
c="green.8"
|
||||
radius="sm"
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
<Badge variant="light" color="green" radius="sm" style={{ fontWeight: 600 }}>
|
||||
{schedule.trainNumber}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Box maw={340}>
|
||||
<RouteCorridor
|
||||
onDark
|
||||
origin={
|
||||
schedule.originStation?.label ?? schedule.originStation?.code
|
||||
}
|
||||
@@ -771,34 +753,51 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
{schedule.status !== "DISPATCHED" ? (
|
||||
<Button
|
||||
variant="white"
|
||||
c="green.8"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
onClick={() => setMaintenanceOpen(true)}
|
||||
>
|
||||
Reschedule train
|
||||
</Button>
|
||||
) : null}
|
||||
<Group gap="sm">
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
|
||||
color="green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
leftSection={<Navigation size={16} />}
|
||||
>
|
||||
Track train
|
||||
</Button>
|
||||
) : null}
|
||||
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
onClick={() => setMaintenanceOpen(true)}
|
||||
>
|
||||
Reschedule train
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<StatTile
|
||||
onDark
|
||||
icon={Train}
|
||||
label="Locomotive"
|
||||
value={schedule.trainSet?.locomotive?.code ?? "—"}
|
||||
hint={
|
||||
schedule.trainSet?.locomotive?.readiness === "EXPORT_READY"
|
||||
? "Export-ready"
|
||||
: schedule.trainSet?.locomotive?.readiness === "IMPORT_READY"
|
||||
? "Import-ready"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatTile
|
||||
onDark
|
||||
icon={Package}
|
||||
label="Bookings"
|
||||
value={schedule.bookings?.length ?? 0}
|
||||
/>
|
||||
<StatTile
|
||||
onDark
|
||||
icon={Weight}
|
||||
label="Wagons / load"
|
||||
value={`${schedule.trainSet?.wagonCount ?? displayWagonPlan.length} · ${
|
||||
@@ -806,7 +805,6 @@ export default function TrainScheduleV2DetailPage() {
|
||||
}T`}
|
||||
/>
|
||||
<StatTile
|
||||
onDark
|
||||
icon={CalendarClock}
|
||||
label="Departure"
|
||||
value={new Date(schedule.scheduledDepartureDate).toLocaleDateString(
|
||||
@@ -824,8 +822,8 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Badge
|
||||
size="lg"
|
||||
radius="sm"
|
||||
variant="white"
|
||||
c={previewResult.valid ? "green.8" : "red.7"}
|
||||
variant="light"
|
||||
color={previewResult.valid ? "green" : "red"}
|
||||
leftSection={
|
||||
<Box
|
||||
w={8}
|
||||
@@ -907,6 +905,8 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<ScheduleBatchPanel schedule={schedule} />
|
||||
|
||||
{scheduleId ? (
|
||||
<RescheduleTrainDialog
|
||||
scheduleId={scheduleId}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { ArrowRight, CalendarClock, Send, Train, Weight } from "lucide-react";
|
||||
import { ArrowRight, CalendarClock, Navigation, Send, Train, Weight } from "lucide-react";
|
||||
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
@@ -81,7 +81,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
|
||||
const schedulesQuery = useScheduleList();
|
||||
const routesQuery = useRoutes();
|
||||
const locomotivesQuery = useAvailableLocomotives();
|
||||
const locomotivesQuery = useAvailableLocomotives(routeId || undefined);
|
||||
const { create, cancel } = useScheduleMutations();
|
||||
|
||||
const activeRoutes = useMemo(
|
||||
@@ -89,6 +89,23 @@ export default function TrainScheduleV2ListPage() {
|
||||
[routesQuery.data],
|
||||
);
|
||||
|
||||
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
|
||||
|
||||
const locomotiveReadinessHint = useMemo(() => {
|
||||
if (!selectedRoute) return "Select a route first";
|
||||
const origin = selectedRoute.originYard?.country?.trim();
|
||||
const dest = selectedRoute.destinationYard?.country?.trim();
|
||||
if (origin === "Djibouti") return "Import corridor — import-ready locomotives only";
|
||||
if (dest === "Djibouti" && origin !== "Djibouti") {
|
||||
return "Export corridor — export-ready locomotives only";
|
||||
}
|
||||
return "Domestic corridor — any readiness";
|
||||
}, [selectedRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocomotiveId("");
|
||||
}, [routeId]);
|
||||
|
||||
const allSchedules = schedulesQuery.data ?? [];
|
||||
|
||||
const stats = useMemo(() => {
|
||||
@@ -253,6 +270,21 @@ export default function TrainScheduleV2ListPage() {
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
{["DISPATCHED", "ARRIVED"].includes(row.original.status) ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="compact-sm"
|
||||
leftSection={<Navigation size={14} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/operations/train-scheduling-v2/${row.original.id}/track`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Track
|
||||
</Button>
|
||||
) : null}
|
||||
{["DRAFT", "SCHEDULED"].includes(row.original.status) ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
@@ -320,52 +352,22 @@ export default function TrainScheduleV2ListPage() {
|
||||
style={{
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
background: scheduleBrand.heroGradient,
|
||||
boxShadow: scheduleBrand.shadow,
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 1px 3px rgba(15,23,42,0.04)",
|
||||
}}
|
||||
>
|
||||
{/* decorative glow */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -90,
|
||||
right: -60,
|
||||
width: 280,
|
||||
height: 280,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.12)",
|
||||
filter: "blur(8px)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: -120,
|
||||
right: 120,
|
||||
width: 220,
|
||||
height: 220,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<Stack gap="lg" style={{ position: "relative" }}>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={56}
|
||||
radius="lg"
|
||||
variant="white"
|
||||
style={{ color: "var(--mantine-color-green-7)" }}
|
||||
>
|
||||
<ThemeIcon size={56} radius="lg" variant="light" color="green">
|
||||
<Train size={28} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4}>
|
||||
<Title order={2} c="white" fw={700}>
|
||||
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
||||
Train Schedules
|
||||
</Title>
|
||||
<Text size="sm" c="rgba(255,255,255,0.85)" maw={520}>
|
||||
<Text size="sm" c="dimmed" maw={520}>
|
||||
Plan departures, allocate bookings, and dispatch trains across
|
||||
every corridor.
|
||||
</Text>
|
||||
@@ -374,8 +376,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
<Button
|
||||
size="md"
|
||||
radius="lg"
|
||||
variant="white"
|
||||
c="green.8"
|
||||
color="green"
|
||||
leftSection={<Train size={18} />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
@@ -384,20 +385,10 @@ export default function TrainScheduleV2ListPage() {
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<StatTile onDark icon={Train} label="Total trains" value={stats.total} />
|
||||
<StatTile
|
||||
onDark
|
||||
icon={CalendarClock}
|
||||
label="Scheduled"
|
||||
value={stats.scheduled}
|
||||
/>
|
||||
<StatTile onDark icon={Send} label="Dispatched" value={stats.dispatched} />
|
||||
<StatTile
|
||||
onDark
|
||||
icon={Weight}
|
||||
label="Planned load"
|
||||
value={`${Math.round(stats.weight)}T`}
|
||||
/>
|
||||
<StatTile icon={Train} label="Total trains" value={stats.total} />
|
||||
<StatTile icon={CalendarClock} label="Scheduled" value={stats.scheduled} />
|
||||
<StatTile icon={Send} label="Dispatched" value={stats.dispatched} />
|
||||
<StatTile icon={Weight} label="Planned load" value={`${Math.round(stats.weight)}T`} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -493,6 +484,11 @@ export default function TrainScheduleV2ListPage() {
|
||||
`/dashboard/operations/train-scheduling-v2/${schedule.id}`,
|
||||
)
|
||||
}
|
||||
onTrack={() =>
|
||||
navigate(
|
||||
`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
@@ -528,6 +524,11 @@ export default function TrainScheduleV2ListPage() {
|
||||
onChange={(v) => setRouteId(v ?? "")}
|
||||
searchable
|
||||
/>
|
||||
{routeId ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{locomotiveReadinessHint}
|
||||
</Text>
|
||||
) : null}
|
||||
<TextInput
|
||||
label="Departure date"
|
||||
type="datetime-local"
|
||||
@@ -539,14 +540,20 @@ export default function TrainScheduleV2ListPage() {
|
||||
/>
|
||||
<Select
|
||||
label="Locomotive"
|
||||
placeholder="Select locomotive"
|
||||
placeholder={routeId ? "Select locomotive" : "Select a route first"}
|
||||
data={(locomotivesQuery.data ?? []).map((l) => ({
|
||||
value: l.id,
|
||||
label: `${l.code}${l.name ? ` — ${l.name}` : ""}`,
|
||||
label: `${l.code}${l.name ? ` — ${l.name}` : ""} · ${
|
||||
l.readiness === "EXPORT_READY" ? "Export-ready" : "Import-ready"
|
||||
}`,
|
||||
}))}
|
||||
value={locomotiveId || null}
|
||||
onChange={(v) => setLocomotiveId(v ?? "")}
|
||||
searchable
|
||||
disabled={!routeId}
|
||||
nothingFoundMessage={
|
||||
routeId ? "No available locomotives for this corridor" : "Select a route first"
|
||||
}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setCreateOpen(false)}>
|
||||
@@ -601,11 +608,14 @@ function MetricChip({
|
||||
function ScheduleCard({
|
||||
schedule,
|
||||
onOpen,
|
||||
onTrack,
|
||||
}: {
|
||||
schedule: TrainScheduleListItem;
|
||||
onOpen: () => void;
|
||||
onTrack: () => void;
|
||||
}) {
|
||||
const { day, time } = splitDate(schedule.scheduleDate);
|
||||
const canTrack = ["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
return (
|
||||
<Card
|
||||
radius="lg"
|
||||
@@ -667,20 +677,37 @@ function ScheduleCard({
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
size="sm"
|
||||
radius="md"
|
||||
fullWidth
|
||||
rightSection={<ArrowRight size={15} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
Open schedule
|
||||
</Button>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
size="sm"
|
||||
radius="md"
|
||||
style={{ flex: 1 }}
|
||||
rightSection={<ArrowRight size={15} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
Open schedule
|
||||
</Button>
|
||||
{canTrack ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
size="sm"
|
||||
radius="md"
|
||||
leftSection={<Navigation size={15} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTrack();
|
||||
}}
|
||||
>
|
||||
Track
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { cargoService, type Cargo } from "@/services/cargoService";
|
||||
import { containerService, type Container } from "@/services/containerService";
|
||||
import { locomotivesService, type Locomotive } from "@/services/locomotives.service";
|
||||
import {
|
||||
locomotivesService,
|
||||
type Locomotive,
|
||||
type LocomotiveListFilters,
|
||||
} from "@/services/locomotives.service";
|
||||
import { trainService, type Train } from "@/services/trains.service";
|
||||
import { wagonService, type Wagon } from "@/services/wagon.service";
|
||||
import { wagonService, type Wagon, type WagonListFilters } from "@/services/wagon.service";
|
||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||
|
||||
export type FleetRecord = Locomotive | Train | Wagon | Container | Cargo;
|
||||
|
||||
const listHandlers: Record<FleetResourceSlug, () => Promise<FleetRecord[]>> = {
|
||||
locomotives: () => locomotivesService.getAll().then((r) => r.data),
|
||||
export type FleetListFilters = WagonListFilters & LocomotiveListFilters;
|
||||
|
||||
const listHandlers: Record<
|
||||
FleetResourceSlug,
|
||||
(filters?: FleetListFilters) => Promise<FleetRecord[]>
|
||||
> = {
|
||||
locomotives: (filters) => locomotivesService.getAll(filters ?? {}).then((r) => r.data),
|
||||
trains: () => trainService.getAll().then((r) => r.data),
|
||||
wagons: () => wagonService.getAll().then((r) => r.data),
|
||||
wagons: (filters) => wagonService.getAll(filters ?? {}).then((r) => r.data),
|
||||
containers: () => containerService.getAll().then((r) => r.data),
|
||||
cargoes: () => cargoService.getAll().then((r) => r.data),
|
||||
};
|
||||
@@ -43,7 +52,7 @@ const removeHandlers: Record<FleetResourceSlug, (id: string) => Promise<unknown>
|
||||
};
|
||||
|
||||
export const fleetService = {
|
||||
list: (slug: FleetResourceSlug) => listHandlers[slug](),
|
||||
list: (slug: FleetResourceSlug, filters?: FleetListFilters) => listHandlers[slug](filters),
|
||||
create: (slug: FleetResourceSlug, data: Record<string, unknown>) => createHandlers[slug](data),
|
||||
update: (slug: FleetResourceSlug, id: string, data: Record<string, unknown>) =>
|
||||
updateHandlers[slug](id, data),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Freight } from '@edr/types';
|
||||
|
||||
import { api as apiClient } from '../auth/http';
|
||||
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
@@ -9,12 +11,18 @@ export type LocomotiveStatus =
|
||||
| 'ASSIGNED'
|
||||
| 'OUT_OF_SERVICE';
|
||||
|
||||
export interface LocomotiveListFilters {
|
||||
status?: LocomotiveStatus;
|
||||
readiness?: Freight.WagonReadiness;
|
||||
}
|
||||
|
||||
export interface Locomotive {
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string | null;
|
||||
locomotiveType: LocomotiveType;
|
||||
status: LocomotiveStatus;
|
||||
readiness: Freight.WagonReadiness;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
powerKw?: number | null;
|
||||
@@ -30,7 +38,15 @@ export type SaveLocomotivePayload = Omit<
|
||||
>;
|
||||
|
||||
export const locomotivesService = {
|
||||
getAll: () => apiClient.get<Locomotive[]>(URL_CONSTANTS.LOCOMOTIVES.BASE),
|
||||
getAll: (filters: LocomotiveListFilters = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.readiness) params.set('readiness', filters.readiness);
|
||||
const qs = params.toString();
|
||||
return apiClient.get<Locomotive[]>(
|
||||
`${URL_CONSTANTS.LOCOMOTIVES.BASE}${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
getById: (id: string) => apiClient.get<Locomotive>(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id)),
|
||||
create: (data: Partial<SaveLocomotivePayload>) =>
|
||||
apiClient.post(URL_CONSTANTS.LOCOMOTIVES.BASE, data),
|
||||
|
||||
@@ -2,18 +2,24 @@ import { api as client } from '../auth/http';
|
||||
import { unwrap } from '@/utils/endpoint';
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import type {
|
||||
BatchBoardSchedule,
|
||||
BatchBoardScheduleDetail,
|
||||
BookableSchedule,
|
||||
AssignBookingsPayload,
|
||||
CreateTrainSchedulePayload,
|
||||
EligibleContainerBookingsResponse,
|
||||
FreightType,
|
||||
LocomotiveRecord,
|
||||
PinWagonsPayload,
|
||||
RecordCheckpointPayload,
|
||||
TrainScheduleDetail,
|
||||
TrainScheduleFilters,
|
||||
TrainScheduleListItem,
|
||||
TrainSchedulePreviewPayload,
|
||||
TrainSchedulePreviewResponse,
|
||||
TrainSchedulingGlobalRules,
|
||||
TrainTrackResponse,
|
||||
WagonAllocationAttemptResult,
|
||||
YardOption,
|
||||
} from '@/types/trainScheduling';
|
||||
|
||||
@@ -75,6 +81,75 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getBatchBoard: async (): Promise<BatchBoardSchedule[]> => {
|
||||
const response = await client.get<BatchBoardSchedule[]>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BATCH_BOARD,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getBatchBoardDetail: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
|
||||
const response = await client.get<BatchBoardScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BATCH_BOARD_DETAIL(scheduleId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getBookableSchedules: async (
|
||||
originYardId?: string,
|
||||
destinationYardId?: string,
|
||||
): Promise<BookableSchedule[]> => {
|
||||
const response = await client.get<BookableSchedule[]>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKABLE_SCHEDULES,
|
||||
{ params: { originYardId, destinationYardId } },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
runBatch: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
|
||||
const response = await client.post<BatchBoardScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
runAllocation: async (scheduleId: string): Promise<WagonAllocationAttemptResult> => {
|
||||
const response = await client.post<WagonAllocationAttemptResult>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_ALLOCATION(scheduleId),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
setBookingWindow: async (
|
||||
scheduleId: string,
|
||||
status: "OPEN" | "CLOSED",
|
||||
): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.patch<TrainScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WINDOW(scheduleId),
|
||||
{ status },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
markBookingPaid: async (bookingId: string): Promise<void> => {
|
||||
await client.post(URL_CONSTANTS.TRAIN_SCHEDULING.MARK_BOOKING_PAID(bookingId), {});
|
||||
},
|
||||
|
||||
expireBooking: async (bookingId: string): Promise<void> => {
|
||||
await client.post(URL_CONSTANTS.TRAIN_SCHEDULING.EXPIRE_BOOKING(bookingId), {});
|
||||
},
|
||||
|
||||
moveBookingSchedule: async (
|
||||
bookingId: string,
|
||||
trainScheduleId: string,
|
||||
): Promise<void> => {
|
||||
await client.post(URL_CONSTANTS.TRAIN_SCHEDULING.MOVE_BOOKING_SCHEDULE(bookingId), {
|
||||
trainScheduleId,
|
||||
});
|
||||
},
|
||||
|
||||
getScheduleById: async (
|
||||
id: string,
|
||||
freightType?: FreightType,
|
||||
@@ -137,6 +212,32 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getTrack: async (scheduleId: string): Promise<TrainTrackResponse> => {
|
||||
const response = await client.get<TrainTrackResponse>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
recordCheckpoint: async (
|
||||
scheduleId: string,
|
||||
payload: RecordCheckpointPayload,
|
||||
): Promise<TrainTrackResponse> => {
|
||||
const response = await client.post<TrainTrackResponse>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
arriveSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.post<TrainScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.ARRIVE(scheduleId),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
cancelSchedule: async (
|
||||
id: string,
|
||||
freightType: FreightType = "CONTAINER",
|
||||
@@ -148,7 +249,14 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getAvailableLocomotives: async (): Promise<LocomotiveRecord[]> => {
|
||||
getAvailableLocomotives: async (routeId?: string): Promise<LocomotiveRecord[]> => {
|
||||
if (routeId) {
|
||||
const response = await client.get<LocomotiveRecord[]>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_LOCOMOTIVES,
|
||||
{ params: { routeId } },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
}
|
||||
const response = await client.get<LocomotiveRecord[]>(URL_CONSTANTS.LOCOMOTIVES.BASE, {
|
||||
params: { status: 'AVAILABLE' },
|
||||
});
|
||||
|
||||
@@ -15,8 +15,25 @@ export interface Wagon {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface WagonListFilters {
|
||||
search?: string;
|
||||
status?: Freight.WagonStatus;
|
||||
readiness?: Freight.WagonReadiness;
|
||||
wagonTypeId?: string;
|
||||
trainId?: string;
|
||||
}
|
||||
|
||||
export const wagonService = {
|
||||
getAll: () => apiClient.get<Wagon[]>('/wagons'),
|
||||
getAll: (filters: WagonListFilters = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.search?.trim()) params.set('search', filters.search.trim());
|
||||
if (filters.status) params.set('status', filters.status);
|
||||
if (filters.readiness) params.set('readiness', filters.readiness);
|
||||
if (filters.wagonTypeId) params.set('wagonTypeId', filters.wagonTypeId);
|
||||
if (filters.trainId) params.set('trainId', filters.trainId);
|
||||
const qs = params.toString();
|
||||
return apiClient.get<Wagon[]>(`/wagons${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
getById: (id: string) => apiClient.get<Wagon>(`/wagons/${id}`),
|
||||
getByTrain: (trainId: string) => apiClient.get<Wagon[]>(`/wagons?trainId=${trainId}`),
|
||||
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
|
||||
|
||||
@@ -127,6 +127,8 @@ export interface TrainSchedulePreviewResponse {
|
||||
containerSlotSequenceNos?: number[];
|
||||
}
|
||||
|
||||
export type Readiness = "IMPORT_READY" | "EXPORT_READY";
|
||||
|
||||
export interface LocomotiveRecord {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -134,6 +136,7 @@ export interface LocomotiveRecord {
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
status: "AVAILABLE" | "ASSIGNED" | "MAINTENANCE" | "OUT_OF_SERVICE";
|
||||
readiness?: Readiness | null;
|
||||
locomotiveType?: "DIESEL" | "ELECTRIC";
|
||||
}
|
||||
|
||||
@@ -150,6 +153,7 @@ export interface TrainScheduleListItem {
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string | null;
|
||||
readiness?: Readiness | null;
|
||||
}
|
||||
| null;
|
||||
wagonCount: number;
|
||||
@@ -159,6 +163,131 @@ export interface TrainScheduleListItem {
|
||||
status: TrainScheduleStatus | string;
|
||||
}
|
||||
|
||||
export interface BookableSchedule {
|
||||
id: string;
|
||||
scheduleDate: string;
|
||||
trainNumber?: string | null;
|
||||
routeName?: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
freightType?: FreightType | null;
|
||||
status: TrainScheduleStatus | string;
|
||||
bookingWindowStatus: "OPEN" | "FULL" | "CLOSED" | string;
|
||||
maxWagons: number;
|
||||
remainingWagons: number;
|
||||
locomotive: { id: string; code: string; name?: string | null } | null;
|
||||
}
|
||||
|
||||
export type BatchBoardBookingState =
|
||||
| "ALLOCATED"
|
||||
| "SELECTED_FOR_BATCH"
|
||||
| "READY"
|
||||
| "WAITING"
|
||||
| "PENDING_CONTRACT"
|
||||
| "EXPIRED";
|
||||
|
||||
export interface BatchBoardBooking {
|
||||
id: string;
|
||||
reference: string;
|
||||
company: string;
|
||||
isGovernment: boolean;
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
paymentDeadline: string | null;
|
||||
state: BatchBoardBookingState;
|
||||
}
|
||||
|
||||
export interface BatchBoardSchedule {
|
||||
scheduleId: string;
|
||||
trainNumber: string | null;
|
||||
routeName: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
scheduleDate: string | null;
|
||||
status: string;
|
||||
bookingWindowStatus: string;
|
||||
locomotive: {
|
||||
code: string;
|
||||
name: string | null;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
} | null;
|
||||
capacity: {
|
||||
allocatedWagons: number;
|
||||
allocatedLengthMeters: number;
|
||||
maxLengthMeters: number | null;
|
||||
usedWeightTons: number;
|
||||
maxWeightTons: number | null;
|
||||
};
|
||||
counts: {
|
||||
allocated: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
pendingContract: number;
|
||||
expired: number;
|
||||
};
|
||||
bookings: BatchBoardBooking[];
|
||||
}
|
||||
|
||||
export type BookingAllocationStatus =
|
||||
| "NOT_ATTEMPTED"
|
||||
| "ASSIGNED"
|
||||
| "DEFERRED"
|
||||
| "FAILED";
|
||||
|
||||
export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
fullyExecutedAt: string | null;
|
||||
selectedForBatchAt: string | null;
|
||||
allocationStatus: BookingAllocationStatus;
|
||||
allocationIssue: string | null;
|
||||
}
|
||||
|
||||
export interface BatchWindowGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
start: string;
|
||||
end: string;
|
||||
counts: {
|
||||
allocated: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
expired: number;
|
||||
pendingContract: number;
|
||||
};
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
}
|
||||
|
||||
export interface BatchBoardScheduleDetail {
|
||||
scheduleId: string;
|
||||
trainNumber: string | null;
|
||||
routeName: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
scheduleDate: string | null;
|
||||
status: string;
|
||||
bookingWindowStatus: string;
|
||||
locomotive: BatchBoardSchedule["locomotive"];
|
||||
capacity: BatchBoardSchedule["capacity"];
|
||||
counts: BatchBoardSchedule["counts"];
|
||||
windows: BatchWindowGroup[];
|
||||
pendingContract: BatchWindowGroup;
|
||||
allocationViolations: string[];
|
||||
}
|
||||
|
||||
export interface WagonAllocationAttemptResult {
|
||||
assignedBookingIds: string[];
|
||||
deferred: Array<{ id: string; reference: string; reason: string }>;
|
||||
issues: Array<{
|
||||
bookingId: string;
|
||||
status: BookingAllocationStatus;
|
||||
issue: string | null;
|
||||
}>;
|
||||
violations: string[];
|
||||
}
|
||||
|
||||
export interface TrainScheduleWagonAllocation {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
@@ -197,6 +326,7 @@ export interface TrainScheduleDetail {
|
||||
scheduledDepartureDate: string;
|
||||
scheduledArrivalDate?: string | null;
|
||||
actualDepartureAt?: string | null;
|
||||
actualArrivalAt?: string | null;
|
||||
originStation?: {
|
||||
id: string;
|
||||
label?: string;
|
||||
@@ -218,6 +348,7 @@ export interface TrainScheduleDetail {
|
||||
code: string;
|
||||
name?: string | null;
|
||||
status: string;
|
||||
readiness?: Readiness | null;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters?: number;
|
||||
} | null;
|
||||
@@ -249,10 +380,52 @@ export interface TrainScheduleDetail {
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export type TrainCheckpointKind = "DEPARTED" | "PASSED" | "ARRIVED";
|
||||
|
||||
export interface TrackStation {
|
||||
sequenceNo: number;
|
||||
yardId: string;
|
||||
label: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface TrainCheckpoint {
|
||||
id: string;
|
||||
sequenceNo: number;
|
||||
yardId: string;
|
||||
label: string | null;
|
||||
kind: TrainCheckpointKind;
|
||||
occurredAt: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface TrainTrackResponse {
|
||||
scheduleId: string;
|
||||
status: TrainScheduleStatus | string;
|
||||
direction?: string | null;
|
||||
trainNumber?: string | null;
|
||||
actualDepartureAt?: string | null;
|
||||
actualArrivalAt?: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
stations: TrackStation[];
|
||||
currentSequenceNo: number;
|
||||
checkpoints: TrainCheckpoint[];
|
||||
}
|
||||
|
||||
export interface RecordCheckpointPayload {
|
||||
sequenceNo: number;
|
||||
kind?: TrainCheckpointKind;
|
||||
occurredAt?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface TrainScheduleFilters {
|
||||
originStationId?: string;
|
||||
destinationStationId?: string;
|
||||
schedulingStatus?: SchedulingStatus;
|
||||
/** Scope eligible bookings to a single schedule (batch parity). */
|
||||
trainScheduleId?: string;
|
||||
}
|
||||
|
||||
export interface TrainSchedulePreviewPayload {
|
||||
|
||||
@@ -53,6 +53,12 @@ export enum BookingStatus {
|
||||
FullyExecuted = "FULLY_EXECUTED",
|
||||
PnrGenerated = "PNR_GENERATED",
|
||||
PaymentVerificationInProgress = "PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
/** Selected in a batch and notified to pay within the pay window. */
|
||||
SelectedForBatch = "SELECTED_FOR_BATCH",
|
||||
/** @deprecated Use SelectedForBatch */
|
||||
AwaitingPayment = "SELECTED_FOR_BATCH",
|
||||
/** Missed the 1h pay window — recoverable via move/cancel (no re-approval). */
|
||||
Expired = "EXPIRED",
|
||||
Paid = "PAID",
|
||||
InTransit = "IN_TRANSIT",
|
||||
Completed = "COMPLETED",
|
||||
@@ -113,6 +119,13 @@ export enum TrainScheduleStatus {
|
||||
Cancelled = "CANCELLED",
|
||||
}
|
||||
|
||||
/** Whether a schedule is still accepting / holding bookings (orthogonal to its operational status). */
|
||||
export enum ScheduleBookingWindow {
|
||||
Open = "OPEN",
|
||||
Full = "FULL",
|
||||
Closed = "CLOSED",
|
||||
}
|
||||
|
||||
export enum AllocationLoadType {
|
||||
Container = "CONTAINER",
|
||||
Bulk = "BULK",
|
||||
@@ -146,6 +159,22 @@ export enum WagonReadiness {
|
||||
|
||||
export type ScheduleTradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
|
||||
|
||||
export enum TrainCheckpointKind {
|
||||
Departed = "DEPARTED",
|
||||
Passed = "PASSED",
|
||||
Arrived = "ARRIVED",
|
||||
}
|
||||
|
||||
export interface ITrainCheckpointEvent extends BaseEntity {
|
||||
trainScheduleId: string;
|
||||
yardId: string;
|
||||
sequenceNo: number;
|
||||
kind: TrainCheckpointKind;
|
||||
occurredAt: string;
|
||||
note?: string | null;
|
||||
recordedByUserId?: string | null;
|
||||
}
|
||||
|
||||
export enum BulkPricingUnit {
|
||||
PerWagon = "PER_WAGON",
|
||||
PerTon = "PER_TON",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -68,6 +68,9 @@ importers:
|
||||
'@nestjs/platform-express':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||
'@nestjs/schedule':
|
||||
specifier: ^6.1.3
|
||||
version: 6.1.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||
'@nestjs/swagger':
|
||||
specifier: ^11.4.2
|
||||
version: 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
|
||||
|
||||
Reference in New Issue
Block a user