diff --git a/apps/edr-freight-api/nest-cli.json b/apps/edr-freight-api/nest-cli.json
index 6c524a8a1..f4a3b488d 100644
--- a/apps/edr-freight-api/nest-cli.json
+++ b/apps/edr-freight-api/nest-cli.json
@@ -4,7 +4,10 @@
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
- "assets": [{ "include": "migrations/**/*", "outDir": "dist" }],
+ "assets": [
+ { "include": "migrations/**/*", "outDir": "dist" },
+ { "include": "contracts/templates/**/*", "watchAssets": true }
+ ],
"watchAssets": true
}
}
diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json
index 41c570010..e086598b8 100644
--- a/apps/edr-freight-api/package.json
+++ b/apps/edr-freight-api/package.json
@@ -32,11 +32,12 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^17.4.2",
+ "handlebars": "^4.7.9",
"minio": "7.1.3",
+ "puppeteer": "^24.2.0",
"pg": "^8.13.0",
"reflect-metadata": "^0.2.2",
- "rxjs": "^7.8.1",
- "typeorm": "^0.3.20"
+ "rxjs": "^7.8.1"
},
"devDependencies": {
"@edr/api-common": "workspace:*",
@@ -58,6 +59,7 @@
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
+ "typeorm": "^1.0.0",
"typescript": "^5.5.4"
},
"jest": {
diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index 1f9e7a5ce..af3d37dce 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -12,7 +12,13 @@ import databaseConfig from "./config/database.config";
import { BookingsModule } from "./modules/bookings/bookings.module";
import { FilesModule } from "./modules/files/files.module";
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
-import { TrainsModule } from "./modules/trains/trains.module";
+
+//import { TrainsModule } from "./modules/trains/trains.module";
+import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
+import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
+import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
+import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
+import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
import { CustomersModule } from "./modules/customers/customers.module";
import { CompaniesModule } from "./modules/companies/companies.module";
import { TrackingModule } from "./modules/tracking/tracking.module";
@@ -31,6 +37,14 @@ import {
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
+import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
+import { PricingDataSeeder } from "./seed/pricing-data.seeder";
+import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
+//New Trains, Wagons, Container and Cargo management modules
+import { TrainsModule } from "./modules/trains/trains.module";
+import { WagonsModule } from './modules/wagons/wagons.module';
+import { ContainersModule } from './modules/container-management/containers.module';
+import { CargoesModule } from './modules/cargoes/cargoes.module';
@Module({
imports: [
@@ -60,6 +74,11 @@ import { PaymentModule } from "./modules/payment/payment.module";
FilesModule,
ConsignmentsModule,
TrainsModule,
+ LocomotivesModule,
+ WagonTypesModule,
+ TrainSetsModule,
+ TrainSchedulesModule,
+ TrainSchedulingModule,
CustomersModule,
CompaniesModule,
TrackingModule,
@@ -72,19 +91,30 @@ import { PaymentModule } from "./modules/payment/payment.module";
BackofficeModule,
DemoPermissionsModule,
PaymentModule
+ //New Modules
+ TrainsModule,
+ WagonsModule,
+ ContainersModule,
+ CargoesModule,
],
- providers: [EdrOrgSeeder, DemoUsersSeeder],
+ providers: [EdrOrgSeeder, DemoUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
})
export class AppModule implements OnApplicationBootstrap {
constructor(
private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
+ private readonly demoBookingsSeeder: DemoBookingsSeeder,
+ private readonly pricingDataSeeder: PricingDataSeeder,
+ private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
) { }
async onApplicationBootstrap() {
await this.seeder.run();
await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run();
+ await this.demoBookingsSeeder.run();
+ await this.pricingDataSeeder.run();
+ await this.fileUploadSettingsSeeder.run();
}
}
diff --git a/apps/edr-freight-api/src/common/resolve-auth-user-id.ts b/apps/edr-freight-api/src/common/resolve-auth-user-id.ts
new file mode 100644
index 000000000..cab29b671
--- /dev/null
+++ b/apps/edr-freight-api/src/common/resolve-auth-user-id.ts
@@ -0,0 +1,12 @@
+import { UnauthorizedException } from '@nestjs/common';
+
+export type AuthUserPayload = { id?: string; sub?: string } | null | undefined;
+
+/** Resolve IAM user id from JWT payload attached by JwtGuard. */
+export function resolveAuthUserId(user: AuthUserPayload): string {
+ const id = user?.id ?? user?.sub;
+ if (!id) {
+ throw new UnauthorizedException('Authentication required');
+ }
+ return id;
+}
diff --git a/apps/edr-freight-api/src/contracts/contract-clause-packs.ts b/apps/edr-freight-api/src/contracts/contract-clause-packs.ts
new file mode 100644
index 000000000..2d73cd50d
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-clause-packs.ts
@@ -0,0 +1,224 @@
+import type {
+ Article1Clause,
+ ContractClausePack,
+ ContractDirection,
+ ContractFreight,
+ ContractServiceScope,
+} from './contract-template.types';
+
+const STANDARD_CONTRACT_DOCUMENTS = [
+ 'Amendments (if any)',
+ 'This Contract Agreement',
+ 'Final Minutes of Negotiation (if any)',
+];
+
+const PAYMENT_OBLIGATION =
+ 'Pay 100% transportation fees in advance per train set in accordance with Article 5.';
+
+const HAZARDOUS_OBLIGATION =
+ 'Notify EDR 48 hours in advance for hazardous or valuable cargo.';
+
+function clonePack(pack: ContractClausePack): ContractClausePack {
+ return {
+ article1: {
+ objective: pack.article1.objective,
+ scope: [...pack.article1.scope],
+ },
+ clientObligations: [...pack.clientObligations],
+ providerObligations: [...pack.providerObligations],
+ contractDocuments: [...pack.contractDocuments],
+ };
+}
+
+function applyForwardingOverlay(
+ pack: ContractClausePack,
+ service: ContractServiceScope,
+): ContractClausePack {
+ if (service !== 'FORWARDING') return pack;
+
+ const next = clonePack(pack);
+ next.article1.scope.push(
+ 'First-mile and/or last-mile coordination, documentation, and handover with road or port partners where included in the agreed service scope.',
+ );
+ next.providerObligations.push(
+ 'Coordinate first-mile and last-mile logistics with designated partners and keep the Client informed of handover milestones.',
+ );
+ return next;
+}
+
+function buildImportContainerPack(): ContractClausePack {
+ return {
+ article1: {
+ objective:
+ 'To provide railway transportation for 40ft and/or 20ft full containers from SGTD to Dire Dawa, Modjo dry port and/or Galaan Multipurpose port (GMP), and empty container return from those terminals to SGTD.',
+ scope: [
+ 'Railway transport service on the agreed import corridor.',
+ 'Cargo handling at Galaan Multipurpose port (GMP) where applicable.',
+ ],
+ },
+ clientObligations: [
+ 'Provide shipment instructions to EDR for container movements on the agreed corridor.',
+ 'Meet minimum container supply per terminal (Modjo, Dire Dawa, GMP) as per EDR operational rules.',
+ 'Submit required documents to Djibouti Nagad station at least 24 hours before loading.',
+ PAYMENT_OBLIGATION,
+ HAZARDOUS_OBLIGATION,
+ ],
+ providerObligations: [
+ 'Assign voyage per operational schedule and notify train schedule 48 hours in advance.',
+ 'Provide safe transportation and deliver within agreed timelines when documents are complete.',
+ 'Return empty containers from Dire Dawa, Modjo and GMP to SGTD within seven (7) calendar days of receipt.',
+ 'Maintain cargo liability insurance per wagon.',
+ ],
+ contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
+ };
+}
+
+function buildImportBulkPack(): ContractClausePack {
+ return {
+ article1: {
+ objective:
+ 'To provide railway transportation for bulk cargo from SGTD railway freight station at Djibouti to designated Ethiopian rail terminals on the import corridor.',
+ scope: [
+ 'Railway bulk transport service on the agreed import corridor.',
+ 'Loading and unloading coordination at designated terminals per EDR operational rules.',
+ ],
+ },
+ clientObligations: [
+ 'Provide accurate commodity description, weight, and shipment instructions for each train movement.',
+ 'Ensure cargo is prepared and available at origin per the agreed loading window.',
+ 'Submit required customs and operational documents at least 24 hours before loading where applicable.',
+ PAYMENT_OBLIGATION,
+ HAZARDOUS_OBLIGATION,
+ ],
+ providerObligations: [
+ 'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
+ 'Provide safe bulk transportation and deliver within agreed timelines when documents are complete.',
+ 'Maintain cargo liability insurance per wagon or train consist as applicable.',
+ ],
+ contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
+ };
+}
+
+function buildExportContainerPack(): ContractClausePack {
+ return {
+ article1: {
+ objective:
+ 'To provide railway transportation for 40ft and/or 20ft full containers from designated Ethiopian dry ports and terminals to SGTD and related export corridors.',
+ scope: [
+ 'Railway export transport service on the agreed corridor.',
+ 'Terminal coordination at origin yards for export dispatch where applicable.',
+ ],
+ },
+ clientObligations: [
+ 'Provide export shipment instructions and container release details for each movement.',
+ 'Ensure containers are available at origin terminals per EDR operational windows.',
+ 'Submit required export, customs, and operational documents at origin at least 24 hours before loading.',
+ PAYMENT_OBLIGATION,
+ HAZARDOUS_OBLIGATION,
+ ],
+ providerObligations: [
+ 'Assign voyage per operational schedule and notify train schedule 48 hours in advance.',
+ 'Provide safe transportation to SGTD and hand over for export processing when documents are complete.',
+ 'Maintain cargo liability insurance per wagon.',
+ ],
+ contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
+ };
+}
+
+function buildExportBulkPack(): ContractClausePack {
+ return {
+ article1: {
+ objective:
+ 'To provide railway transportation for bulk export cargo from designated Ethiopian rail terminals to SGTD and related export corridors.',
+ scope: [
+ 'Railway bulk export transport on the agreed corridor.',
+ 'Loading coordination at origin terminals per EDR operational rules.',
+ ],
+ },
+ clientObligations: [
+ 'Provide accurate commodity description, weight, and export shipment instructions.',
+ 'Ensure bulk cargo is prepared and available at origin per the agreed loading window.',
+ 'Submit required export and customs documents at least 24 hours before loading where applicable.',
+ PAYMENT_OBLIGATION,
+ HAZARDOUS_OBLIGATION,
+ ],
+ providerObligations: [
+ 'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
+ 'Provide safe bulk transportation to SGTD within agreed timelines when documents are complete.',
+ 'Maintain cargo liability insurance per wagon or train consist as applicable.',
+ ],
+ contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
+ };
+}
+
+function buildDomesticContainerPack(): ContractClausePack {
+ return {
+ article1: {
+ objective:
+ 'To provide railway transportation for 40ft and/or 20ft containers between designated Ethiopian rail terminals on the domestic corridor.',
+ scope: ['Domestic railway container transport between agreed origin and destination yards.'],
+ },
+ clientObligations: [
+ 'Provide shipment instructions for each domestic container movement.',
+ 'Ensure containers are available at origin per EDR operational rules.',
+ PAYMENT_OBLIGATION,
+ HAZARDOUS_OBLIGATION,
+ ],
+ providerObligations: [
+ 'Assign voyage per operational schedule and notify train schedule 48 hours in advance where practicable.',
+ 'Provide safe transportation and deliver within agreed timelines when instructions are complete.',
+ 'Maintain cargo liability insurance per wagon.',
+ ],
+ contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
+ };
+}
+
+function buildDomesticBulkPack(): ContractClausePack {
+ return {
+ article1: {
+ objective:
+ 'To provide railway transportation for bulk cargo between designated Ethiopian rail terminals on the domestic corridor.',
+ scope: ['Domestic railway bulk transport between agreed origin and destination terminals.'],
+ },
+ clientObligations: [
+ 'Provide commodity description, weight, and shipment instructions for each movement.',
+ 'Ensure cargo is prepared at origin per the agreed loading window.',
+ PAYMENT_OBLIGATION,
+ HAZARDOUS_OBLIGATION,
+ ],
+ providerObligations: [
+ 'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
+ 'Provide safe bulk transportation within agreed timelines.',
+ 'Maintain cargo liability insurance per wagon or train consist as applicable.',
+ ],
+ contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
+ };
+}
+
+const BASE_PACKS: Record ContractClausePack>> = {
+ IMP: {
+ CON: buildImportContainerPack,
+ BULK: buildImportBulkPack,
+ },
+ EXP: {
+ CON: buildExportContainerPack,
+ BULK: buildExportBulkPack,
+ },
+ DOM: {
+ CON: buildDomesticContainerPack,
+ BULK: buildDomesticBulkPack,
+ },
+};
+
+export function buildClausePack(
+ direction: ContractDirection,
+ freight: ContractFreight,
+ service: ContractServiceScope,
+): ContractClausePack {
+ const base = BASE_PACKS[direction][freight]();
+ return applyForwardingOverlay(base, service);
+}
+
+export function article1ObjectiveFromClause(article1: Article1Clause): string {
+ return article1.objective;
+}
diff --git a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts
new file mode 100644
index 000000000..399559fb2
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts
@@ -0,0 +1,57 @@
+import { Injectable, Logger } from '@nestjs/common';
+
+@Injectable()
+export class ContractPdfService {
+ private readonly logger = new Logger(ContractPdfService.name);
+
+ async htmlToPdfBuffer(html: string): Promise {
+ try {
+ const puppeteer = await import('puppeteer');
+ const browser = await puppeteer.default.launch({
+ headless: true,
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
+ });
+ try {
+ const page = await browser.newPage();
+ await page.setContent(html, { waitUntil: 'load' });
+ const pdf = await page.pdf({
+ format: 'A4',
+ printBackground: true,
+ margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
+ });
+ return Buffer.from(pdf);
+ } finally {
+ await browser.close();
+ }
+ } catch (err) {
+ this.logger.warn(
+ `Puppeteer PDF failed, falling back to minimal PDF stub: ${err}`,
+ );
+ return this.fallbackPdfBuffer(html);
+ }
+ }
+
+ /** Minimal valid PDF when Chromium is unavailable. */
+ private fallbackPdfBuffer(html: string): Buffer {
+ const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').slice(0, 2000);
+ const escaped = text.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
+ const stream = `BT /F1 10 Tf 50 750 Td (${escaped}) Tj ET`;
+ const len = stream.length;
+ const pdf = `%PDF-1.4
+1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj
+2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj
+3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>endobj
+4 0 obj<< /Length ${len} >>stream
+${stream}
+endstream endobj
+5 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>endobj
+xref
+0 6
+0000000000 65535 f
+trailer<< /Size 6 /Root 1 0 R >>
+startxref
+0
+%%EOF`;
+ return Buffer.from(pdf, 'utf-8');
+ }
+}
diff --git a/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts
new file mode 100644
index 000000000..f9dc0b7aa
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts
@@ -0,0 +1,70 @@
+import { Injectable } from '@nestjs/common';
+
+import { BookingPricingService } from '../modules/bookings/booking-pricing.service';
+import { Booking } from '../modules/bookings/entities/booking.entity';
+import { PriceLineItemDto } from '../modules/bookings/dto/generate-price-response.dto';
+
+export interface PricingScheduleRow {
+ label: string;
+ description: string;
+ amount: number;
+ currency: string;
+}
+
+export interface PricingSchedule {
+ lineItems: PricingScheduleRow[];
+ surcharges: PricingScheduleRow[];
+ totalAmount: number;
+ currency: string;
+ equipmentReturn?: string;
+ originLabel: string;
+ destinationLabel: string;
+ containerLines: Array<{
+ label: string;
+ quantity: number;
+ vgmPerUnitTons: number;
+ }>;
+}
+
+@Injectable()
+export class ContractPricingScheduleBuilder {
+ constructor(private readonly pricingService: BookingPricingService) {}
+
+ async build(booking: Booking): Promise {
+ const { lineItems, totalAmount, currency } =
+ await this.pricingService.computeContractLineItems(booking);
+
+ const isSurcharge = (l: PriceLineItemDto) =>
+ l.code.includes('SURCHARGE') || l.description.toLowerCase().includes('surcharge');
+
+ const baseLines = lineItems.filter((l) => !isSurcharge(l));
+ const surchargeLines = lineItems.filter(isSurcharge);
+
+ return {
+ lineItems: baseLines.map((l) => ({
+ label: l.code,
+ description: l.description,
+ amount: l.amount,
+ currency: l.currency,
+ })),
+ surcharges: surchargeLines.map((l) => ({
+ label: l.code,
+ description: l.description,
+ amount: l.amount,
+ currency: l.currency,
+ })),
+ totalAmount,
+ currency,
+ equipmentReturn: booking.equipmentReturn ?? undefined,
+ originLabel: booking.originYard?.label ?? booking.originYard?.code ?? '—',
+ destinationLabel:
+ booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—',
+ containerLines: (booking.bookingContainers ?? []).map((c) => ({
+ label:
+ c.containerType?.label ?? c.containerType?.code ?? c.containerTypeId,
+ quantity: c.quantity,
+ vgmPerUnitTons: Number(c.vgmPerUnitTons),
+ })),
+ };
+ }
+}
diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts
new file mode 100644
index 000000000..421621808
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts
@@ -0,0 +1,57 @@
+import { ContractRendererService } from './contract-renderer.service';
+import { getTemplateMeta } from './contract-template.registry';
+import type { ContractViewModel } from './contract-view-model.builder';
+
+describe('ContractRendererService', () => {
+ const renderer = new ContractRendererService();
+ renderer.onModuleInit();
+
+ function minimalView(templateKey: string): ContractViewModel {
+ const template = getTemplateMeta(templateKey);
+ return {
+ bookingId: 'test-id',
+ reference: 'BK-TEST-001',
+ status: 'CONTRACT_READY',
+ templateKey,
+ template,
+ contractDate: '1 January 2026',
+ contractYear: 2026,
+ client: {
+ companyName: 'Test Co',
+ companyAddress: 'Addis Ababa',
+ companyLocation: 'Ethiopia',
+ phone: '+251900000000',
+ email: 'test@example.com',
+ tinNumber: '1234567890',
+ },
+ pricing: {
+ lineItems: [{ label: 'RAIL', description: 'Rail transport', amount: 1000, currency: 'ETB' }],
+ surcharges: [],
+ totalAmount: 1000,
+ currency: 'ETB',
+ originLabel: 'SGTD',
+ destinationLabel: 'Modjo',
+ containerLines: [{ label: '40ft', quantity: 2, vgmPerUnitTons: 12 }],
+ },
+ signatures: [],
+ canSignCustomer: true,
+ canSignStaff: false,
+ hasContractDocument: false,
+ hasCustomerSignature: false,
+ hasStaffSignature: false,
+ };
+ }
+
+ it('renders import flagship with Nagad and Article 5', () => {
+ const html = renderer.render(minimalView('IMP_CON_ETB_TRANSPORT_ONLY'));
+ expect(html).toContain('Djibouti Nagad');
+ expect(html).toContain('Article 5: Contract Price');
+ expect(html).toContain('Article 2: Obligations of the Client');
+ });
+
+ it('renders export variant without import empty-return clause', () => {
+ const html = renderer.render(minimalView('EXP_CON_USD_TRANSPORT_ONLY'));
+ expect(html).toContain('export corridors');
+ expect(html).not.toContain('Return empty containers from Dire Dawa');
+ });
+});
diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts
new file mode 100644
index 000000000..dd539df25
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts
@@ -0,0 +1,51 @@
+import { Injectable, OnModuleInit } from '@nestjs/common';
+import * as fs from 'fs';
+import * as path from 'path';
+import Handlebars from 'handlebars';
+
+import { ContractViewModel } from './contract-view-model.builder';
+
+@Injectable()
+export class ContractRendererService implements OnModuleInit {
+ private readonly templatesDir = path.join(__dirname, 'templates');
+ private readonly compiled = new Map();
+
+ onModuleInit(): void {
+ Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b);
+
+ const partialsDir = path.join(this.templatesDir, '_partials');
+ if (fs.existsSync(partialsDir)) {
+ for (const file of fs.readdirSync(partialsDir)) {
+ if (!file.endsWith('.hbs')) continue;
+ const name = file.replace(/\.hbs$/, '');
+ const content = fs.readFileSync(path.join(partialsDir, file), 'utf-8');
+ Handlebars.registerPartial(name, content);
+ }
+ }
+ }
+
+ render(view: ContractViewModel): string {
+ const fileName =
+ view.template.templateFile ?? 'generic.hbs';
+ const template = this.getCompiled(fileName);
+ return template({
+ ...view,
+ paymentArticle: view.pricing.currency === 'ETB' ? 'ETB' : 'USD',
+ });
+ }
+
+ private getCompiled(fileName: string): Handlebars.TemplateDelegate {
+ const cached = this.compiled.get(fileName);
+ if (cached) return cached;
+
+ const filePath = path.join(this.templatesDir, fileName);
+ const fallbackPath = path.join(this.templatesDir, 'generic.hbs');
+ const source = fs.existsSync(filePath)
+ ? fs.readFileSync(filePath, 'utf-8')
+ : fs.readFileSync(fallbackPath, 'utf-8');
+
+ const compiled = Handlebars.compile(source);
+ this.compiled.set(fileName, compiled);
+ return compiled;
+ }
+}
diff --git a/apps/edr-freight-api/src/contracts/contract-template.registry.spec.ts b/apps/edr-freight-api/src/contracts/contract-template.registry.spec.ts
new file mode 100644
index 000000000..2c921889a
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-template.registry.spec.ts
@@ -0,0 +1,68 @@
+import {
+ CONTRACT_TEMPLATE_KEYS,
+ CONTRACT_TEMPLATE_REGISTRY,
+ getTemplateMeta,
+ isValidTemplateKey,
+} from './contract-template.registry';
+
+describe('ContractTemplateRegistry', () => {
+ it('defines exactly 24 template keys', () => {
+ expect(CONTRACT_TEMPLATE_KEYS).toHaveLength(24);
+ expect(Object.keys(CONTRACT_TEMPLATE_REGISTRY)).toHaveLength(24);
+ });
+
+ it('keys match the direction_freight_currency_service pattern', () => {
+ for (const key of CONTRACT_TEMPLATE_KEYS) {
+ expect(isValidTemplateKey(key)).toBe(true);
+ }
+ });
+
+ it('each meta has non-empty obligations and article1 scope', () => {
+ for (const key of CONTRACT_TEMPLATE_KEYS) {
+ const meta = CONTRACT_TEMPLATE_REGISTRY[key]!;
+ expect(meta.clientObligations.length).toBeGreaterThan(0);
+ expect(meta.providerObligations.length).toBeGreaterThan(0);
+ expect(meta.article1.scope.length).toBeGreaterThan(0);
+ expect(meta.article1.objective.length).toBeGreaterThan(0);
+ expect(meta.contractDocuments.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('IMP_CON_ETB_TRANSPORT_ONLY retains import container flagship clauses', () => {
+ const meta = getTemplateMeta('IMP_CON_ETB_TRANSPORT_ONLY');
+ expect(meta.direction).toBe('IMP');
+ expect(meta.freight).toBe('CON');
+ expect(meta.article1.objective).toContain('SGTD');
+ expect(meta.article1.objective).toContain('empty container return');
+ const clientText = meta.clientObligations.join(' ');
+ expect(clientText).toContain('Djibouti Nagad');
+ const providerText = meta.providerObligations.join(' ');
+ expect(providerText).toContain('seven (7) calendar days');
+ });
+
+ it('EXP_CON_USD_TRANSPORT_ONLY uses export-oriented article1', () => {
+ const meta = getTemplateMeta('EXP_CON_USD_TRANSPORT_ONLY');
+ expect(meta.direction).toBe('EXP');
+ expect(meta.article1.objective).toContain('SGTD');
+ expect(meta.providerObligations.join(' ')).not.toContain(
+ 'Return empty containers from Dire Dawa',
+ );
+ });
+
+ it('FORWARDING adds scope and provider obligations', () => {
+ const transport = getTemplateMeta('IMP_CON_ETB_TRANSPORT_ONLY');
+ const forwarding = getTemplateMeta('IMP_CON_ETB_FORWARDING');
+ expect(forwarding.article1.scope.length).toBeGreaterThan(
+ transport.article1.scope.length,
+ );
+ expect(forwarding.providerObligations.length).toBeGreaterThan(
+ transport.providerObligations.length,
+ );
+ });
+
+ it('getTemplateMeta fallback includes clause arrays for unknown keys', () => {
+ const meta = getTemplateMeta('UNKNOWN_KEY');
+ expect(meta.clientObligations.length).toBeGreaterThan(0);
+ expect(meta.article1.scope.length).toBeGreaterThan(0);
+ });
+});
diff --git a/apps/edr-freight-api/src/contracts/contract-template.registry.ts b/apps/edr-freight-api/src/contracts/contract-template.registry.ts
new file mode 100644
index 000000000..3e91e1b4f
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-template.registry.ts
@@ -0,0 +1,119 @@
+import {
+ article1ObjectiveFromClause,
+ buildClausePack,
+} from './contract-clause-packs';
+import type {
+ ContractDirection,
+ ContractFreight,
+ ContractServiceScope,
+ ContractTemplateMeta,
+} from './contract-template.types';
+
+export type { ContractTemplateMeta } from './contract-template.types';
+
+const DIRECTION_LABELS: Record = {
+ IMP: 'Import',
+ EXP: 'Export',
+ DOM: 'Domestic',
+};
+
+const FREIGHT_LABELS: Record = {
+ CON: 'Container',
+ BULK: 'Bulk',
+};
+
+const DIRECTIONS: ContractDirection[] = ['IMP', 'EXP', 'DOM'];
+const FREIGHTS: ContractFreight[] = ['CON', 'BULK'];
+const CURRENCIES = ['ETB', 'USD'] as const;
+const SERVICES: ContractServiceScope[] = ['TRANSPORT_ONLY', 'FORWARDING'];
+
+const KEY_PATTERN =
+ /^(IMP|EXP|DOM)_(CON|BULK)_(ETB|USD)_(TRANSPORT_ONLY|FORWARDING)$/;
+
+function buildMeta(
+ dir: ContractDirection,
+ freight: ContractFreight,
+ currency: string,
+ service: ContractServiceScope,
+): ContractTemplateMeta {
+ const key = `${dir}_${freight}_${currency}_${service}`;
+ const dirLabel = DIRECTION_LABELS[dir];
+ const freightLabel = FREIGHT_LABELS[freight];
+ const serviceLabel =
+ service === 'FORWARDING' ? 'Rail and Forwarding' : 'Transport Only';
+
+ const corridor =
+ dir === 'IMP'
+ ? 'from SGTD railway freight station at Djibouti to Ethiopian dry ports and return of empty containers as applicable'
+ : dir === 'EXP'
+ ? 'from Ethiopian dry ports to SGTD and related export corridors'
+ : 'between designated Ethiopian rail terminals';
+
+ const clauses = buildClausePack(dir, freight, service);
+
+ return {
+ key,
+ direction: dir,
+ freight,
+ currency,
+ serviceScope: service,
+ title: `${dirLabel} ${freightLabel} Transport Service by Railway (${serviceLabel})`,
+ directionLabel: dirLabel,
+ freightLabel,
+ whereas: `The Client has requested transportation of ${freightLabel.toLowerCase()} cargo ${corridor} using the Addis Ababa–Djibouti Railway line. The Service Provider has agreed to provide services per this contract.`,
+ article1Objective: article1ObjectiveFromClause(clauses.article1),
+ article1: clauses.article1,
+ clientObligations: clauses.clientObligations,
+ providerObligations: clauses.providerObligations,
+ contractDocuments: clauses.contractDocuments,
+ };
+}
+
+/** Full template matrix (24 keys). */
+export const CONTRACT_TEMPLATE_REGISTRY: Record =
+ {};
+
+for (const dir of DIRECTIONS) {
+ for (const freight of FREIGHTS) {
+ for (const currency of CURRENCIES) {
+ for (const service of SERVICES) {
+ const meta = buildMeta(dir, freight, currency, service);
+ CONTRACT_TEMPLATE_REGISTRY[meta.key] = meta;
+ }
+ }
+ }
+}
+
+export const CONTRACT_TEMPLATE_KEYS = Object.keys(CONTRACT_TEMPLATE_REGISTRY);
+
+export function listTemplateKeys(): string[] {
+ return CONTRACT_TEMPLATE_KEYS;
+}
+
+export function getTemplateMeta(key: string): ContractTemplateMeta {
+ const found = CONTRACT_TEMPLATE_REGISTRY[key];
+ if (found) return found;
+
+ const fallbackClauses = buildClausePack('IMP', 'CON', 'TRANSPORT_ONLY');
+ return {
+ key,
+ direction: 'IMP',
+ freight: 'CON',
+ currency: 'USD',
+ serviceScope: 'TRANSPORT_ONLY',
+ title: 'Freight Contract Agreement',
+ directionLabel: 'Freight',
+ freightLabel: 'Cargo',
+ whereas:
+ 'The parties agree to railway freight services as described in the schedule below.',
+ article1Objective: article1ObjectiveFromClause(fallbackClauses.article1),
+ article1: fallbackClauses.article1,
+ clientObligations: fallbackClauses.clientObligations,
+ providerObligations: fallbackClauses.providerObligations,
+ contractDocuments: fallbackClauses.contractDocuments,
+ };
+}
+
+export function isValidTemplateKey(key: string): boolean {
+ return KEY_PATTERN.test(key);
+}
diff --git a/apps/edr-freight-api/src/contracts/contract-template.resolver.spec.ts b/apps/edr-freight-api/src/contracts/contract-template.resolver.spec.ts
new file mode 100644
index 000000000..f4cb1c4ee
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-template.resolver.spec.ts
@@ -0,0 +1,65 @@
+import { ContractTemplateResolver } from './contract-template.resolver';
+import { Booking } from '../modules/bookings/entities/booking.entity';
+import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
+import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
+
+describe('ContractTemplateResolver', () => {
+ const resolver = new ContractTemplateResolver();
+
+ function booking(partial: Partial): Booking {
+ return partial as Booking;
+ }
+
+ it('resolves import container ETB transport-only', () => {
+ const key = resolver.resolve(
+ booking({
+ tradeDirection: 'IMPORT',
+ freightType: 'CONTAINER',
+ paymentCurrency: 'ETB',
+ serviceType: { code: 'RAIL_ONLY', includesFirstMile: false, includesLastMile: false } as ServiceType,
+ }),
+ );
+ expect(key).toBe('IMP_CON_ETB_TRANSPORT_ONLY');
+ });
+
+ it('resolves export bulk USD forwarding', () => {
+ const key = resolver.resolve(
+ booking({
+ tradeDirection: 'EXPORT',
+ freightType: 'BULK',
+ paymentCurrency: 'USD',
+ serviceType: {
+ code: 'RAIL_FORWARDING',
+ includesFirstMile: true,
+ includesLastMile: false,
+ } as ServiceType,
+ }),
+ );
+ expect(key).toBe('EXP_BULK_USD_FORWARDING');
+ });
+
+ it('maps BREAK_BULK cargo to BULK freight', () => {
+ const key = resolver.resolve(
+ booking({
+ tradeDirection: 'IMPORT',
+ freightType: 'CONTAINER',
+ paymentCurrency: 'ETB',
+ cargoType: { code: 'BREAK_BULK_GENERAL' } as CargoType,
+ serviceType: undefined,
+ }),
+ );
+ expect(key).toBe('IMP_BULK_ETB_TRANSPORT_ONLY');
+ });
+
+ it('resolves domestic container', () => {
+ const key = resolver.resolve(
+ booking({
+ tradeDirection: 'DOMESTIC',
+ freightType: 'CONTAINER',
+ paymentCurrency: 'USD',
+ serviceType: undefined,
+ }),
+ );
+ expect(key).toBe('DOM_CON_USD_TRANSPORT_ONLY');
+ });
+});
diff --git a/apps/edr-freight-api/src/contracts/contract-template.resolver.ts b/apps/edr-freight-api/src/contracts/contract-template.resolver.ts
new file mode 100644
index 000000000..daa48a4e7
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-template.resolver.ts
@@ -0,0 +1,43 @@
+import { Injectable } from '@nestjs/common';
+
+import { Booking } from '../modules/bookings/entities/booking.entity';
+import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
+import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
+
+@Injectable()
+export class ContractTemplateResolver {
+ resolve(booking: Booking): string {
+ const dir =
+ booking.tradeDirection === 'IMPORT'
+ ? 'IMP'
+ : booking.tradeDirection === 'EXPORT'
+ ? 'EXP'
+ : 'DOM';
+
+ let freight = booking.freightType === 'BULK' ? 'BULK' : 'CON';
+ const cargoCode = (booking.cargoType as CargoType | undefined)?.code ?? '';
+ if (cargoCode.startsWith('BREAK_BULK')) {
+ freight = 'BULK';
+ }
+
+ const currency = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD';
+ const service = this.resolveServiceScope(booking.serviceType);
+
+ return `${dir}_${freight}_${currency}_${service}`;
+ }
+
+ private resolveServiceScope(
+ serviceType?: ServiceType | null,
+ ): 'TRANSPORT_ONLY' | 'FORWARDING' {
+ if (!serviceType) return 'TRANSPORT_ONLY';
+ const code = (serviceType.code ?? '').toUpperCase();
+ if (
+ serviceType.includesFirstMile ||
+ serviceType.includesLastMile ||
+ code.includes('FORWARD')
+ ) {
+ return 'FORWARDING';
+ }
+ return 'TRANSPORT_ONLY';
+ }
+}
diff --git a/apps/edr-freight-api/src/contracts/contract-template.types.ts b/apps/edr-freight-api/src/contracts/contract-template.types.ts
new file mode 100644
index 000000000..b056b4c69
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-template.types.ts
@@ -0,0 +1,35 @@
+export type ContractDirection = 'IMP' | 'EXP' | 'DOM';
+export type ContractFreight = 'CON' | 'BULK';
+export type ContractServiceScope = 'TRANSPORT_ONLY' | 'FORWARDING';
+
+export interface Article1Clause {
+ objective: string;
+ scope: string[];
+}
+
+export interface ContractClausePack {
+ article1: Article1Clause;
+ clientObligations: string[];
+ providerObligations: string[];
+ contractDocuments: string[];
+}
+
+export interface ContractTemplateMeta {
+ key: string;
+ direction: ContractDirection;
+ freight: ContractFreight;
+ currency: string;
+ serviceScope: ContractServiceScope;
+ title: string;
+ directionLabel: string;
+ freightLabel: string;
+ whereas: string;
+ /** Summary line for APIs; mirrors article1.objective */
+ article1Objective: string;
+ article1: Article1Clause;
+ clientObligations: string[];
+ providerObligations: string[];
+ contractDocuments: string[];
+ /** Optional dedicated .hbs file; otherwise uses generic.hbs */
+ templateFile?: string;
+}
diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
new file mode 100644
index 000000000..2eab2a01b
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
@@ -0,0 +1,124 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+
+import { BookingsRepository } from '../modules/bookings/bookings.repository';
+import { Booking } from '../modules/bookings/entities/booking.entity';
+import {
+ BookingContractSignature,
+ ContractSignerRole,
+} from '../modules/bookings/entities/booking-contract-signature.entity';
+import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder';
+import { ContractTemplateResolver } from './contract-template.resolver';
+import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
+
+export interface ContractSignatureView {
+ role: ContractSignerRole;
+ signerDisplayName: string;
+ signedAt: string;
+ signatureImageUrl?: string | null;
+}
+
+export interface ContractViewModel {
+ bookingId: string;
+ reference: string;
+ status: string;
+ templateKey: string;
+ template: ContractTemplateMeta;
+ contractDate: string;
+ contractYear: number;
+ client: {
+ companyName: string;
+ companyAddress: string;
+ companyLocation: string;
+ phone: string;
+ email: string;
+ tinNumber: string;
+ };
+ pricing: PricingSchedule;
+ signatures: ContractSignatureView[];
+ canSignCustomer: boolean;
+ canSignStaff: boolean;
+ hasContractDocument: boolean;
+ hasCustomerSignature: boolean;
+ hasStaffSignature: boolean;
+}
+
+@Injectable()
+export class ContractViewModelBuilder {
+ constructor(
+ private readonly bookingsRepository: BookingsRepository,
+ private readonly templateResolver: ContractTemplateResolver,
+ private readonly pricingBuilder: ContractPricingScheduleBuilder,
+ ) {}
+
+ async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
+ const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
+ if (!booking) {
+ throw new NotFoundException(`Booking ${bookingId} not found`);
+ }
+
+ const templateKey =
+ booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
+ const template = getTemplateMeta(templateKey);
+ const pricing = await this.pricingBuilder.build(booking);
+ const signatures = await this.loadSignatures(bookingId);
+
+ const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
+ const hasStaff = signatures.some((s) => s.role === 'STAFF');
+ const hasContractFile = Boolean(
+ booking.files?.some((f) => f.code === 'contract'),
+ );
+
+ const view: ContractViewModel = {
+ bookingId: booking.id,
+ reference: booking.reference,
+ status: booking.status,
+ templateKey,
+ template,
+ contractDate: new Date().toLocaleDateString('en-GB', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric',
+ }),
+ contractYear: new Date().getFullYear(),
+ client: {
+ // companyName: booking.customer?.companyName ?? 'Client',
+ // companyAddress: booking.customer?.companyAddress ?? '—',
+ // companyLocation: booking.customer?.companyLocation ?? '—',
+ // phone: booking.customer?.companyPhone ?? booking.customer?.phone ?? '—',
+ // email: booking.customer?.companyEmail ?? booking.customer?.email ?? '—',
+ // tinNumber: booking.customer?.tinNumber ?? '—',
+ companyName: booking.company?.name ?? 'Client',
+ companyAddress: booking.company?.address ?? '—',
+ companyLocation: booking.company?.country ?? '—',
+ phone: booking.company?.phone ?? '—',
+ email: booking.company?.email ?? '—',
+ tinNumber: booking.company?.tin ?? '—',
+ },
+ pricing,
+ signatures,
+ canSignCustomer:
+ booking.status === 'CONTRACT_READY' && !hasCustomer,
+ canSignStaff:
+ booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
+ hasContractDocument: hasContractFile,
+ hasCustomerSignature: hasCustomer,
+ hasStaffSignature: hasStaff,
+ };
+
+ return { booking, view };
+ }
+
+ private async loadSignatures(bookingId: string): Promise {
+ const rows = await this.bookingsRepository.findContractSignatures(bookingId);
+ return rows.map((s) => this.toSignatureView(s));
+ }
+
+ toSignatureView(row: BookingContractSignature): ContractSignatureView {
+ return {
+ role: row.signerRole,
+ signerDisplayName: row.signerDisplayName,
+ signedAt: row.signedAt.toISOString(),
+ signatureImageUrl: row.signatureFile?.url ?? null,
+ };
+ }
+}
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article1.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article1.hbs
new file mode 100644
index 000000000..ebdfa744f
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/templates/_partials/article1.hbs
@@ -0,0 +1,12 @@
+
+
Article 1: Objective and Scope of Services
+
Objective: {{template.article1.objective}}
+ {{#if template.article1.scope.length}}
+
Scope:
+
+ {{#each template.article1.scope}}
+ {{this}}
+ {{/each}}
+
+ {{/if}}
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs
new file mode 100644
index 000000000..d56c8b8a6
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs
@@ -0,0 +1,47 @@
+Article 5: Contract Price and Terms of Payment
+
+
Contract Price
+
Corridor: {{pricing.originLabel}} → {{pricing.destinationLabel}}
+ {{#if pricing.equipmentReturn}}
+
Equipment return: {{pricing.equipmentReturn}}
+ {{/if}}
+ {{#if pricing.containerLines.length}}
+
+
+ Container type Quantity VGM / unit (t)
+
+
+ {{#each pricing.containerLines}}
+ {{label}} {{quantity}} {{vgmPerUnitTons}}
+ {{/each}}
+
+
+ {{/if}}
+
+
+ Item Description Amount
+
+
+ {{#each pricing.lineItems}}
+
+ {{label}}
+ {{description}}
+ {{currency}} {{amount}}
+
+ {{/each}}
+ {{#each pricing.surcharges}}
+
+ {{label}}
+ {{description}}
+ {{currency}} {{amount}}
+
+ {{/each}}
+
+ Total contract value
+ {{pricing.currency}} {{pricing.totalAmount}}
+
+
+
+
Terms of payment
+
All payments shall be made in accordance with EDR policy in {{paymentArticle}} , unless otherwise agreed in writing.
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/articles_obligations.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/articles_obligations.hbs
new file mode 100644
index 000000000..e48e1e453
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/templates/_partials/articles_obligations.hbs
@@ -0,0 +1,17 @@
+
+
Article 2: Obligations of the Client (summary)
+
+ {{#each template.clientObligations}}
+ {{this}}
+ {{/each}}
+
+
+
+
+
Article 3: Obligations of the Service Provider (summary)
+
+ {{#each template.providerObligations}}
+ {{this}}
+ {{/each}}
+
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/contract_documents.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/contract_documents.hbs
new file mode 100644
index 000000000..ed901a808
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/templates/_partials/contract_documents.hbs
@@ -0,0 +1,8 @@
+
+
Article 6: Contract Documents
+
+ {{#each template.contractDocuments}}
+ {{this}}
+ {{/each}}
+
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/force_majeure.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/force_majeure.hbs
new file mode 100644
index 000000000..6785d1be6
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/templates/_partials/force_majeure.hbs
@@ -0,0 +1,4 @@
+
+
Article 4: Force Majeure
+
Neither party is liable for delays due to force majeure interpreted under the Ethiopian Civil Code.
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs
new file mode 100644
index 000000000..ed5b7048d
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs
@@ -0,0 +1,30 @@
+
+
+
Service Provider (EDR)
+ {{#if hasStaffSignature}}
+ {{#each signatures}}
+ {{#if (eq role "STAFF")}}
+ {{#if signatureImageUrl}}
{{/if}}
+
{{signerDisplayName}}
+
Signed: {{signedAt}}
+ {{/if}}
+ {{/each}}
+ {{else}}
+
Authorized representative (pending)
+ {{/if}}
+
+
+
Client — {{client.companyName}}
+ {{#if hasCustomerSignature}}
+ {{#each signatures}}
+ {{#if (eq role "CUSTOMER")}}
+ {{#if signatureImageUrl}}
{{/if}}
+
{{signerDisplayName}}
+
Signed: {{signedAt}}
+ {{/if}}
+ {{/each}}
+ {{else}}
+
Client representative (pending)
+ {{/if}}
+
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs
new file mode 100644
index 000000000..601768185
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs
@@ -0,0 +1,22 @@
+
diff --git a/apps/edr-freight-api/src/contracts/templates/generic.hbs b/apps/edr-freight-api/src/contracts/templates/generic.hbs
new file mode 100644
index 000000000..7e6069e19
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/templates/generic.hbs
@@ -0,0 +1,30 @@
+
+
+
+
+ {{template.title}} — {{reference}}
+ {{> styles}}
+
+
+
+
Contract Agreement
+
{{template.title}}
+
Contract Ref No: {{reference}}
+
Year: {{contractYear}}
+
+
+ This Contract Agreement is made on {{contractDate}} .
+ Between Ethio-Djibouti Standard Gauge Railway Share Company (EDR), Addis Ababa (“Service Provider”), and {{client.companyName}} at {{client.companyAddress}}, {{client.companyLocation}} (“Client”). Phone {{client.phone}} / {{client.email}}. TIN {{client.tinNumber}}.
+
+ Whereas
+ {{template.whereas}}
+ Now therefore, the parties agree as follows:
+
+ {{> article1}}
+ {{> articles_obligations}}
+ {{> article5_pricing}}
+ {{> force_majeure}}
+ {{> contract_documents}}
+ {{> signatures_block}}
+
+
diff --git a/apps/edr-freight-api/src/data-source.ts b/apps/edr-freight-api/src/data-source.ts
new file mode 100644
index 000000000..b35d79932
--- /dev/null
+++ b/apps/edr-freight-api/src/data-source.ts
@@ -0,0 +1,20 @@
+// apps/edr-freight-api/src/data-source.ts
+import { DataSource } from 'typeorm';
+//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
+
+export const AppDataSource = new DataSource({
+ type: 'postgres',
+ host: 'localhost',
+ port: 5432,
+ username: 'postgres',
+ password: '', // Laragon default: empty
+ database: 'edr_freight',
+ schema: 'freight', // default schema for entities without an explicit schema
+ entities: [__dirname + '/**/*.entity{.ts,.js}'],
+ migrations: [__dirname + '/migrations/*{.ts,.js}'],
+ synchronize: false,
+ logging: true,
+});
+
+// Optional: call ensurePostgresSchemas before initializing
+// But you can also run it separately.
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/migrations/1748900000000-MoveCustomersToFreightSchema.ts b/apps/edr-freight-api/src/migrations/1748900000000-MoveCustomersToFreightSchema.ts
index 43bb0eb02..36e848e64 100644
--- a/apps/edr-freight-api/src/migrations/1748900000000-MoveCustomersToFreightSchema.ts
+++ b/apps/edr-freight-api/src/migrations/1748900000000-MoveCustomersToFreightSchema.ts
@@ -43,10 +43,10 @@ export class MoveCustomersToFreightSchema1748900000000 implements MigrationInter
CREATE INDEX IF NOT EXISTS "IDX_freight_customers_email"
ON freight.customers (email);
`);
- await queryRunner.query(`
- CREATE INDEX IF NOT EXISTS "IDX_freight_customers_user_id"
- ON freight.customers (user_id);
- `);
+ // await queryRunner.query(`
+ // CREATE INDEX IF NOT EXISTS "IDX_freight_customers_user_id"
+ // ON freight.customers (user_id);
+ //`);
// Copy rows from public.customers when that legacy table exists
await queryRunner.query(`
diff --git a/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts b/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts
new file mode 100644
index 000000000..162672727
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts
@@ -0,0 +1,50 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class BookingFlowRefactor1749200000000 implements MigrationInterface {
+ name = 'BookingFlowRefactor1749200000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.booking_review_note (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
+ author_id UUID,
+ note TEXT NOT NULL,
+ type VARCHAR(30) NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ deleted_at TIMESTAMPTZ
+ );
+ CREATE INDEX IF NOT EXISTS idx_booking_review_note_booking_id
+ ON freight.booking_review_note(booking_id);
+ `);
+
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ ADD COLUMN IF NOT EXISTS marketing_approved_by_id UUID,
+ ADD COLUMN IF NOT EXISTS marketing_approved_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS contract_summary TEXT,
+ ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ;
+ `);
+
+ await queryRunner.query(`
+ UPDATE freight.bookings SET status = 'SUBMITTED'
+ WHERE status IN ('RFQ_SUBMITTED', 'QUOTATION_SENT', 'QUOTATION_APPROVED');
+ UPDATE freight.bookings SET status = 'REJECTED'
+ WHERE status = 'QUOTATION_REJECTED';
+ UPDATE freight.bookings SET status = 'CANCELLED'
+ WHERE status = 'CANCELLED';
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ DROP COLUMN IF EXISTS locked_at,
+ DROP COLUMN IF EXISTS contract_summary,
+ DROP COLUMN IF EXISTS marketing_approved_at,
+ DROP COLUMN IF EXISTS marketing_approved_by_id;
+ `);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_review_note;`);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts b/apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts
new file mode 100644
index 000000000..795d93fc3
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts
@@ -0,0 +1,71 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddBookingFreightType1749300000000 implements MigrationInterface {
+ name = 'AddBookingFreightType1749300000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ ADD COLUMN IF NOT EXISTS freight_type VARCHAR(20);
+ `);
+
+ await queryRunner.query(`
+ UPDATE freight.bookings b
+ SET freight_type = 'CONTAINER'
+ WHERE EXISTS (
+ SELECT 1 FROM freight.booking_container bc WHERE bc.booking_id = b.id
+ );
+ `);
+
+ await queryRunner.query(`
+ UPDATE freight.bookings b
+ SET freight_type = 'BULK'
+ WHERE freight_type IS NULL
+ AND b.cargo_type_id IS NOT NULL
+ AND EXISTS (
+ SELECT 1 FROM freight.cargo_types ct
+ WHERE ct.id = b.cargo_type_id AND ct.requires_director_approval = true
+ );
+ `);
+
+ await queryRunner.query(`
+ UPDATE freight.bookings
+ SET freight_type = 'CONTAINER'
+ WHERE freight_type IS NULL;
+ `);
+
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ ALTER COLUMN cargo_type_id DROP NOT NULL;
+ `);
+
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ ALTER COLUMN freight_type SET NOT NULL;
+ `);
+
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ ADD CONSTRAINT chk_bookings_freight_type
+ CHECK (freight_type IN ('CONTAINER', 'BULK'));
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS chk_bookings_freight_type;
+ `);
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings DROP COLUMN IF EXISTS freight_type;
+ `);
+ await queryRunner.query(`
+ UPDATE freight.bookings SET cargo_type_id = (
+ SELECT id FROM freight.cargo_types LIMIT 1
+ ) WHERE cargo_type_id IS NULL;
+ `);
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ ALTER COLUMN cargo_type_id SET NOT NULL;
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts b/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts
index 647e4fb5b..4df7ea4ce 100644
--- a/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts
+++ b/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts
@@ -4,16 +4,17 @@ export class AddFanNumberToCompanies1749300000000 implements MigrationInterface
name = 'AddFanNumberToCompanies1749300000000';
public async up(queryRunner: QueryRunner): Promise {
+ // fan_number may already exist when CreateCompaniesModule ran with the full schema
await queryRunner.query(`
ALTER TABLE freight.companies
- ADD COLUMN fan_number varchar(16) NULL;
+ ADD COLUMN IF NOT EXISTS fan_number varchar(16) NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise {
await queryRunner.query(`
ALTER TABLE freight.companies
- DROP COLUMN fan_number;
+ DROP COLUMN IF EXISTS fan_number;
`);
}
}
diff --git a/apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts b/apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts
new file mode 100644
index 000000000..8126b91ca
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts
@@ -0,0 +1,45 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddContractSignatures1749400000000 implements MigrationInterface {
+ name = 'AddContractSignatures1749400000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ ADD COLUMN IF NOT EXISTS contract_template_key VARCHAR(80),
+ ADD COLUMN IF NOT EXISTS contract_generated_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS pricing_breakdown JSONB;
+ `);
+
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.booking_contract_signatures (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
+ signer_role VARCHAR(20) NOT NULL,
+ signer_user_id UUID,
+ signer_display_name VARCHAR(200) NOT NULL,
+ signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ signature_file_id UUID REFERENCES freight.files(id) ON DELETE SET NULL,
+ consent_text TEXT,
+ ip_address VARCHAR(64),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ deleted_at TIMESTAMPTZ,
+ CONSTRAINT uq_booking_contract_signatures_role
+ UNIQUE (booking_id, signer_role)
+ );
+ CREATE INDEX IF NOT EXISTS idx_booking_contract_signatures_booking_id
+ ON freight.booking_contract_signatures(booking_id);
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_contract_signatures;`);
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ DROP COLUMN IF EXISTS pricing_breakdown,
+ DROP COLUMN IF EXISTS contract_generated_at,
+ DROP COLUMN IF EXISTS contract_template_key;
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts b/apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts
new file mode 100644
index 000000000..5e61797da
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts
@@ -0,0 +1,153 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddTrainScheduling1749400000000 implements MigrationInterface {
+ name = 'AddTrainScheduling1749400000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.wagon_types (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ code VARCHAR(32) NOT NULL UNIQUE,
+ name VARCHAR(100) NOT NULL,
+ capacity_tons NUMERIC(10,3) NOT NULL,
+ length_meters NUMERIC(10,3) NOT NULL,
+ max_wagons_per_train INT NULL,
+ supported_load_types TEXT[] NOT NULL DEFAULT '{}',
+ is_active BOOLEAN NOT NULL DEFAULT true,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ NULL
+ );
+ `);
+
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.locomotives (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ code VARCHAR(32) NOT NULL UNIQUE,
+ name VARCHAR(100) NULL,
+ max_pull_weight_tons NUMERIC(10,3) NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE',
+ available_from TIMESTAMPTZ NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ NULL
+ );
+ `);
+
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.train_sets (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ locomotive_id UUID NOT NULL,
+ total_weight_tons NUMERIC(10,3) NOT NULL,
+ total_length_meters NUMERIC(10,3) NOT NULL,
+ wagon_count INT NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ NULL,
+ CONSTRAINT fk_train_sets_locomotive FOREIGN KEY (locomotive_id)
+ REFERENCES freight.locomotives(id)
+ );
+ `);
+
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.train_set_wagons (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ train_set_id UUID NOT NULL,
+ wagon_type_id UUID NOT NULL,
+ sequence_no INT NOT NULL,
+ capacity_tons NUMERIC(10,3) NOT NULL,
+ length_meters NUMERIC(10,3) NOT NULL,
+ assigned_weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ NULL,
+ CONSTRAINT uq_train_set_wagons_sequence UNIQUE (train_set_id, sequence_no),
+ CONSTRAINT fk_train_set_wagons_train_set FOREIGN KEY (train_set_id)
+ REFERENCES freight.train_sets(id) ON DELETE CASCADE,
+ CONSTRAINT fk_train_set_wagons_wagon_type FOREIGN KEY (wagon_type_id)
+ REFERENCES freight.wagon_types(id)
+ );
+ `);
+
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.train_schedules (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ train_set_id UUID NOT NULL UNIQUE,
+ origin_station_id UUID NOT NULL,
+ destination_station_id UUID NOT NULL,
+ scheduled_departure_date TIMESTAMPTZ NOT NULL,
+ scheduled_arrival_date TIMESTAMPTZ NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ NULL,
+ CONSTRAINT fk_train_schedules_train_set FOREIGN KEY (train_set_id)
+ REFERENCES freight.train_sets(id),
+ CONSTRAINT fk_train_schedules_origin FOREIGN KEY (origin_station_id)
+ REFERENCES freight.yards(id),
+ CONSTRAINT fk_train_schedules_destination FOREIGN KEY (destination_station_id)
+ REFERENCES freight.yards(id)
+ );
+ `);
+
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.train_schedule_bookings (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ train_schedule_id UUID NOT NULL,
+ booking_id UUID NOT NULL UNIQUE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ NULL,
+ CONSTRAINT uq_train_schedule_booking UNIQUE (train_schedule_id, booking_id),
+ CONSTRAINT fk_train_schedule_bookings_schedule FOREIGN KEY (train_schedule_id)
+ REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
+ CONSTRAINT fk_train_schedule_bookings_booking FOREIGN KEY (booking_id)
+ REFERENCES freight.bookings(id)
+ );
+ `);
+
+ await queryRunner.query(`
+ CREATE TABLE IF NOT EXISTS freight.wagon_booking_allocations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ train_set_wagon_id UUID NOT NULL,
+ booking_id UUID NOT NULL,
+ allocated_weight_tons NUMERIC(10,3) NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ NULL,
+ CONSTRAINT fk_wagon_booking_allocations_wagon FOREIGN KEY (train_set_wagon_id)
+ REFERENCES freight.train_set_wagons(id) ON DELETE CASCADE,
+ CONSTRAINT fk_wagon_booking_allocations_booking FOREIGN KEY (booking_id)
+ REFERENCES freight.bookings(id)
+ );
+ `);
+
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS idx_locomotives_status
+ ON freight.locomotives(status);
+ `);
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS idx_train_sets_status
+ ON freight.train_sets(status);
+ `);
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS idx_train_schedules_departure_status
+ ON freight.train_schedules(scheduled_departure_date, status);
+ `);
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_booking
+ ON freight.wagon_booking_allocations(booking_id);
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_booking_allocations;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedule_bookings;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedules;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.train_set_wagons;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.train_sets;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.locomotives;`);
+ await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_types;`);
+ }
+}
diff --git a/apps/edr-freight-api/src/migrations/1749500000000-AddCompanyIdToBookings.ts b/apps/edr-freight-api/src/migrations/1749500000000-AddCompanyIdToBookings.ts
new file mode 100644
index 000000000..25fbe1806
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/1749500000000-AddCompanyIdToBookings.ts
@@ -0,0 +1,59 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddCompanyIdToBookings1749500000000 implements MigrationInterface {
+ name = 'AddCompanyIdToBookings1749500000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ ALTER COLUMN customer_id DROP NOT NULL;
+ `);
+
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ ADD COLUMN IF NOT EXISTS company_id UUID;
+ `);
+
+ await queryRunner.query(`
+ CREATE INDEX IF NOT EXISTS idx_bookings_company_id
+ ON freight.bookings(company_id);
+ `);
+
+ await queryRunner.query(`
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_id'
+ ) THEN
+ ALTER TABLE freight.bookings
+ ADD CONSTRAINT "FK_bookings_company_id"
+ FOREIGN KEY (company_id)
+ REFERENCES freight.companies(id);
+ END IF;
+ END $$;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ DROP CONSTRAINT IF EXISTS "FK_bookings_company_id";
+ `);
+
+ await queryRunner.query(`
+ UPDATE freight.bookings SET customer_id = company_id WHERE customer_id IS NULL AND company_id IS NOT NULL;
+ `);
+
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ ALTER COLUMN customer_id SET NOT NULL;
+ `);
+ await queryRunner.query(`
+ DROP INDEX IF EXISTS freight.idx_bookings_company_id;
+ `);
+ await queryRunner.query(`
+ ALTER TABLE freight.bookings
+ DROP COLUMN IF EXISTS company_id;
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts
new file mode 100644
index 000000000..4eadff0fc
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts
@@ -0,0 +1,260 @@
+import {
+ BadRequestException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { Readable } from 'stream';
+
+import { ContractPdfService } from '../../contracts/contract-pdf.service';
+import { ContractRendererService } from '../../contracts/contract-renderer.service';
+import { getTemplateMeta } from '../../contracts/contract-template.registry';
+import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
+import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
+import { MinioService } from '../minio/minio.service';
+import { FilesService } from '../files/files.service';
+import { BookingsRepository } from './bookings.repository';
+import { Booking } from './entities/booking.entity';
+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';
+
+@Injectable()
+export class BookingContractService {
+ constructor(
+ private readonly bookingsRepository: BookingsRepository,
+ private readonly filesService: FilesService,
+ private readonly minioService: MinioService,
+ private readonly templateResolver: ContractTemplateResolver,
+ private readonly viewModelBuilder: ContractViewModelBuilder,
+ private readonly renderer: ContractRendererService,
+ private readonly pdfService: ContractPdfService,
+ ) {}
+
+ buildContractSummary(booking: Booking): string {
+ const direction =
+ booking.tradeDirection === 'IMPORT'
+ ? 'Import'
+ : booking.tradeDirection === 'EXPORT'
+ ? 'Export'
+ : booking.tradeDirection;
+
+ const cargo = booking.cargoType;
+ const isBulk = booking.freightType === 'BULK';
+
+ let cargoLabel: string;
+ if (isBulk) {
+ cargoLabel = `Bulk (${booking.cargoFreeText || cargo?.cargoTypeName || 'Commodity'})`;
+ } else {
+ const lines =
+ booking.bookingContainers?.map((bc) => {
+ const label = bc.containerType?.label ?? bc.containerType?.code ?? 'Container';
+ return `${bc.quantity}× ${label}`;
+ }) ?? [];
+ cargoLabel =
+ lines.length > 0
+ ? `Container (${lines.join(', ')})`
+ : 'Container (Standard)';
+ }
+
+ return `Operation: ${direction} | Cargo Type: ${cargoLabel}`;
+ }
+
+ async getSummary(bookingId: string): Promise<{ summary: string }> {
+ const booking = await this.requireBooking(bookingId);
+ const summary = booking.contractSummary ?? this.buildContractSummary(booking);
+ return { summary };
+ }
+
+ async getContractView(bookingId: string): Promise {
+ const { view } = await this.viewModelBuilder.build(bookingId);
+ await this.enrichSignatureUrls(view.signatures);
+ const html = this.renderer.render(view);
+ return {
+ bookingId: view.bookingId,
+ reference: view.reference,
+ status: view.status,
+ templateKey: view.templateKey,
+ title: view.template.title,
+ html,
+ canSignCustomer: view.canSignCustomer,
+ canSignStaff: view.canSignStaff,
+ hasContractDocument: view.hasContractDocument,
+ signatures: view.signatures,
+ pricingSchedule: view.pricing as unknown as Record,
+ };
+ }
+
+ async generateContract(bookingId: string): Promise {
+ const booking = await this.requireBooking(bookingId);
+ assertBookingStatus(booking, ['APPROVED']);
+
+ const templateKey = this.templateResolver.resolve(booking);
+ const { view } = await this.viewModelBuilder.build(bookingId);
+ view.templateKey = templateKey;
+ view.template = getTemplateMeta(templateKey);
+
+ const html = this.renderer.render(view);
+ const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
+ const summary = this.buildContractSummary(booking);
+
+ const file: Express.Multer.File = {
+ fieldname: 'contract',
+ originalname: `contract-${booking.reference}.pdf`,
+ encoding: '7bit',
+ mimetype: 'application/pdf',
+ size: pdfBuffer.length,
+ buffer: pdfBuffer,
+ stream: Readable.from(pdfBuffer),
+ destination: '',
+ filename: '',
+ path: '',
+ };
+
+ await this.filesService.upsertByCode({
+ resourceId: bookingId,
+ resource: 'bookings',
+ code: 'contract',
+ file,
+ });
+
+ const now = new Date();
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'CONTRACT_READY',
+ contractSummary: summary,
+ contractTemplateKey: templateKey,
+ contractGeneratedAt: now,
+ } as never);
+ return updated!;
+ }
+
+ async streamContract(bookingId: string) {
+ try {
+ const record = await this.filesService.findByCode(
+ bookingId,
+ 'bookings',
+ 'contract',
+ );
+ return this.filesService.streamById(record.id);
+ } catch {
+ throw new NotFoundException(
+ 'Contract document not found. Generate the contract first.',
+ );
+ }
+ }
+
+ async signContract(
+ bookingId: string,
+ dto: SignContractDto,
+ options: { signerUserId?: string; ipAddress?: string },
+ ): Promise {
+ const booking = await this.requireBooking(bookingId);
+ const role = dto.role as ContractSignerRole;
+
+ if (role === 'CUSTOMER') {
+ assertBookingStatus(booking, ['CONTRACT_READY']);
+ const existing = await this.bookingsRepository.findContractSignature(
+ bookingId,
+ 'CUSTOMER',
+ );
+ if (existing) {
+ throw new BadRequestException('Customer has already signed this contract');
+ }
+ } else {
+ assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
+ const existing = await this.bookingsRepository.findContractSignature(
+ bookingId,
+ 'STAFF',
+ );
+ if (existing) {
+ throw new BadRequestException('Staff has already signed this contract');
+ }
+ }
+
+ const buffer = this.decodeSignatureImage(dto.signatureImageBase64);
+ const sigFile: Express.Multer.File = {
+ fieldname: `signature_${role.toLowerCase()}`,
+ originalname: `signature-${role.toLowerCase()}-${booking.reference}.png`,
+ encoding: '7bit',
+ mimetype: 'image/png',
+ size: buffer.length,
+ buffer,
+ stream: Readable.from(buffer),
+ destination: '',
+ filename: '',
+ path: '',
+ };
+
+ const fileRecord = await this.filesService.upsertByCode({
+ resourceId: bookingId,
+ resource: 'bookings',
+ code: role === 'CUSTOMER' ? 'signature_customer' : 'signature_staff',
+ file: sigFile,
+ });
+
+ const now = new Date();
+ await this.bookingsRepository.saveContractSignature({
+ bookingId,
+ signerRole: role,
+ signerUserId: options.signerUserId ?? null,
+ signerDisplayName: dto.signerDisplayName,
+ signedAt: now,
+ signatureFileId: fileRecord.id,
+ consentText: dto.consentText ?? null,
+ ipAddress: options.ipAddress ?? null,
+ });
+
+ const updates: Record = {};
+
+ if (role === 'CUSTOMER') {
+ updates.status = 'SIGNED_CUSTOMER';
+ updates.customerSignedAt = now;
+ } else {
+ updates.status = 'FULLY_EXECUTED';
+ updates.fullyExecutedAt = now;
+ updates.marketingApprovedAt = now;
+ updates.marketingApprovedById = options.signerUserId ?? null;
+ updates.lockedAt = now;
+ }
+
+ const updated = await this.bookingsRepository.update(bookingId, updates as never);
+ return updated!;
+ }
+
+ async getSignatures(bookingId: string) {
+ const rows = await this.bookingsRepository.findContractSignatures(bookingId);
+ const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r));
+ await this.enrichSignatureUrls(views);
+ return { signatures: views };
+ }
+
+ private async enrichSignatureUrls(
+ signatures: Array<{ signatureImageUrl?: string | null }>,
+ ): Promise {
+ for (const sig of signatures) {
+ if (!sig.signatureImageUrl) continue;
+ try {
+ const objectName = this.extractObjectName(sig.signatureImageUrl);
+ sig.signatureImageUrl = await this.minioService.getSignedUrl(objectName, 3600);
+ } catch {
+ /* keep original url */
+ }
+ }
+ }
+
+ private extractObjectName(url: string): string {
+ const parts = url.split('/');
+ return parts.slice(4).join('/');
+ }
+
+ private decodeSignatureImage(base64: string): Buffer {
+ const raw = base64.includes(',') ? base64.split(',')[1]! : base64;
+ return Buffer.from(raw, 'base64');
+ }
+
+ private async requireBooking(id: string): Promise {
+ const booking = await this.bookingsRepository.findByIdWithFiles(id);
+ if (!booking) throw new NotFoundException(`Booking ${id} not found`);
+ return booking;
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts
new file mode 100644
index 000000000..e3e97f301
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts
@@ -0,0 +1,44 @@
+import { BadRequestException } from '@nestjs/common';
+
+import { FREIGHT_TYPES, FreightType } from './entities/booking.entity';
+import { BookingFreightShapeInput } from './dto/validators/booking-freight.validator';
+
+/** Normalize and validate booking freight shape (used on create and after update merge). */
+export function assertFreightShape(input: BookingFreightShapeInput): void {
+ if (!input.freightType || !FREIGHT_TYPES.includes(input.freightType as FreightType)) {
+ throw new BadRequestException(
+ `freightType is required and must be one of: ${FREIGHT_TYPES.join(', ')}`,
+ );
+ }
+ //
+
+ const containers = input.containers ?? [];
+ const hasContainers = containers.length > 0;
+ const hasCargoType = Boolean(input.cargoTypeId);
+
+ if (input.freightType === 'BULK') {
+ if (hasContainers) {
+ throw new BadRequestException(
+ 'BULK freight cannot include container lines; use cargoTypeId only',
+ );
+ }
+ if (!hasCargoType) {
+ throw new BadRequestException('cargoTypeId is required for BULK freight');
+ }
+ return;
+ }
+
+ if (hasCargoType) {
+ throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight');
+ }
+ if (!hasContainers) {
+ throw new BadRequestException(
+ 'CONTAINER freight requires at least one container line with containerTypeId',
+ );
+ }
+ for (const line of containers) {
+ if (!line.containerTypeId) {
+ throw new BadRequestException('Each container line must include containerTypeId');
+ }
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts
new file mode 100644
index 000000000..80476ead2
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts
@@ -0,0 +1,130 @@
+import {
+ BadRequestException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { FilesService } from '../files/files.service';
+import { BookingsRepository } from './bookings.repository';
+import { Booking } from './entities/booking.entity';
+import { assertBookingStatus } from './booking-status.util';
+
+const PROOF_MAX_BYTES = 5 * 1024 * 1024;
+const PROOF_MIMES = ['application/pdf', 'image/jpeg', 'image/png'];
+
+@Injectable()
+export class BookingPaymentService {
+ constructor(
+ private readonly bookingsRepository: BookingsRepository,
+ private readonly filesService: FilesService,
+ ) {}
+
+ async generatePnr(bookingId: string): Promise {
+ const booking = await this.requireBooking(bookingId);
+ assertBookingStatus(booking, ['FULLY_EXECUTED']);
+
+ if (booking.paymentCurrency !== 'ETB') {
+ throw new BadRequestException('PNR generation is only for ETB payers');
+ }
+
+ const year = new Date().getFullYear();
+ const pnrCode = `PNR-${year}-${Math.random().toString(36).slice(2, 10).toUpperCase()}`;
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'PNR_GENERATED',
+ pnrCode,
+ paymentStatus: 'PNR_GENERATED',
+ } as never);
+ return updated!;
+ }
+
+ async submitPaymentProof(
+ bookingId: string,
+ file: Express.Multer.File,
+ ): Promise {
+ const booking = await this.requireBooking(bookingId);
+ assertBookingStatus(booking, ['FULLY_EXECUTED']);
+
+ if (booking.paymentCurrency !== 'USD') {
+ throw new BadRequestException('Payment proof upload is only for USD payers');
+ }
+
+ this.validateProofFile(file);
+
+ await this.filesService.upload({
+ resourceId: bookingId,
+ resource: 'bookings',
+ code: 'payment_proof',
+ file,
+ });
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'PAYMENT_VERIFICATION_IN_PROGRESS',
+ paymentStatus: 'VERIFICATION_IN_PROGRESS',
+ } as never);
+ return updated!;
+ }
+
+ async verifyPayment(bookingId: string): Promise {
+ const booking = await this.requireBooking(bookingId);
+ assertBookingStatus(booking, ['PAYMENT_VERIFICATION_IN_PROGRESS']);
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'PAID',
+ paymentStatus: 'PAID',
+ } as never);
+ return updated!;
+ }
+
+ async handleBankCallback(pnrCode: string): Promise {
+ const booking = await this.bookingsRepository.findByPnrCode(pnrCode);
+ if (!booking) {
+ throw new NotFoundException(`No booking found for PNR ${pnrCode}`);
+ }
+
+ if (booking.status !== 'PNR_GENERATED') {
+ throw new BadRequestException(
+ `Booking ${booking.reference} is not awaiting bank payment (status: ${booking.status})`,
+ );
+ }
+
+ const updated = await this.bookingsRepository.update(booking.id, {
+ status: 'PAID',
+ paymentStatus: 'PAID',
+ } as never);
+ return updated!;
+ }
+
+ async getPaymentRequestLetter(
+ bookingId: string,
+ ): Promise<{ buffer: Buffer; filename: string }> {
+ const booking = await this.requireBooking(bookingId);
+ const body = [
+ 'PAYMENT REQUEST LETTER (STUB)',
+ `Reference: ${booking.reference}`,
+ `Amount: ${booking.totalAmount} ${booking.paymentCurrency}`,
+ 'Pay at your bank and upload stamped proof.',
+ ].join('\n');
+ return {
+ buffer: Buffer.from(body, 'utf-8'),
+ filename: `payment-request-${booking.reference}.txt`,
+ };
+ }
+
+ private validateProofFile(file: Express.Multer.File): void {
+ if (!file?.buffer?.length) {
+ throw new BadRequestException('Payment proof file is required');
+ }
+ if (file.size > PROOF_MAX_BYTES) {
+ throw new BadRequestException('Payment proof must be 5MB or less');
+ }
+ if (!PROOF_MIMES.includes(file.mimetype)) {
+ throw new BadRequestException('Payment proof must be PDF, JPG, or PNG');
+ }
+ }
+
+ private async requireBooking(id: string): Promise {
+ const booking = await this.bookingsRepository.findById(id);
+ if (!booking) throw new NotFoundException(`Booking ${id} not found`);
+ return booking;
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
new file mode 100644
index 000000000..4e47456e0
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
@@ -0,0 +1,311 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+
+import { ContainerTypesService } from '../rule-engine/services/container-types.service';
+import { RatesService } from '../rule-engine/services/rates.service';
+import { ServiceTypesService } from '../rule-engine/services/service-types.service';
+import { Rate } from '../rule-engine/entities/rate.entity';
+import {
+ AppliedCargoModifier,
+ BookingEvaluationInput,
+ RuleEngineService,
+} from '../rule-engine/rule-engine.service';
+import { BookingsRepository } from './bookings.repository';
+import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
+import { Booking } from './entities/booking.entity';
+import { assertBookingStatus } from './booking-status.util';
+
+@Injectable()
+export class BookingPricingService {
+ constructor(
+ private readonly bookingsRepository: BookingsRepository,
+ private readonly ruleEngineService: RuleEngineService,
+ private readonly containerTypesService: ContainerTypesService,
+ private readonly ratesService: RatesService,
+ private readonly serviceTypesService: ServiceTypesService,
+ ) {}
+
+ async generatePrice(bookingId: string): Promise {
+ const booking = await this.requireBooking(bookingId);
+ assertBookingStatus(booking, ['DRAFT']);
+
+ const evalInput = await this.buildEvalInputForBooking(booking);
+ console.log('evalInput----', evalInput);
+ const ruleResult = await this.ruleEngineService.evaluate(evalInput);
+ this.ruleEngineService.assertNoHardBlocks(ruleResult);
+
+ const lineItems: PriceLineItemDto[] = [];
+ let total = 0;
+
+ const baseLines = await this.computeBaseRailLines(booking, evalInput);
+ for (const line of baseLines) {
+ lineItems.push(line);
+ total += line.amount;
+ }
+
+ for (const mod of ruleResult.appliedModifiers) {
+ const item: PriceLineItemDto = {
+ code: mod.surchargeTypeCode,
+ description: `Surcharge: ${mod.surchargeTypeCode}`,
+ amount: mod.calculatedAmount,
+ currency: mod.currency,
+ };
+ lineItems.push(item);
+ total += mod.calculatedAmount;
+ }
+
+ await this.persistPriceRun(bookingId, ruleResult.appliedModifiers, total);
+
+ await this.bookingsRepository.update(bookingId, {
+ totalAmount: total,
+ priorityScore: ruleResult.priorityScore,
+ pricingBreakdown: {
+ lineItems,
+ totalAmount: total,
+ currency: booking.paymentCurrency,
+ generatedAt: new Date().toISOString(),
+ },
+ } as never);
+
+ return {
+ bookingId,
+ totalAmount: total,
+ currency: booking.paymentCurrency,
+ lineItems,
+ warnings: ruleResult.warnings,
+ };
+ }
+
+ async buildEvalInputForBooking(booking: Booking): Promise {
+ const containers = await Promise.all(
+ (booking.bookingContainers ?? []).map(async (bc) => {
+ const ct = await this.containerTypesService.findById(bc.containerTypeId);
+ const vgm = Number(bc.vgmPerUnitTons);
+ const qty = bc.quantity;
+ return {
+ containerTypeId: bc.containerTypeId,
+ quantity: qty,
+ vgmPerUnitTons: vgm,
+ totalVgmTons: qty * vgm,
+ isReefer: ct.isReefer,
+ };
+ }),
+ );
+ return {
+ freightType: booking.freightType as 'CONTAINER' | 'BULK',
+ cargoTypeId: booking.cargoTypeId ?? null,
+ serviceTypeId: booking.serviceTypeId,
+ paymentCurrency: booking.paymentCurrency,
+ tradeDirection: booking.tradeDirection,
+ isHazardous: booking.isHazardous,
+ allowConsolidation: booking.allowConsolidation,
+ shippingLineId: booking.shippingLineId,
+ containers,
+ };
+ }
+
+ private async requireBooking(id: string): Promise {
+ const booking = await this.bookingsRepository.findByIdWithFiles(id);
+ if (!booking) throw new NotFoundException(`Booking ${id} not found`);
+ return booking;
+ }
+
+ /** Line items for contract schedule (uses stored breakdown or recomputes). */
+ async computeContractLineItems(booking: Booking): Promise<{
+ lineItems: PriceLineItemDto[];
+ totalAmount: number;
+ currency: string;
+ }> {
+ const stored = booking.pricingBreakdown as {
+ lineItems?: PriceLineItemDto[];
+ totalAmount?: number;
+ currency?: string;
+ } | null;
+
+ if (stored?.lineItems?.length) {
+ return {
+ lineItems: stored.lineItems,
+ totalAmount: Number(stored.totalAmount ?? booking.totalAmount),
+ currency: stored.currency ?? booking.paymentCurrency,
+ };
+ }
+
+ const evalInput = await this.buildEvalInputForBooking(booking);
+ const ruleResult = await this.ruleEngineService.evaluate(evalInput);
+ const lineItems: PriceLineItemDto[] = [];
+ let total = 0;
+
+ const baseLines = await this.computeBaseRailLines(booking, evalInput);
+ for (const line of baseLines) {
+ lineItems.push(line);
+ total += line.amount;
+ }
+
+ for (const mod of ruleResult.appliedModifiers) {
+ lineItems.push({
+ code: mod.surchargeTypeCode,
+ description: `Surcharge: ${mod.surchargeTypeCode}`,
+ amount: mod.calculatedAmount,
+ currency: mod.currency,
+ });
+ total += mod.calculatedAmount;
+ }
+
+ if (lineItems.length === 0) {
+ total = Number(booking.totalAmount);
+ lineItems.push({
+ code: 'TOTAL',
+ description: 'Contract total',
+ amount: total,
+ currency: booking.paymentCurrency,
+ });
+ }
+
+ return {
+ lineItems,
+ totalAmount: total || Number(booking.totalAmount),
+ currency: booking.paymentCurrency,
+ };
+ }
+
+ /** Recompute priority on submit (USD + service tier). */
+ async computeSubmitPriorityScore(booking: Booking): Promise {
+ const evalInput = await this.buildEvalInputForBooking(booking);
+ const ruleResult = await this.ruleEngineService.evaluate(evalInput);
+ let score = ruleResult.priorityScore;
+
+ const serviceType = await this.serviceTypesService.findById(booking.serviceTypeId);
+ if (booking.paymentCurrency === 'USD' && serviceType) {
+ const code = (serviceType.code ?? '').toUpperCase();
+ const hasForwarding =
+ serviceType.includesFirstMile ||
+ serviceType.includesLastMile ||
+ code.includes('FORWARD') ||
+ code.includes('Y');
+ const railOnly = code.includes('RAIL') && !hasForwarding;
+
+ if (hasForwarding) score += 1000;
+ else if (railOnly || code.includes('X')) score += 500;
+ }
+
+ return score;
+ }
+
+ private async computeBaseRailLines(
+ booking: Booking,
+ evalInput: BookingEvaluationInput,
+ ): Promise {
+ const liveRates = await this.ratesService.findLiveRates();
+ const currency = booking.paymentCurrency;
+ const isBulk = booking.freightType === 'BULK';
+console.log('liveRates----', liveRates);
+ const rateType =
+ booking.tradeDirection === 'IMPORT'
+ ? isBulk
+ ? 'BULK_IMPORT'
+ : 'CONTAINER_IMPORT'
+ : booking.tradeDirection === 'EXPORT'
+ ? isBulk
+ ? 'BULK_EXPORT'
+ : 'CONTAINER_EXPORT'
+ : 'INTERCITY_CONTAINER';
+
+
+ console.log('rateType----', rateType);
+
+ const lines: PriceLineItemDto[] = [];
+ const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
+
+ for (const container of evalInput.containers) {
+ console.log('container----', container);
+ const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
+ console.log('rate----', rate);
+ if (!rate) continue;
+
+ const amount = this.amountForRate(rate, container.quantity, wagonCount);
+ lines.push({
+ code: rateType,
+ description: `Base rail (${rateType})`,
+ amount,
+ currency: rate.currency,
+ });
+ }
+
+ if (lines.length === 0) {
+ const fallback = liveRates.find(
+ (r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
+ );
+ if (fallback) {
+ const amount = this.amountForRate(fallback, 1, wagonCount);
+ lines.push({
+ code: rateType,
+ description: `Base rail (${rateType})`,
+ amount,
+ currency: fallback.currency,
+ });
+ }
+ }
+
+ return lines;
+ }
+
+ private pickRate(
+ rates: Rate[],
+ rateType: string,
+ containerTypeId: string,
+ currency: string,
+ ): Rate | undefined {
+ return (
+ rates.find(
+ (r) =>
+ r.rateType === rateType &&
+ r.currency === currency &&
+ r.containerTypeId === containerTypeId,
+ ) ??
+ rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId)
+ );
+ }
+
+ private amountForRate(rate: Rate, quantity: number, wagonCount: number): number {
+ const value = Number(rate.rateValue);
+ switch (rate.rateUnit) {
+ case 'PER_CONTAINER':
+ return value * quantity;
+ case 'PER_WAGON':
+ return value * wagonCount;
+ case 'PER_TON':
+ return value * quantity;
+ case 'FLAT':
+ return value;
+ default:
+ return value * quantity;
+ }
+ }
+
+ private async persistPriceRun(
+ bookingId: string,
+ modifiers: AppliedCargoModifier[],
+ _total: number,
+ ): Promise {
+ await this.bookingsRepository.clearPricingArtifacts(bookingId);
+ const snapshots = await this.ruleEngineService.snapshotLiveRates(bookingId);
+
+ const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
+ const rows = modifiers
+ .map((m) => {
+ const snapshotId = snapshotByRateId.get(m.rateId);
+ if (!snapshotId) return null;
+ return {
+ bookingId,
+ surchargeTypeId: m.surchargeTypeId,
+ triggerValue: m.triggerValue,
+ calculatedAmount: m.calculatedAmount,
+ rateSnapshotId: snapshotId,
+ };
+ })
+ .filter((r): r is NonNullable => r !== null);
+
+ if (rows.length > 0) {
+ await this.bookingsRepository.createCargoModifiers(rows);
+ }
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts
new file mode 100644
index 000000000..fe9152149
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts
@@ -0,0 +1,10 @@
+import { ConflictException } from '@nestjs/common';
+import { Booking } from './entities/booking.entity';
+
+export function assertBookingStatus(booking: Booking, allowed: string[]): void {
+ if (!allowed.includes(booking.status)) {
+ throw new ConflictException(
+ `Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`,
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
new file mode 100644
index 000000000..ad76fa7a3
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
@@ -0,0 +1,288 @@
+import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
+
+import { RuleEngineService } from '../rule-engine/rule-engine.service';
+import { BookingContractService } from './booking-contract.service';
+import { BookingPricingService } from './booking-pricing.service';
+import { BookingsRepository } from './bookings.repository';
+import { assertBookingStatus } from './booking-status.util';
+import { Booking } from './entities/booking.entity';
+import { BookingsService } from './bookings.service';
+
+@Injectable()
+export class BookingTransitionService {
+ constructor(
+ private readonly bookingsRepository: BookingsRepository,
+ private readonly ruleEngineService: RuleEngineService,
+ private readonly pricingService: BookingPricingService,
+ private readonly contractService: BookingContractService,
+ @Inject(forwardRef(() => BookingsService))
+ private readonly bookingsService: BookingsService,
+ ) {}
+
+ async submit(bookingId: string): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
+
+ if (Number(booking.totalAmount) <= 0) {
+ throw new BadRequestException(
+ 'Generate a price before submitting (POST /bookings/:id/generate-price)',
+ );
+ }
+
+ const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
+ await this.ruleEngineService.snapshotLiveRates(bookingId);
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'SUBMITTED',
+ priorityScore,
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async requestChanges(
+ bookingId: string,
+ note: string,
+ actorId: string,
+ ): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, ['SUBMITTED']);
+
+ await this.bookingsRepository.createReviewNote(
+ bookingId,
+ note,
+ 'CHANGES_REQUESTED',
+ actorId,
+ );
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'CHANGES_REQUESTED',
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async acceptIntake(bookingId: string, actorId: string): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, ['SUBMITTED']);
+
+ await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
+ freightType: booking.freightType as 'CONTAINER' | 'BULK',
+ cargoTypeId: booking.cargoTypeId,
+ });
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'PENDING_APPROVAL',
+ approvedByStaffId: actorId,
+ approvedByStaffAt: new Date(),
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async staffReject(
+ bookingId: string,
+ reason: string,
+ actorId: string,
+ ): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']);
+
+ await this.bookingsRepository.createReviewNote(
+ bookingId,
+ reason,
+ 'REJECTION',
+ actorId,
+ );
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'REJECTED',
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async approveStep(
+ bookingId: string,
+ stepId: string,
+ actorId: string,
+ requiredRole: string,
+ ): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, [
+ 'PENDING_APPROVAL',
+ 'APPROVED_PENDING_SIGNATURE',
+ ]);
+
+ const step = await this.bookingsRepository.findApprovalStepById(
+ bookingId,
+ stepId,
+ );
+ if (!step || step.status !== 'PENDING') {
+ throw new BadRequestException('Approval step not found or already actioned');
+ }
+
+ const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
+ if (!next || next.id !== step.id) {
+ throw new BadRequestException(
+ 'Approval steps must be completed in order',
+ );
+ }
+
+ if (step.requiredRole !== requiredRole) {
+ throw new BadRequestException(
+ `Step requires role ${step.requiredRole}, not ${requiredRole}`,
+ );
+ }
+
+ const blocksRole = step.approvalRule?.blocksRole;
+ if (blocksRole && blocksRole === requiredRole) {
+ throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
+ }
+
+ await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
+
+ const updates: Record = {};
+ const now = new Date();
+
+ if (requiredRole === 'LINE_STAFF') {
+ updates.status = 'APPROVED_PENDING_SIGNATURE';
+ updates.approvedByStaffId = actorId;
+ updates.approvedByStaffAt = now;
+ } else if (requiredRole === 'DIRECTOR') {
+ updates.signedByDirectorId = actorId;
+ updates.signedByDirectorAt = now;
+ } else if (requiredRole === 'CEO') {
+ updates.signedByCeoId = actorId;
+ updates.signedByCeoAt = now;
+ }
+
+ const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId);
+ if (allDone) {
+ updates.status = 'APPROVED';
+ }
+
+ if (Object.keys(updates).length > 0) {
+ await this.bookingsRepository.update(bookingId, updates as never);
+ }
+
+ return this.bookingsService.findById(bookingId);
+ }
+
+ async rejectStep(
+ bookingId: string,
+ stepId: string,
+ actorId: string,
+ reason: string,
+ ): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
+
+ const step = await this.bookingsRepository.findApprovalStepById(
+ bookingId,
+ stepId,
+ );
+ if (!step) throw new BadRequestException('Approval step not found');
+
+ await this.bookingsRepository.completeApprovalStep(
+ step.id,
+ actorId,
+ 'REJECTED',
+ reason,
+ );
+
+ await this.bookingsRepository.createReviewNote(
+ bookingId,
+ reason,
+ 'REJECTION',
+ actorId,
+ );
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'REJECTED',
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async customerSign(bookingId: string): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, ['CONTRACT_READY']);
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'SIGNED_CUSTOMER',
+ customerSignedAt: new Date(),
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async marketingApprove(bookingId: string, actorId: string): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'FULLY_EXECUTED',
+ fullyExecutedAt: new Date(),
+ marketingApprovedById: actorId,
+ marketingApprovedAt: new Date(),
+ lockedAt: new Date(),
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async startTransit(bookingId: string): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, ['PAID']);
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'IN_TRANSIT',
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async complete(bookingId: string): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, ['IN_TRANSIT']);
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'COMPLETED',
+ endDate: new Date(),
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async cancel(bookingId: string, reason: string): Promise {
+ const booking = await this.bookingsService.findById(bookingId);
+ assertBookingStatus(booking, [
+ 'DRAFT',
+ 'SUBMITTED',
+ 'CHANGES_REQUESTED',
+ 'PENDING_APPROVAL',
+ 'CONTRACT_READY',
+ ]);
+
+ await this.bookingsRepository.createReviewNote(
+ bookingId,
+ reason,
+ 'REJECTION',
+ );
+
+ const updated = await this.bookingsRepository.update(bookingId, {
+ status: 'CANCELLED',
+ } as never);
+ return this.bookingsService.findById(updated!.id);
+ }
+
+ async enrichBookingResponse(booking: Booking): Promise {
+ const note = await this.bookingsRepository.findLatestReviewNote(
+ booking.id,
+ 'CHANGES_REQUESTED',
+ );
+ const summary =
+ booking.contractSummary ??
+ this.contractService.buildContractSummary(booking);
+ return {
+ ...booking,
+ latestChangeRequestNote: note?.note ?? null,
+ contractSummary: summary,
+ };
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
index 41a1a09c4..367aab41f 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
@@ -3,6 +3,7 @@ import {
Controller,
Delete,
Get,
+ Header,
HttpCode,
Param,
ParseUUIDPipe,
@@ -10,10 +11,15 @@ import {
Post,
Query,
Request,
+ Res,
+ StreamableFile,
UploadedFiles,
+ UseGuards,
UseInterceptors,
-} from "@nestjs/common";
-import { AnyFilesInterceptor } from "@nestjs/platform-express";
+} from '@nestjs/common';
+import { CurrentUser } from '@edr/api-common';
+import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
+import { AnyFilesInterceptor } from '@nestjs/platform-express';
import {
ApiBearerAuth,
ApiBody,
@@ -21,181 +27,417 @@ import {
ApiOkResponse,
ApiOperation,
ApiTags,
-} from "@nestjs/swagger";
+} from '@nestjs/swagger';
+import type { Response } from 'express';
-import { BookingReferenceDataService } from "./booking-reference-data.service";
-import { BookingsService } from "./bookings.service";
-import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto";
-import { CreateBookingDto } from "./dto/create-booking.dto";
-import { FilterBookingDto } from "./dto/filter-booking.dto";
-import { UpdateBookingDto } from "./dto/update-booking.dto";
-import { UpdateStatusDto } from "./dto/update-status.dto";
+import { BookingContractService } from './booking-contract.service';
+import { BookingPaymentService } from './booking-payment.service';
+import { BookingPricingService } from './booking-pricing.service';
+import { BookingTransitionService } from './booking-transition.service';
+import { BookingReferenceDataService } from './booking-reference-data.service';
+import { BookingsService } from './bookings.service';
+import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
+import { CreateBookingDto } from './dto/create-booking.dto';
+import { FilterBookingDto } from './dto/filter-booking.dto';
+import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
+import {
+ ApproveStepDto,
+ CancelBookingDto,
+ RejectStepDto,
+ RequestChangesDto,
+ StaffRejectDto,
+} from './dto/request-changes.dto';
+import { ContractViewDto } from './dto/contract-view.dto';
+import { SignContractDto } from './dto/sign-contract.dto';
+import { UpdateBookingDto } from './dto/update-booking.dto';
+import {
+ type AuthUserPayload,
+ resolveAuthUserId,
+} from '../../common/resolve-auth-user-id';
-@ApiTags("bookings")
-@Controller("bookings")
+@ApiTags('bookings')
+@Controller('bookings')
@ApiBearerAuth()
export class BookingsController {
constructor(
private readonly bookingsService: BookingsService,
private readonly bookingReferenceDataService: BookingReferenceDataService,
+ private readonly pricingService: BookingPricingService,
+ private readonly transitionService: BookingTransitionService,
+ private readonly contractService: BookingContractService,
+ private readonly paymentService: BookingPaymentService,
) {}
- // ── 1. Create booking (multipart/form-data) ──────────────────────────
@Post()
@UseInterceptors(AnyFilesInterceptor())
- @ApiConsumes("multipart/form-data")
- @ApiOperation({
- summary: "Create a new freight booking",
- description:
- "Accepts all booking fields as form fields + dynamic file keys (e.g. passport, license). " +
- "Auto-enables consolidation when container quantity does not fill a whole wagon; attempts partner match or PENDING_CONSOLIDATION.",
- })
- @ApiBody({
- description:
- "Booking form data. Attach files with any field name (e.g. passport, tin_certificate). " +
- "Each uploaded file is saved as a row in the files table (resource=bookings).",
- type: CreateBookingDto,
- })
+ @ApiConsumes('multipart/form-data')
+ @ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
+ @ApiBody({ type: CreateBookingDto })
create(
@Body() dto: CreateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
- @Request() req: any,
+ @Request() req: { user?: { id?: string; sub?: string } },
) {
- console.log(
- "[BookingsController] Files received:",
- files?.length,
- files?.map((f) => ({
- fieldname: f.fieldname,
- originalname: f.originalname,
- size: f.size,
- mimetype: f.mimetype,
- })),
- );
- const userId: string | undefined = req.user?.id ?? req.user?.sub;
+ const userId = req.user?.id ?? req.user?.sub;
return this.bookingsService.create(dto, files ?? [], userId);
}
- // ── 2. Update draft booking (multipart/form-data) ─────────────────────
- @Patch(":id")
+ @Patch(':id')
@UseInterceptors(AnyFilesInterceptor())
- @ApiConsumes("multipart/form-data")
+ @ApiConsumes('multipart/form-data')
@ApiOperation({
- summary: "Update a draft booking",
- description:
- "Only DRAFT bookings can be updated. New files are merged into existing documents.",
+ summary: 'Update booking',
+ description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
})
@ApiBody({ type: UpdateBookingDto })
update(
- @Param("id", ParseUUIDPipe) id: string,
+ @Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.bookingsService.update(id, dto, files ?? []);
}
- // ── 3. List bookings (paginated + filtered) ───────────────────────────
@Get()
- @ApiOperation({
- summary: "List freight bookings (paginated)",
- description:
- "Filter by status, customerId, contractType, serviceTypeId, cargoTypeId, tradeDirection, " +
- "paymentCurrency, allowConsolidation, consolidationPaired. " +
- "Sort by createdAt or priorityScore.",
- })
+ @ApiOperation({ summary: 'List freight bookings (paginated)' })
findAll(@Query() filter: FilterBookingDto) {
return this.bookingsService.findAll(filter);
}
- // ── Booking form catalog (must be before :id) ─────────────────────────
- @Get("reference-data")
+ @Get('queues/:queue')
@ApiOperation({
- summary: "Booking form catalog",
- description:
- "Returns yards, container types (grouped by size), service types, shipping lines, " +
- "and hierarchical cargo types for the booking UI in a single payload.",
+ summary: 'List bookings for a dashboard queue',
+ description: 'Queues: intake, approval, signatures, marketing, finance',
})
+ findQueue(
+ @Param('queue') queue: string,
+ @Query() filter: FilterBookingDto,
+ @Query('excludeBulk') excludeBulk?: string,
+ ) {
+ return this.bookingsService.findQueue(queue, filter, {
+ excludeBulk: excludeBulk === 'true',
+ });
+ }
+
+ @Get('reference-data')
+ @ApiOperation({ summary: 'Booking form catalog' })
@ApiOkResponse({ type: BookingReferenceDataDto })
getReferenceData(): Promise {
return this.bookingReferenceDataService.getReferenceData();
}
- // ── 5. Lookup by reference (must be before :id to avoid conflict) ─────
- @Get("by-reference/:reference")
- @ApiOperation({
- summary: "Get a freight booking by reference number",
- description: "Lookup booking by its human-readable reference string.",
- })
- findByReference(@Param("reference") reference: string) {
- return this.bookingsService.findByReference(reference);
+ @Get('by-reference/:reference')
+ @ApiOperation({ summary: 'Get booking by reference' })
+ async findByReference(@Param('reference') reference: string) {
+ const booking = await this.bookingsService.findByReference(reference);
+ return this.transitionService.enrichBookingResponse(booking);
}
- // ── 4. Get single booking by ID ───────────────────────────────────────
- @Get(":id")
- @ApiOperation({ summary: "Get a freight booking by ID" })
- findOne(@Param("id", ParseUUIDPipe) id: string) {
- return this.bookingsService.findById(id);
+ @Get(':id')
+ @ApiOperation({ summary: 'Get booking by ID' })
+ async findOne(@Param('id', ParseUUIDPipe) id: string) {
+ const booking = await this.bookingsService.findById(id);
+ return this.transitionService.enrichBookingResponse(booking);
}
- // ── 6. Soft-delete (DRAFT only) ───────────────────────────────────────
- @Delete(":id")
+ @Delete(':id')
@HttpCode(204)
- @ApiOperation({
- summary: "Soft-delete a freight booking",
- description: "Only DRAFT bookings can be deleted.",
- })
- remove(@Param("id", ParseUUIDPipe) id: string) {
+ @ApiOperation({ summary: 'Soft-delete DRAFT booking' })
+ remove(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.remove(id);
}
- // ── 7. Unified status transition ──────────────────────────────────────
- @Patch(":id/status")
- @ApiOperation({
- summary: "Transition booking status",
- description:
- "Unified endpoint for all status transitions. Actions: " +
- "SUBMIT, APPROVE_STAFF, APPROVE_DIRECTOR, APPROVE_CEO, REJECT, CANCEL, ACTIVATE, EXPIRE. " +
- "Approval routing: Standard → LINE_STAFF → DIRECTOR → SIGNED. " +
- "Bulk/high-volume → DIRECTOR → CEO → SIGNED.",
- })
- updateStatus(
- @Param("id", ParseUUIDPipe) id: string,
- @Body() dto: UpdateStatusDto,
+ @Post(':id/documents')
+ @UseInterceptors(AnyFilesInterceptor())
+ @ApiConsumes('multipart/form-data')
+ @ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' })
+ async uploadDocuments(
+ @Param('id', ParseUUIDPipe) id: string,
+ @UploadedFiles() files: Express.Multer.File[],
) {
- return this.bookingsService.updateStatus(id, dto);
+ const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
+ return this.transitionService.enrichBookingResponse(booking);
}
- // ── 8. Request or auto-pair consolidation ─────────────────────────────
- @Post(":id/consolidation")
+ @Post(':id/generate-price')
+ @ApiOperation({ summary: 'Generate price preview (DRAFT only)' })
+ @ApiOkResponse({ type: GeneratePriceResponseDto })
+ generatePrice(@Param('id', ParseUUIDPipe) id: string) {
+ return this.pricingService.generatePrice(id);
+ }
+
+ @Post(':id/submit')
+ @ApiOperation({ summary: 'Customer submit booking' })
+ async submit(@Param('id', ParseUUIDPipe) id: string) {
+ const booking = await this.transitionService.submit(id);
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/staff/request-changes')
+ @UseGuards(JwtGuard)
+ @ApiOperation({ summary: 'Staff return booking for customer updates' })
+ async requestChanges(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: RequestChangesDto,
+ @CurrentUser() user: AuthUserPayload,
+ ) {
+ const booking = await this.transitionService.requestChanges(
+ id,
+ dto.note,
+ resolveAuthUserId(user),
+ );
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/staff/accept')
+ @UseGuards(JwtGuard)
+ @ApiOperation({ summary: 'Staff accept intake → start approval chain' })
+ async acceptIntake(
+ @Param('id', ParseUUIDPipe) id: string,
+ @CurrentUser() user: AuthUserPayload,
+ ) {
+ const booking = await this.transitionService.acceptIntake(
+ id,
+ resolveAuthUserId(user),
+ );
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/staff/reject')
+ @UseGuards(JwtGuard)
+ @ApiOperation({ summary: 'Staff final reject' })
+ async staffReject(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: StaffRejectDto,
+ @CurrentUser() user: AuthUserPayload,
+ ) {
+ const booking = await this.transitionService.staffReject(
+ id,
+ dto.reason,
+ resolveAuthUserId(user),
+ );
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/approval-steps/:stepId/approve')
+ @UseGuards(JwtGuard)
+ @ApiOperation({ summary: 'Approve one approval step in sequence' })
+ async approveStep(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Param('stepId', ParseUUIDPipe) stepId: string,
+ @Body() dto: ApproveStepDto,
+ @CurrentUser() user: AuthUserPayload,
+ ) {
+ const booking = await this.transitionService.approveStep(
+ id,
+ stepId,
+ resolveAuthUserId(user),
+ dto.requiredRole,
+ );
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/approval-steps/:stepId/reject')
+ @UseGuards(JwtGuard)
+ @ApiOperation({ summary: 'Reject at approval step' })
+ async rejectStep(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Param('stepId', ParseUUIDPipe) stepId: string,
+ @Body() dto: RejectStepDto,
+ @CurrentUser() user: AuthUserPayload,
+ ) {
+ const booking = await this.transitionService.rejectStep(
+ id,
+ stepId,
+ resolveAuthUserId(user),
+ dto.reason,
+ );
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/contract/generate')
+ @UseGuards(JwtGuard)
+ @ApiOperation({ summary: 'Generate contract PDF from template' })
+ async generateContract(@Param('id', ParseUUIDPipe) id: string) {
+ const booking = await this.contractService.generateContract(id);
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Get(':id/contract/view')
+ @ApiOkResponse({ type: ContractViewDto })
+ @ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
+ getContractView(@Param('id', ParseUUIDPipe) id: string) {
+ return this.contractService.getContractView(id);
+ }
+
+ @Get(':id/contract/document')
+ @ApiOperation({ summary: 'Download contract PDF' })
+ async downloadContractDocument(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Res({ passthrough: true }) res: Response,
+ ) {
+ const { stream, record } = await this.contractService.streamContract(id);
+ res.set({
+ 'Content-Type': record.mimeType ?? 'application/pdf',
+ 'Content-Disposition': `attachment; filename="${record.name}"`,
+ });
+ return new StreamableFile(stream);
+ }
+
+ @Get(':id/contract')
+ @ApiOperation({ summary: 'Download contract file (alias)' })
+ async downloadContract(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Res({ passthrough: true }) res: Response,
+ ) {
+ return this.downloadContractDocument(id, res);
+ }
+
+ @Post(':id/contract/sign')
+ @ApiOperation({ summary: 'Apply digital signature (customer or staff)' })
+ async signContract(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: SignContractDto,
+ @Request() req: { user?: { id?: string; sub?: string }; ip?: string },
+ ) {
+ const userId = req.user?.id ?? req.user?.sub;
+ const booking = await this.contractService.signContract(id, dto, {
+ signerUserId: userId,
+ ipAddress: req.ip,
+ });
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Get(':id/contract/signatures')
+ @ApiOperation({ summary: 'List contract signatures' })
+ getContractSignatures(@Param('id', ParseUUIDPipe) id: string) {
+ return this.contractService.getSignatures(id);
+ }
+
+ @Get(':id/summary')
+ @ApiOperation({ summary: 'Contract summary string for dashboard' })
+ getSummary(@Param('id', ParseUUIDPipe) id: string) {
+ return this.contractService.getSummary(id);
+ }
+
+ @Post(':id/customer/sign')
@ApiOperation({
- summary: "Request freight consolidation",
- description:
- "Searches for a partner whose container quantity complements yours to fill whole wagon(s) " +
- "(same route, same container type). Pairs on match or sets PENDING_CONSOLIDATION with a status message.",
+ summary: 'Customer digital signature (deprecated — use POST contract/sign)',
})
- requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
+ async customerSign(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: SignContractDto,
+ @Request() req: { user?: { id?: string; sub?: string }; ip?: string },
+ ) {
+ const payload: SignContractDto = { ...dto, role: 'CUSTOMER' };
+ const booking = await this.contractService.signContract(id, payload, {
+ signerUserId: req.user?.id ?? req.user?.sub,
+ ipAddress: req.ip,
+ });
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/marketing/approve')
+ @UseGuards(JwtGuard)
+ @ApiOperation({
+ summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
+ })
+ async marketingApprove(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: SignContractDto,
+ @CurrentUser() user: AuthUserPayload,
+ @Request() req: { ip?: string },
+ ) {
+ const payload: SignContractDto = {
+ ...dto,
+ role: 'STAFF',
+ };
+ const booking = await this.contractService.signContract(id, payload, {
+ signerUserId: resolveAuthUserId(user),
+ ipAddress: req.ip,
+ });
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/payment/pnr')
+ @ApiOperation({ summary: 'Generate PNR code (ETB)' })
+ async generatePnr(@Param('id', ParseUUIDPipe) id: string) {
+ const booking = await this.paymentService.generatePnr(id);
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/payment/proof')
+ @UseInterceptors(AnyFilesInterceptor())
+ @ApiConsumes('multipart/form-data')
+ @ApiOperation({ summary: 'Upload USD payment proof' })
+ async submitPaymentProof(
+ @Param('id', ParseUUIDPipe) id: string,
+ @UploadedFiles() files: Express.Multer.File[],
+ ) {
+ const file = files?.[0];
+ const booking = await this.paymentService.submitPaymentProof(id, file);
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Get(':id/payment/request-letter')
+ @ApiOperation({ summary: 'Download payment request letter (USD stub)' })
+ @Header('Content-Type', 'text/plain')
+ async paymentRequestLetter(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Res({ passthrough: true }) res: Response,
+ ) {
+ const { buffer, filename } =
+ await this.paymentService.getPaymentRequestLetter(id);
+ res.set('Content-Disposition', `attachment; filename="${filename}"`);
+ return new StreamableFile(buffer);
+ }
+
+ @Post(':id/payment/verify')
+ @ApiOperation({ summary: 'Finance verify USD payment' })
+ async verifyPayment(@Param('id', ParseUUIDPipe) id: string) {
+ const booking = await this.paymentService.verifyPayment(id);
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/operations/start-transit')
+ @ApiOperation({ summary: 'Mark in transit' })
+ async startTransit(@Param('id', ParseUUIDPipe) id: string) {
+ const booking = await this.transitionService.startTransit(id);
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/operations/complete')
+ @ApiOperation({ summary: 'Mark completed' })
+ async complete(@Param('id', ParseUUIDPipe) id: string) {
+ const booking = await this.transitionService.complete(id);
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/cancel')
+ @ApiOperation({ summary: 'Cancel booking' })
+ async cancel(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Body() dto: CancelBookingDto,
+ ) {
+ const booking = await this.transitionService.cancel(id, dto.reason);
+ return this.transitionService.enrichBookingResponse(booking);
+ }
+
+ @Post(':id/consolidation')
+ @ApiOperation({ summary: 'Request freight consolidation' })
+ requestConsolidation(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.requestConsolidation(id);
}
- // ── 9. Remove consolidation pairing ───────────────────────────────────
- @Delete(":id/consolidation")
- @ApiOperation({
- summary: "Remove consolidation pairing",
- description:
- "Unpairs both bookings and returns them to PENDING_CONSOLIDATION status.",
- })
- removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
+ @Delete(':id/consolidation')
+ @ApiOperation({ summary: 'Remove consolidation pairing' })
+ removeConsolidation(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.removeConsolidation(id);
}
- // ── 10. Get consolidation details ─────────────────────────────────────
- @Get(":id/consolidation")
- @ApiOperation({
- summary: "Get consolidation details",
- description:
- "Returns partner booking details and split billing information.",
- })
- getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
+ @Get(':id/consolidation')
+ @ApiOperation({ summary: 'Get consolidation details' })
+ getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.getConsolidationDetails(id);
}
-
}
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
index 9785ec270..71006f519 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
@@ -1,20 +1,33 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
-import { CustomersModule } from '../customers/customers.module';
+// import { CustomersModule } from '../customers/customers.module';
+import { CompaniesModule } from '../companies/companies.module';
import { FilesModule } from '../files/files.module';
import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
+import { BookingContractService } from './booking-contract.service';
+import { BookingPaymentService } from './booking-payment.service';
+import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
+import { BookingTransitionService } from './booking-transition.service';
import { BookingsController } from './bookings.controller';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { BookingsService } from './bookings.service';
+import { PaymentsWebhookController } from './payments-webhook.controller';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
+import { BookingContractSignature } from './entities/booking-contract-signature.entity';
+import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
+import { ContractPdfService } from '../../contracts/contract-pdf.service';
+import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
+import { ContractRendererService } from '../../contracts/contract-renderer.service';
+import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
+import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
@Module({
imports: [
@@ -24,19 +37,31 @@ import { Booking } from './entities/booking.entity';
BookingCargoModifier,
BookingApprovalStep,
BookingRateSnapshot,
+ BookingReviewNote,
+ BookingContractSignature,
]),
FilesModule,
MinioModule,
- CustomersModule,
+ CompaniesModule,
+ // CustomersModule,
RuleEngineModule,
],
- controllers: [BookingsController],
+ controllers: [BookingsController, PaymentsWebhookController],
providers: [
BookingsService,
BookingsRepository,
ConsolidationService,
BookingReferenceDataService,
+ BookingPricingService,
+ BookingTransitionService,
+ BookingContractService,
+ BookingPaymentService,
+ ContractTemplateResolver,
+ ContractViewModelBuilder,
+ ContractPricingScheduleBuilder,
+ ContractRendererService,
+ ContractPdfService,
],
- exports: [BookingsService],
+ exports: [BookingsService, BookingsRepository],
})
export class BookingsModule {}
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
index f329c942f..eb7bbd10a 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
@@ -1,14 +1,19 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
-import { DataSource, Repository } from 'typeorm';
+import { DataSource, FindOptionsWhere, Repository } from 'typeorm';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
+import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
+import {
+ BookingContractSignature,
+ ContractSignerRole,
+} from './entities/booking-contract-signature.entity';
import { FileRecord } from '../files/entities/file.entity';
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
@@ -56,7 +61,8 @@ export class BookingsRepository extends BaseRepository {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.bookingContainers', 'bc')
.leftJoinAndSelect('bc.containerType', 'ct')
- .leftJoinAndSelect('booking.customer', 'customer')
+ .leftJoinAndSelect('booking.company', 'company')
+ // .leftJoinAndSelect('booking.customer', 'customer')
.leftJoinAndSelect('booking.train', 'train')
.leftJoinAndSelect('booking.serviceType', 'st')
.leftJoinAndSelect('booking.cargoType', 'cargo')
@@ -66,6 +72,7 @@ export class BookingsRepository extends BaseRepository {
.leftJoinAndSelect('booking.approvalSteps', 'steps')
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
+ .leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
.where('booking.id = :id', { id })
.leftJoinAndMapMany(
'booking.files',
@@ -216,15 +223,35 @@ export class BookingsRepository extends BaseRepository {
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
}
- /** Get pending approval step for a role. */
+ /** Lowest-order pending approval step (sequential enforcement). */
+ async findNextPendingApprovalStep(
+ bookingId: string,
+ ): Promise {
+ return this.dataSource.getRepository(BookingApprovalStep).findOne({
+ where: { bookingId, status: 'PENDING' },
+ order: { stepOrder: 'ASC' },
+ relations: ['approvalRule'],
+ });
+ }
+
+ async findApprovalStepById(
+ bookingId: string,
+ stepId: string,
+ ): Promise {
+ return this.dataSource.getRepository(BookingApprovalStep).findOne({
+ where: { bookingId, id: stepId },
+ relations: ['approvalRule'],
+ });
+ }
+
+ /** Get pending approval step for a role (must match next in sequence). */
async findPendingApprovalStep(
bookingId: string,
requiredRole: string,
): Promise {
- return this.dataSource.getRepository(BookingApprovalStep).findOne({
- where: { bookingId, requiredRole, status: 'PENDING' },
- order: { stepOrder: 'ASC' },
- });
+ const next = await this.findNextPendingApprovalStep(bookingId);
+ if (!next || next.requiredRole !== requiredRole) return null;
+ return next;
}
/** Mark an approval step complete. */
@@ -277,4 +304,121 @@ export class BookingsRepository extends BaseRepository {
where: { bookingId, rateId },
});
}
+
+ async createReviewNote(
+ bookingId: string,
+ note: string,
+ type: ReviewNoteType,
+ authorId?: string,
+ ): Promise {
+ const repo = this.dataSource.getRepository(BookingReviewNote);
+ return repo.save(
+ repo.create({ bookingId, note, type, authorId: authorId ?? null }),
+ );
+ }
+
+ async findLatestReviewNote(
+ bookingId: string,
+ type?: ReviewNoteType,
+ ): Promise {
+ const repo = this.dataSource.getRepository(BookingReviewNote);
+ return repo.findOne({
+ where: type ? { bookingId, type } : { bookingId },
+ order: { createdAt: 'DESC' },
+ });
+ }
+
+ async clearPricingArtifacts(bookingId: string): Promise {
+ await this.dataSource.getRepository(BookingCargoModifier).delete({ bookingId });
+ await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
+ }
+
+ async findByPnrCode(pnrCode: string): Promise {
+ return this.repository.findOne({ where: { pnrCode } });
+ }
+
+ /** Queue listing with optional bulk exclusion for LINE_STAFF. */
+ async findQueue(options: {
+ status: string | string[];
+ page?: number;
+ pageSize?: number;
+ excludeBulk?: boolean;
+ sortBy?: string;
+ sortOrder?: 'ASC' | 'DESC';
+ }): Promise<{ items: Booking[]; total: number }> {
+ const page = options.page ?? 1;
+ const pageSize = options.pageSize ?? 20;
+ const statuses = Array.isArray(options.status) ? options.status : [options.status];
+
+ const qb = this.repository
+ .createQueryBuilder('booking')
+ .leftJoinAndSelect('booking.company', 'company')
+ // .leftJoinAndSelect('booking.customer', 'customer')
+ .leftJoinAndSelect('booking.cargoType', 'cargo')
+ .leftJoinAndSelect('booking.serviceType', 'serviceType')
+ .where('booking.status IN (:...statuses)', { statuses });
+
+ if (options.excludeBulk) {
+ qb.andWhere("booking.freight_type = 'CONTAINER'");
+ }
+
+ const sortField =
+ options.sortBy === 'priorityScore' ? 'booking.priority_score' : 'booking.created_at';
+ qb.orderBy(sortField, options.sortOrder ?? 'DESC');
+
+ const [items, total] = await qb
+ .skip((page - 1) * pageSize)
+ .take(pageSize)
+ .getManyAndCount();
+
+ return { items, total };
+ }
+
+ async findAndCountFiltered(where: FindOptionsWhere, options: {
+ skip: number;
+ take: number;
+ order: Record;
+ }): Promise<[Booking[], number]> {
+ return this.repository.findAndCount({
+ where,
+ skip: options.skip,
+ take: options.take,
+ order: options.order,
+ });
+ }
+
+ findContractSignatures(bookingId: string): Promise {
+ return this.dataSource.getRepository(BookingContractSignature).find({
+ where: { bookingId },
+ relations: ['signatureFile'],
+ order: { signedAt: 'ASC' },
+ });
+ }
+
+ findContractSignature(
+ bookingId: string,
+ role: ContractSignerRole,
+ ): Promise {
+ return this.dataSource.getRepository(BookingContractSignature).findOne({
+ where: { bookingId, signerRole: role },
+ relations: ['signatureFile'],
+ });
+ }
+
+ async saveContractSignature(
+ data: Partial,
+ ): Promise {
+ const repo = this.dataSource.getRepository(BookingContractSignature);
+ const existing = await repo.findOne({
+ where: {
+ bookingId: data.bookingId!,
+ signerRole: data.signerRole!,
+ },
+ });
+ if (existing) {
+ Object.assign(existing, data);
+ return repo.save(existing);
+ }
+ return repo.save(repo.create(data));
+ }
}
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
index 3aaa12a70..b18a2600b 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -6,7 +6,8 @@ import {
} from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
-import { CustomersService } from '../customers/customers.service';
+// import { CustomersService } from '../customers/customers.service';
+import { CompaniesService } from '../companies/companies.service';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
@@ -16,10 +17,11 @@ import {
} from '../rule-engine/rule-engine.service';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
+import { assertFreightShape } from './booking-freight.util';
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
-import { UpdateStatusDto } from './dto/update-status.dto';
+import { CUSTOMER_EDITABLE_STATUSES, FreightType } from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
@@ -29,7 +31,8 @@ export class BookingsService {
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
- private readonly customersService: CustomersService,
+ // private readonly customersService: CustomersService,
+ private readonly companiesService: CompaniesService,
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService,
@@ -42,22 +45,23 @@ export class BookingsService {
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
}
- /** Build evaluation input from DTO containers. */
- private async buildEvalInput(
- dto: Pick<
- CreateBookingDto,
- | 'cargoTypeId'
- | 'serviceTypeId'
- | 'paymentCurrency'
- | 'tradeDirection'
- | 'isHazardous'
- | 'allowConsolidation'
- | 'shippingLineId'
- | 'containers'
- >,
- ): Promise {
+ /** Build evaluation input from booking freight shape. */
+ private async buildEvalInput(dto: {
+ freightType: FreightType;
+ cargoTypeId?: string | null;
+ serviceTypeId: string;
+ paymentCurrency: string;
+ tradeDirection: string;
+ isHazardous?: boolean;
+ allowConsolidation?: boolean;
+ shippingLineId?: string | null;
+ containers: CreateBookingContainerDto[];
+ }): Promise {
+ const containerLines =
+ dto.freightType === 'CONTAINER' ? dto.containers : [];
+
const containers = await Promise.all(
- dto.containers.map(async (c) => {
+ containerLines.map(async (c) => {
const ct = await this.containerTypesService.findById(c.containerTypeId);
const totalVgmTons = c.quantity * c.vgmPerUnitTons;
return {
@@ -69,13 +73,16 @@ export class BookingsService {
};
}),
);
+
return {
- cargoTypeId: dto.cargoTypeId,
+ freightType: dto.freightType,
+ cargoTypeId: dto.cargoTypeId ?? null,
serviceTypeId: dto.serviceTypeId,
paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous ?? false,
- allowConsolidation: dto.allowConsolidation,
+ allowConsolidation:
+ dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
shippingLineId: dto.shippingLineId,
containers,
};
@@ -149,24 +156,52 @@ export class BookingsService {
): Promise<{ booking: Booking; warnings: string[] }> {
const warnings: string[] = [];
- let customerId = dto.customerId;
- if (!customerId) {
+ // let customerId = dto.customerId;
+ // if (!customerId) {
+ // if (!userId) {
+ // throw new BadRequestException(
+ // 'customerId is required or must be resolvable from auth token',
+ // );
+ // }
+ // const customer = await this.customersService.findByUserId(userId);
+ // customerId = customer.id;
+ // }
+
+ let companyId = dto.companyId;
+ if (!companyId) {
if (!userId) {
throw new BadRequestException(
- 'customerId is required or must be resolvable from auth token',
+ 'companyId is required or must be resolvable from auth token',
);
}
- const customer = await this.customersService.findByUserId(userId);
- customerId = customer.id;
+ const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
+ companyId = company.id;
}
const reference = dto.reference || (await this.generateReference());
- const allowConsolidation = await this.resolveConsolidation(
- dto.containers,
- dto.allowConsolidation,
- );
+ const containers = dto.containers ?? [];
+ assertFreightShape({
+ freightType: dto.freightType,
+ cargoTypeId: dto.cargoTypeId,
+ containers,
+ });
- const evalInput = await this.buildEvalInput({ ...dto, allowConsolidation });
+ const allowConsolidation =
+ dto.freightType === 'CONTAINER'
+ ? await this.resolveConsolidation(containers, dto.allowConsolidation)
+ : false;
+
+ const evalInput = await this.buildEvalInput({
+ freightType: dto.freightType as FreightType,
+ cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null,
+ serviceTypeId: dto.serviceTypeId,
+ paymentCurrency: dto.paymentCurrency,
+ tradeDirection: dto.tradeDirection,
+ isHazardous: dto.isHazardous,
+ allowConsolidation,
+ shippingLineId: dto.shippingLineId,
+ containers,
+ });
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
this.ruleEngineService.assertNoHardBlocks(ruleResult);
@@ -174,7 +209,7 @@ export class BookingsService {
const booking = await this.bookingsRepository.create({
reference,
- customerId,
+ companyId,
trainId: dto.trainId,
contractType: dto.contractType,
previousContractId: dto.previousContractId,
@@ -185,7 +220,8 @@ export class BookingsService {
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,
tradeDirection: dto.tradeDirection,
- cargoTypeId: dto.cargoTypeId,
+ freightType: dto.freightType,
+ cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
cargoFreeText: dto.cargoFreeText,
shippingLineId: dto.shippingLineId,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
@@ -203,18 +239,19 @@ export class BookingsService {
paymentStatus: 'PENDING',
});
- await this.bookingsRepository.createContainers(
- booking.id,
- dto.containers.map((c, i) => ({
- containerTypeId: c.containerTypeId,
- quantity: c.quantity,
- vgmPerUnitTons: c.vgmPerUnitTons,
- weightResult: ruleResult.containerWeightResults[i],
- })),
- );
-
- const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
- warnings.push(`Estimated wagons required: ${wagonCount}`);
+ if (dto.freightType === 'CONTAINER') {
+ await this.bookingsRepository.createContainers(
+ booking.id,
+ containers.map((c, i) => ({
+ containerTypeId: c.containerTypeId,
+ quantity: c.quantity,
+ vgmPerUnitTons: c.vgmPerUnitTons,
+ weightResult: ruleResult.containerWeightResults[i],
+ })),
+ );
+ const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
+ warnings.push(`Estimated wagons required: ${wagonCount}`);
+ }
if (files.length > 0) {
try {
@@ -242,24 +279,51 @@ export class BookingsService {
files: Express.Multer.File[],
): Promise<{ booking: Booking; warnings: string[] }> {
const existing = await this.findById(id);
- if (existing.status !== 'DRAFT') {
- throw new BadRequestException('Only DRAFT bookings can be updated');
+ if (!CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
+ throw new BadRequestException(
+ 'Only DRAFT or CHANGES_REQUESTED bookings can be updated',
+ );
}
const warnings: string[] = [];
- const containers = dto.containers ?? existing.bookingContainers?.map((bc) => ({
- containerTypeId: bc.containerTypeId,
- quantity: bc.quantity,
- vgmPerUnitTons: Number(bc.vgmPerUnitTons),
- })) ?? [];
+ const freightType = (dto.freightType ?? existing.freightType) as FreightType;
+ let containers =
+ dto.containers ??
+ existing.bookingContainers?.map((bc) => ({
+ containerTypeId: bc.containerTypeId,
+ quantity: bc.quantity,
+ vgmPerUnitTons: Number(bc.vgmPerUnitTons),
+ })) ??
+ [];
- const allowConsolidation = await this.resolveConsolidation(
- containers,
- dto.allowConsolidation ?? existing.allowConsolidation,
- );
+ let cargoTypeId =
+ dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;
+
+ if (freightType === 'BULK') {
+ containers = [];
+ if (dto.containers !== undefined) {
+ await this.bookingsRepository.deleteContainers(id);
+ }
+ } else {
+ cargoTypeId = null;
+ if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== null) {
+ throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight');
+ }
+ }
+
+ assertFreightShape({ freightType, cargoTypeId, containers });
+
+ const allowConsolidation =
+ freightType === 'CONTAINER'
+ ? await this.resolveConsolidation(
+ containers,
+ dto.allowConsolidation ?? existing.allowConsolidation,
+ )
+ : false;
const evalInput = await this.buildEvalInput({
- cargoTypeId: dto.cargoTypeId ?? existing.cargoTypeId,
+ freightType,
+ cargoTypeId,
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
@@ -275,6 +339,8 @@ export class BookingsService {
const updates: Record = {
...dto,
+ freightType,
+ cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
allowConsolidation,
priorityScore: ruleResult.priorityScore,
};
@@ -285,7 +351,7 @@ export class BookingsService {
await this.bookingsRepository.update(id, updates);
- if (dto.containers) {
+ if (freightType === 'CONTAINER' && dto.containers) {
await this.bookingsRepository.deleteContainers(id);
await this.bookingsRepository.createContainers(
id,
@@ -322,10 +388,12 @@ export class BookingsService {
const where: Record = {};
if (filter.status) where.status = filter.status;
- if (filter.customerId) where.customerId = filter.customerId;
+ // if (filter.customerId) where.customerId = filter.customerId;
+ if (filter.companyId) where.companyId = filter.companyId;
if (filter.contractType) where.contractType = filter.contractType;
if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId;
if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId;
+ if (filter.freightType) where.freightType = filter.freightType;
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency;
if (filter.allowConsolidation !== undefined) {
@@ -345,6 +413,8 @@ export class BookingsService {
skip: (page - 1) * pageSize,
take: pageSize,
order: { [sortField]: sortDir },
+ relations: ['company', 'originYard', 'destinationYard', 'serviceType'],
+ // relations: ['customer', 'originYard', 'destinationYard', 'serviceType'],
});
return { items, total };
}
@@ -382,6 +452,21 @@ export class BookingsService {
return this.findById(booking.id);
}
+ /** Upload documents for a DRAFT booking. */
+ async uploadDocuments(
+ id: string,
+ files: Express.Multer.File[],
+ ): Promise {
+ const booking = await this.findById(id);
+ if (booking.status !== 'DRAFT') {
+ throw new BadRequestException(
+ 'Documents can only be uploaded for DRAFT bookings',
+ );
+ }
+ await this.filesService.uploadMany(id, 'bookings', files);
+ return this.findById(id);
+ }
+
async remove(id: string): Promise {
const booking = await this.findById(id);
if (booking.status !== 'DRAFT') {
@@ -390,201 +475,32 @@ export class BookingsService {
await this.bookingsRepository.softDelete(id);
}
- /** Unified status transition handler. */
- async updateStatus(id: string, dto: UpdateStatusDto): Promise {
- const booking = await this.findById(id);
- const { action, actorId, reason, requiredRole } = dto;
+ async findQueue(
+ queue: string,
+ filter: FilterBookingDto,
+ options?: { excludeBulk?: boolean },
+ ): Promise<{ items: Booking[]; total: number }> {
+ const statusMap: Record = {
+ intake: 'SUBMITTED',
+ approval: 'PENDING_APPROVAL',
+ signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'],
+ marketing: 'SIGNED_CUSTOMER',
+ finance: 'PAYMENT_VERIFICATION_IN_PROGRESS',
+ };
- switch (action) {
- case 'SUBMIT':
- return this.handleSubmit(booking);
- case 'SEND_QUOTATION':
- return this.handleSendQuotation(booking);
- case 'APPROVE_QUOTATION':
- return this.handleApproveQuotation(booking);
- case 'REJECT_QUOTATION':
- return this.handleRejectQuotation(booking, reason);
- case 'APPROVE_STEP':
- return this.handleApproveStep(booking, actorId, requiredRole);
- case 'APPROVE':
- return this.handleFullyApproved(booking);
- case 'CUSTOMER_SIGN':
- return this.handleCustomerSign(booking);
- case 'MARK_FULLY_EXECUTED':
- return this.handleFullyExecuted(booking);
- case 'MARK_PAID':
- return this.handleMarkPaid(booking);
- case 'START_TRANSIT':
- return this.handleStartTransit(booking);
- case 'COMPLETE':
- return this.handleComplete(booking);
- case 'REJECT':
- return this.handleReject(booking, actorId, reason);
- case 'CANCEL':
- return this.handleCancel(booking, reason);
- default:
- throw new BadRequestException(`Unknown action: ${action}`);
- }
- }
-
- /** SUBMIT: DRAFT → RFQ_SUBMITTED → PENDING_APPROVAL with approval steps and rate snapshots. */
- private async handleSubmit(booking: Booking): Promise {
- this.assertStatus(booking, ['DRAFT']);
-
- await this.bookingsRepository.update(booking.id, { status: 'RFQ_SUBMITTED' } as never);
- await this.ruleEngineService.snapshotLiveRates(booking.id);
- await this.ruleEngineService.instantiateApprovalSteps(booking.id, booking.cargoTypeId);
-
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'PENDING_APPROVAL',
- } as never);
- return updated!;
- }
-
- private async handleSendQuotation(booking: Booking): Promise {
- this.assertStatus(booking, ['RFQ_SUBMITTED']);
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'QUOTATION_SENT',
- } as never);
- return updated!;
- }
-
- private async handleApproveQuotation(booking: Booking): Promise {
- this.assertStatus(booking, ['QUOTATION_SENT']);
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'QUOTATION_APPROVED',
- } as never);
- return updated!;
- }
-
- private async handleRejectQuotation(booking: Booking, reason?: string): Promise {
- this.assertStatus(booking, ['QUOTATION_SENT']);
- if (!reason) throw new BadRequestException('reason is required for REJECT_QUOTATION');
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'QUOTATION_REJECTED',
- } as never);
- return updated!;
- }
-
- private async handleApproveStep(
- booking: Booking,
- actorId?: string,
- requiredRole?: string,
- ): Promise {
- this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
- if (!actorId || !requiredRole) {
- throw new BadRequestException('actorId and requiredRole are required for APPROVE_STEP');
+ const status = statusMap[queue];
+ if (!status) {
+ throw new BadRequestException(`Unknown queue: ${queue}`);
}
- const step = await this.bookingsRepository.findPendingApprovalStep(
- booking.id,
- requiredRole,
- );
- if (!step) {
- throw new BadRequestException(`No pending approval step for role ${requiredRole}`);
- }
-
- await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
-
- const allDone = await this.bookingsRepository.allApprovalStepsComplete(booking.id);
- if (allDone) {
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'APPROVED',
- } as never);
- return updated!;
- }
-
- return this.findById(booking.id);
- }
-
- private async handleFullyApproved(booking: Booking): Promise {
- this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'APPROVED',
- } as never);
- return updated!;
- }
-
- private async handleCustomerSign(booking: Booking): Promise {
- this.assertStatus(booking, ['APPROVED']);
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'SIGNED_CUSTOMER',
- customerSignedAt: new Date(),
- } as never);
- return updated!;
- }
-
- private async handleFullyExecuted(booking: Booking): Promise {
- this.assertStatus(booking, ['SIGNED_CUSTOMER']);
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'FULLY_EXECUTED',
- fullyExecutedAt: new Date(),
- } as never);
- return updated!;
- }
-
- private async handleMarkPaid(booking: Booking): Promise {
- this.assertStatus(booking, ['FULLY_EXECUTED', 'APPROVED', 'SIGNED_CUSTOMER']);
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'PAID',
- paymentStatus: 'PAID',
- } as never);
- return updated!;
- }
-
- private async handleStartTransit(booking: Booking): Promise {
- this.assertStatus(booking, ['PAID']);
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'IN_TRANSIT',
- } as never);
- return updated!;
- }
-
- private async handleComplete(booking: Booking): Promise {
- this.assertStatus(booking, ['IN_TRANSIT']);
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'COMPLETED',
- endDate: new Date(),
- } as never);
- return updated!;
- }
-
- private async handleReject(
- booking: Booking,
- actorId?: string,
- reason?: string,
- ): Promise {
- this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
- if (!actorId || !reason) {
- throw new BadRequestException('actorId and reason are required for REJECT');
- }
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'CANCELLED',
- } as never);
- return updated!;
- }
-
- private async handleCancel(booking: Booking, reason?: string): Promise {
- this.assertStatus(booking, [
- 'DRAFT',
- 'RFQ_SUBMITTED',
- 'QUOTATION_SENT',
- 'QUOTATION_APPROVED',
- 'PENDING_APPROVAL',
- ]);
- if (!reason) throw new BadRequestException('reason is required for CANCEL');
- const updated = await this.bookingsRepository.update(booking.id, {
- status: 'CANCELLED',
- } as never);
- return updated!;
- }
-
- private assertStatus(booking: Booking, allowed: string[]): void {
- if (!allowed.includes(booking.status)) {
- throw new ConflictException(
- `Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`,
- );
- }
+ return this.bookingsRepository.findQueue({
+ status,
+ page: filter.page,
+ pageSize: filter.pageSize,
+ excludeBulk: options?.excludeBulk ?? queue === 'approval',
+ sortBy: filter.sortBy,
+ sortOrder: filter.sortOrder,
+ });
}
async requestConsolidation(id: string): Promise<{
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts
new file mode 100644
index 000000000..4af9e535d
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts
@@ -0,0 +1,50 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+
+export class ContractSignatureDto {
+ @ApiProperty({ enum: ['CUSTOMER', 'STAFF'] })
+ role!: string;
+
+ @ApiProperty()
+ signerDisplayName!: string;
+
+ @ApiProperty()
+ signedAt!: string;
+
+ @ApiPropertyOptional()
+ signatureImageUrl?: string | null;
+}
+
+export class ContractViewDto {
+ @ApiProperty()
+ bookingId!: string;
+
+ @ApiProperty()
+ reference!: string;
+
+ @ApiProperty()
+ status!: string;
+
+ @ApiProperty()
+ templateKey!: string;
+
+ @ApiProperty()
+ title!: string;
+
+ @ApiProperty({ description: 'Full HTML document for in-browser display' })
+ html!: string;
+
+ @ApiProperty()
+ canSignCustomer!: boolean;
+
+ @ApiProperty()
+ canSignStaff!: boolean;
+
+ @ApiProperty()
+ hasContractDocument!: boolean;
+
+ @ApiProperty({ type: [ContractSignatureDto] })
+ signatures!: ContractSignatureDto[];
+
+ @ApiPropertyOptional()
+ pricingSchedule?: Record;
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts
index 0d61752f4..8089d0307 100644
--- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts
+++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts
@@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
+ ArrayMinSize,
IsArray,
IsBoolean,
IsDateString,
@@ -11,9 +12,12 @@ import {
IsString,
IsUUID,
Min,
+ Validate,
+ ValidateIf,
ValidateNested,
} from 'class-validator';
-import { BOOKING_STATUSES } from '../entities/booking.entity';
+import { BOOKING_STATUSES, FREIGHT_TYPES } from '../entities/booking.entity';
+import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN', 'NA'] as const;
@@ -24,6 +28,7 @@ export {
BOOKING_STATUSES,
CONTRACT_TYPES,
EQUIPMENT_RETURNS,
+ FREIGHT_TYPES,
TRADE_DIRECTIONS,
PAYMENT_CURRENCIES,
};
@@ -47,16 +52,24 @@ export class CreateBookingContainerDto {
}
export class CreateBookingDto {
+ /** Class-level freight shape check (not a request field). */
+ @Validate(BookingFreightShapeConstraint)
+ freightShapeValidation?: boolean;
@ApiPropertyOptional({ description: 'Unique booking reference (auto-generated if omitted)' })
@IsOptional()
@IsString()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
reference?: string;
- @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target customer' })
+ // @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target customer (legacy)' })
+ // @IsOptional()
+ // @IsUUID()
+ // customerId?: string;
+
+ @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
@IsOptional()
@IsUUID()
- customerId?: string;
+ companyId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@@ -107,9 +120,17 @@ export class CreateBookingDto {
@IsIn([...TRADE_DIRECTIONS])
tradeDirection!: string;
- @ApiProperty({ format: 'uuid', description: 'FK to cargo_types.id' })
+ @ApiProperty({ enum: FREIGHT_TYPES, description: 'CONTAINER or BULK (mutually exclusive cargo shape)' })
+ @IsIn([...FREIGHT_TYPES])
+ freightType!: string;
+
+ @ApiPropertyOptional({
+ format: 'uuid',
+ description: 'Required for BULK; must be omitted for CONTAINER',
+ })
+ @ValidateIf((o) => o.freightType === 'BULK')
@IsUUID()
- cargoTypeId!: string;
+ cargoTypeId?: string;
@ApiPropertyOptional({ maxLength: 200 })
@IsOptional()
@@ -157,11 +178,16 @@ export class CreateBookingDto {
@IsString()
financialTerms?: string;
- @ApiProperty({ type: [CreateBookingContainerDto] })
+ @ApiPropertyOptional({
+ type: [CreateBookingContainerDto],
+ description: 'Required for CONTAINER (min 1 line); must be empty for BULK',
+ })
+ @ValidateIf((o) => o.freightType === 'CONTAINER')
@IsArray()
+ @ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateBookingContainerDto)
- containers!: CreateBookingContainerDto[];
+ containers?: CreateBookingContainerDto[];
@ApiPropertyOptional({ default: false })
@IsOptional()
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts
index 912064905..50165ac00 100644
--- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts
+++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts
@@ -1,7 +1,12 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
-import { BOOKING_STATUSES, PAYMENT_CURRENCIES, TRADE_DIRECTIONS } from './create-booking.dto';
+import {
+ BOOKING_STATUSES,
+ FREIGHT_TYPES,
+ PAYMENT_CURRENCIES,
+ TRADE_DIRECTIONS,
+} from './create-booking.dto';
export class FilterBookingDto {
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
@@ -9,10 +14,15 @@ export class FilterBookingDto {
@IsIn([...BOOKING_STATUSES])
status?: string;
+ // @ApiPropertyOptional({ format: 'uuid' })
+ // @IsOptional()
+ // @IsUUID()
+ // customerId?: string;
+
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
- customerId?: string;
+ companyId?: string;
@ApiPropertyOptional()
@IsOptional()
@@ -28,6 +38,11 @@ export class FilterBookingDto {
@IsUUID()
cargoTypeId?: string;
+ @ApiPropertyOptional({ enum: FREIGHT_TYPES })
+ @IsOptional()
+ @IsIn([...FREIGHT_TYPES])
+ freightType?: string;
+
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
@IsOptional()
@IsIn([...TRADE_DIRECTIONS])
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts
new file mode 100644
index 000000000..3474bec74
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts
@@ -0,0 +1,32 @@
+import { ApiProperty } from '@nestjs/swagger';
+
+export class PriceLineItemDto {
+ @ApiProperty()
+ code!: string;
+
+ @ApiProperty()
+ description!: string;
+
+ @ApiProperty()
+ amount!: number;
+
+ @ApiProperty()
+ currency!: string;
+}
+
+export class GeneratePriceResponseDto {
+ @ApiProperty()
+ bookingId!: string;
+
+ @ApiProperty()
+ totalAmount!: number;
+
+ @ApiProperty()
+ currency!: string;
+
+ @ApiProperty({ type: [PriceLineItemDto] })
+ lineItems!: PriceLineItemDto[];
+
+ @ApiProperty({ type: [String] })
+ warnings!: string[];
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts
new file mode 100644
index 000000000..6f716388c
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts
@@ -0,0 +1,42 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsString, MinLength } from 'class-validator';
+
+export class RequestChangesDto {
+ @ApiProperty({ description: 'Staff note explaining what the customer must fix' })
+ @IsString()
+ @MinLength(1)
+ note!: string;
+}
+
+export class StaffRejectDto {
+ @ApiProperty()
+ @IsString()
+ @MinLength(1)
+ reason!: string;
+}
+
+export class ApproveStepDto {
+ @ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' })
+ @IsString()
+ requiredRole!: string;
+}
+
+export class RejectStepDto {
+ @ApiProperty()
+ @IsString()
+ @MinLength(1)
+ reason!: string;
+}
+
+export class CancelBookingDto {
+ @ApiProperty()
+ @IsString()
+ @MinLength(1)
+ reason!: string;
+}
+
+export class BankCallbackDto {
+ @ApiProperty()
+ @IsString()
+ pnrCode!: string;
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts
new file mode 100644
index 000000000..0b176ebd5
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts
@@ -0,0 +1,23 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
+
+export class SignContractDto {
+ @ApiProperty({ enum: ['CUSTOMER', 'STAFF'] })
+ @IsIn(['CUSTOMER', 'STAFF'])
+ role!: 'CUSTOMER' | 'STAFF';
+
+ @ApiProperty({ description: 'PNG signature image as base64 (with or without data URL prefix)' })
+ @IsString()
+ @MinLength(20)
+ signatureImageBase64!: string;
+
+ @ApiProperty()
+ @IsString()
+ @MinLength(1)
+ signerDisplayName!: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ consentText?: string;
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts
index 2b97debc6..328e71180 100644
--- a/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts
+++ b/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts
@@ -1,5 +1,10 @@
-import { PartialType } from "@nestjs/mapped-types";
+import { PartialType } from '@nestjs/mapped-types';
+import { Validate } from 'class-validator';
-import { CreateBookingDto } from "./create-booking.dto";
+import { CreateBookingDto } from './create-booking.dto';
+import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
-export class UpdateBookingDto extends PartialType(CreateBookingDto) {}
+export class UpdateBookingDto extends PartialType(CreateBookingDto) {
+ @Validate(BookingFreightShapeConstraint)
+ freightShapeValidation?: boolean;
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts
deleted file mode 100644
index a2d6c192d..000000000
--- a/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
-import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
-
-const STATUS_ACTIONS = [
- 'SUBMIT',
- 'SEND_QUOTATION',
- 'APPROVE_QUOTATION',
- 'REJECT_QUOTATION',
- 'APPROVE_STEP',
- 'APPROVE',
- 'CUSTOMER_SIGN',
- 'MARK_FULLY_EXECUTED',
- 'MARK_PAID',
- 'START_TRANSIT',
- 'COMPLETE',
- 'REJECT',
- 'CANCEL',
-] as const;
-
-export { STATUS_ACTIONS };
-
-export class UpdateStatusDto {
- @ApiProperty({ enum: STATUS_ACTIONS })
- @IsIn([...STATUS_ACTIONS])
- action!: string;
-
- @ApiPropertyOptional({ format: 'uuid', description: 'Staff/director/CEO actor' })
- @IsOptional()
- @IsUUID()
- actorId?: string;
-
- @ApiPropertyOptional({ description: 'Required role for APPROVE_STEP (LINE_STAFF, DIRECTOR, CEO)' })
- @IsOptional()
- @IsString()
- requiredRole?: string;
-
- @ApiPropertyOptional({ description: 'Required for REJECT, REJECT_QUOTATION, CANCEL' })
- @IsOptional()
- @IsString()
- reason?: string;
-}
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts b/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts
new file mode 100644
index 000000000..1365158b1
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts
@@ -0,0 +1,60 @@
+import {
+ ValidationArguments,
+ ValidatorConstraint,
+ ValidatorConstraintInterface,
+} from 'class-validator';
+
+import { FREIGHT_TYPES, FreightType } from '../../entities/booking.entity';
+
+export interface BookingFreightShapeInput {
+ freightType?: string;
+ cargoTypeId?: string | null;
+ containers?: Array<{ containerTypeId?: string }> | null;
+}
+
+@ValidatorConstraint({ name: 'BookingFreightShape', async: false })
+export class BookingFreightShapeConstraint implements ValidatorConstraintInterface {
+ validate(_value: unknown, args: ValidationArguments): boolean {
+ const dto = args.object as BookingFreightShapeInput;
+ if (!dto.freightType || !FREIGHT_TYPES.includes(dto.freightType as FreightType)) {
+ return true;
+ }
+
+ const containers = dto.containers ?? [];
+ const hasContainers = containers.length > 0;
+ const hasCargoType =
+ dto.cargoTypeId !== undefined &&
+ dto.cargoTypeId !== null &&
+ String(dto.cargoTypeId).trim() !== '';
+
+ if (dto.freightType === 'BULK') {
+ if (hasContainers) return false;
+ if (!hasCargoType) return false;
+ return true;
+ }
+
+ if (dto.freightType === 'CONTAINER') {
+ if (hasCargoType) return false;
+ if (!hasContainers) return false;
+ return containers.every(
+ (c) =>
+ c.containerTypeId !== undefined &&
+ c.containerTypeId !== null &&
+ String(c.containerTypeId).trim() !== '',
+ );
+ }
+
+ return true;
+ }
+
+ defaultMessage(args: ValidationArguments): string {
+ const dto = args.object as BookingFreightShapeInput;
+ if (dto.freightType === 'BULK') {
+ return 'BULK freight requires cargoTypeId and must not include container lines';
+ }
+ if (dto.freightType === 'CONTAINER') {
+ return 'CONTAINER freight requires at least one container line with containerTypeId and must not include cargoTypeId';
+ }
+ return 'Invalid freight type shape';
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts
new file mode 100644
index 000000000..6370c97c2
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts
@@ -0,0 +1,44 @@
+import { BaseEntity } from '@edr/api-common';
+import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
+import { FileRecord } from '../../files/entities/file.entity';
+import { Booking } from './booking.entity';
+
+export const CONTRACT_SIGNER_ROLES = ['CUSTOMER', 'STAFF'] as const;
+export type ContractSignerRole = (typeof CONTRACT_SIGNER_ROLES)[number];
+
+@Entity({ schema: 'freight', name: 'booking_contract_signatures' })
+@Unique(['bookingId', 'signerRole'])
+@Index(['bookingId'])
+export class BookingContractSignature extends BaseEntity {
+ @Column({ name: 'booking_id', type: 'uuid' })
+ bookingId!: string;
+
+ @ManyToOne(() => Booking, { onDelete: 'CASCADE' })
+ @JoinColumn({ name: 'booking_id' })
+ booking?: Booking;
+
+ @Column({ name: 'signer_role', type: 'varchar', length: 20 })
+ signerRole!: ContractSignerRole;
+
+ @Column({ name: 'signer_user_id', type: 'uuid', nullable: true })
+ signerUserId?: string | null;
+
+ @Column({ name: 'signer_display_name', type: 'varchar', length: 200 })
+ signerDisplayName!: string;
+
+ @Column({ name: 'signed_at', type: 'timestamptz' })
+ signedAt!: Date;
+
+ @Column({ name: 'signature_file_id', type: 'uuid', nullable: true })
+ signatureFileId?: string | null;
+
+ @ManyToOne(() => FileRecord, { nullable: true })
+ @JoinColumn({ name: 'signature_file_id' })
+ signatureFile?: FileRecord | null;
+
+ @Column({ name: 'consent_text', type: 'text', nullable: true })
+ consentText?: string | null;
+
+ @Column({ name: 'ip_address', type: 'varchar', length: 64, nullable: true })
+ ipAddress?: string | null;
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts
new file mode 100644
index 000000000..af39a469c
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts
@@ -0,0 +1,26 @@
+import { BaseEntity } from '@edr/api-common';
+import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
+import { Booking } from './booking.entity';
+
+export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION'] as const;
+export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
+
+@Entity({ schema: 'freight', name: 'booking_review_note' })
+@Index(['bookingId'])
+export class BookingReviewNote extends BaseEntity {
+ @Column({ name: 'booking_id', type: 'uuid' })
+ bookingId!: string;
+
+ @ManyToOne(() => Booking, (b) => b.reviewNotes, { onDelete: 'CASCADE' })
+ @JoinColumn({ name: 'booking_id' })
+ booking?: Booking;
+
+ @Column({ name: 'author_id', type: 'uuid', nullable: true })
+ authorId?: string | null;
+
+ @Column({ name: 'note', type: 'text' })
+ note!: string;
+
+ @Column({ name: 'type', type: 'varchar', length: 30 })
+ type!: ReviewNoteType;
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
index 7875babbf..fb3679a60 100644
--- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
+++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
@@ -1,6 +1,7 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
-import { Customer } from '../../customers/entities/customer.entity';
+// import { Customer } from '../../customers/entities/customer.entity';
+import { Company } from '../../companies/entities/company.entity';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
@@ -11,36 +12,68 @@ import { BookingApprovalStep } from './booking-approval-step.entity';
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
import { BookingContainer } from './booking-container.entity';
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
+import { BookingReviewNote } from './booking-review-note.entity';
export const BOOKING_STATUSES = [
'DRAFT',
- 'RFQ_SUBMITTED',
- 'QUOTATION_SENT',
- 'QUOTATION_APPROVED',
- 'QUOTATION_REJECTED',
+ 'SUBMITTED',
+ 'CHANGES_REQUESTED',
'PENDING_APPROVAL',
+ 'APPROVED_PENDING_SIGNATURE',
'APPROVED',
+ 'CONTRACT_READY',
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
+ 'PNR_GENERATED',
+ 'PAYMENT_VERIFICATION_IN_PROGRESS',
'PAID',
'IN_TRANSIT',
'COMPLETED',
+ 'REJECTED',
'CANCELLED',
'PENDING_CONSOLIDATION',
'CONSOLIDATED',
] as const;
+export type BookingStatus = (typeof BOOKING_STATUSES)[number];
+
+export const PAYMENT_STATUSES = [
+ 'PENDING',
+ 'PNR_GENERATED',
+ 'VERIFICATION_IN_PROGRESS',
+ 'PAID',
+ 'FAILED',
+] as const;
+
+export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
+
+export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
+export type FreightType = (typeof FREIGHT_TYPES)[number];
+
+/** Statuses where the customer may edit booking fields. */
+export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [
+ 'DRAFT',
+ 'CHANGES_REQUESTED',
+];
+
@Entity({ schema: 'freight', name: 'bookings' })
export class Booking extends BaseEntity {
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
reference!: string;
- @Column({ name: 'customer_id', type: 'uuid' })
- customerId!: string;
+ // Legacy — superseded by companyId (column kept in DB)
+ // @Column({ name: 'customer_id', type: 'uuid' })
+ // customerId!: string;
+ // @ManyToOne(() => Customer)
+ // @JoinColumn({ name: 'customer_id' })
+ // customer?: Customer;
- @ManyToOne(() => Customer)
- @JoinColumn({ name: 'customer_id' })
- customer?: Customer;
+ @Column({ name: 'company_id', type: 'uuid' })
+ companyId!: string;
+
+ @ManyToOne(() => Company)
+ @JoinColumn({ name: 'company_id' })
+ company?: Company;
@Column({ name: 'train_id', type: 'uuid', nullable: true })
trainId?: string | null;
@@ -104,8 +137,11 @@ export class Booking extends BaseEntity {
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
tradeDirection!: string;
- @Column({ name: 'cargo_type_id', type: 'uuid' })
- cargoTypeId!: string;
+ @Column({ name: 'freight_type', type: 'varchar', length: 20 })
+ freightType!: string;
+
+ @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
+ cargoTypeId?: string | null;
@ManyToOne(() => CargoType)
@JoinColumn({ name: 'cargo_type_id' })
@@ -169,6 +205,27 @@ export class Booking extends BaseEntity {
@Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true })
fullyExecutedAt?: Date | null;
+ @Column({ name: 'marketing_approved_by_id', type: 'uuid', nullable: true })
+ marketingApprovedById?: string | null;
+
+ @Column({ name: 'marketing_approved_at', type: 'timestamptz', nullable: true })
+ marketingApprovedAt?: Date | null;
+
+ @Column({ name: 'contract_summary', type: 'text', nullable: true })
+ contractSummary?: string | null;
+
+ @Column({ name: 'contract_template_key', type: 'varchar', length: 80, nullable: true })
+ contractTemplateKey?: string | null;
+
+ @Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
+ contractGeneratedAt?: Date | null;
+
+ @Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
+ pricingBreakdown?: Record | null;
+
+ @Column({ name: 'locked_at', type: 'timestamptz', nullable: true })
+ lockedAt?: Date | null;
+
@Column({ name: 'priority_score', type: 'int', default: 0 })
priorityScore!: number;
@@ -194,6 +251,9 @@ export class Booking extends BaseEntity {
@OneToMany(() => BookingRateSnapshot, (s) => s.booking)
rateSnapshots?: BookingRateSnapshot[];
+ @OneToMany(() => BookingReviewNote, (n) => n.booking)
+ reviewNotes?: BookingReviewNote[];
+
@OneToMany(() => FileRecord, (file) => file.resourceId, {
createForeignKeyConstraints: false,
})
diff --git a/apps/edr-freight-api/src/modules/bookings/payments-webhook.controller.ts b/apps/edr-freight-api/src/modules/bookings/payments-webhook.controller.ts
new file mode 100644
index 000000000..d0f60f8df
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/payments-webhook.controller.ts
@@ -0,0 +1,17 @@
+import { Body, Controller, Post } from '@nestjs/common';
+import { ApiOperation, ApiTags } from '@nestjs/swagger';
+
+import { BookingPaymentService } from './booking-payment.service';
+import { BankCallbackDto } from './dto/request-changes.dto';
+
+@ApiTags('payments')
+@Controller('webhooks/payments')
+export class PaymentsWebhookController {
+ constructor(private readonly paymentService: BookingPaymentService) {}
+
+ @Post('bank')
+ @ApiOperation({ summary: 'Bank payment callback (stub)' })
+ bankCallback(@Body() dto: BankCallbackDto) {
+ return this.paymentService.handleBankCallback(dto.pnrCode);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts
new file mode 100644
index 000000000..5d86a579b
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts
@@ -0,0 +1,70 @@
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ Param,
+ ParseUUIDPipe,
+ Patch,
+ Post,
+} from '@nestjs/common';
+import { ApiOperation, ApiTags } from '@nestjs/swagger';
+import { CreateCargoDto } from './dto/create-cargo.dto';
+import { UpdateCargoDto } from './dto/update-cargo.dto';
+import { LoadCargoDto } from './dto/load-cargo.dto';
+import { DeliverCargoDto } from './dto/deliver-cargo.dto';
+import { CargoesService } from './cargoes.service';
+
+@ApiTags('cargoes')
+@Controller('cargoes')
+export class CargoesController {
+ constructor(private readonly cargoesService: CargoesService) {}
+
+ @Post()
+ @ApiOperation({ summary: 'Create a new cargo' })
+ create(@Body() dto: CreateCargoDto) {
+ return this.cargoesService.create(dto);
+ }
+
+ @Get()
+ @ApiOperation({ summary: 'List all cargoes' })
+ findAll() {
+ return this.cargoesService.findAll();
+ }
+
+ @Get(':id')
+ @ApiOperation({ summary: 'Get a cargo by ID' })
+ findOne(@Param('id', ParseUUIDPipe) id: string) {
+ return this.cargoesService.findById(id);
+ }
+
+ @Patch(':id')
+ @ApiOperation({ summary: 'Update a cargo' })
+ update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) {
+ return this.cargoesService.update(id, dto);
+ }
+
+ @Delete(':id')
+ @ApiOperation({ summary: 'Delete a cargo' })
+ remove(@Param('id', ParseUUIDPipe) id: string) {
+ return this.cargoesService.remove(id);
+ }
+
+ @Post(':id/load')
+ @ApiOperation({ summary: 'Load cargo into a container' })
+ load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) {
+ return this.cargoesService.loadCargo(id, dto);
+ }
+
+ @Post(':id/unload')
+ @ApiOperation({ summary: 'Unload cargo from container' })
+ unload(@Param('id', ParseUUIDPipe) id: string) {
+ return this.cargoesService.unloadCargo(id);
+ }
+
+ @Post(':id/deliver')
+ @ApiOperation({ summary: 'Mark cargo as delivered' })
+ deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
+ return this.cargoesService.deliverCargo(id, dto);
+ }
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.module.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.module.ts
new file mode 100644
index 000000000..8a60d2600
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.module.ts
@@ -0,0 +1,14 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { Cargo } from './entities/cargoes.entity';
+import { Container } from '../container-management/entities/container.entity';
+import { CargoesController } from './cargoes.controller';
+import { CargoesService } from './cargoes.service';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([Cargo, Container])],
+ controllers: [CargoesController],
+ providers: [CargoesService],
+ exports: [CargoesService],
+})
+export class CargoesModule {}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.repository.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.repository.ts
new file mode 100644
index 000000000..cedb217da
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.repository.ts
@@ -0,0 +1,15 @@
+import { BaseRepository } from '@edr/api-common';
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { Cargo } from './entities/cargoes.entity';
+
+@Injectable()
+export class CargoesRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(Cargo)
+ repository: Repository,
+ ) {
+ super(repository);
+ }
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts
new file mode 100644
index 000000000..c1a6e213e
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts
@@ -0,0 +1,104 @@
+import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { CreateCargoDto } from './dto/create-cargo.dto';
+import { UpdateCargoDto } from './dto/update-cargo.dto';
+import { LoadCargoDto } from './dto/load-cargo.dto';
+import { DeliverCargoDto } from './dto/deliver-cargo.dto';
+import { Cargo } from './entities/cargoes.entity';
+import { Container } from '../container-management/entities/container.entity';
+
+@Injectable()
+export class CargoesService {
+ constructor(
+ @InjectRepository(Cargo)
+ private readonly cargoRepo: Repository,
+ @InjectRepository(Container)
+ private readonly containerRepo: Repository,
+ ) {}
+
+ async create(dto: CreateCargoDto): Promise {
+ const cargo = this.cargoRepo.create(dto);
+ return this.cargoRepo.save(cargo);
+ }
+
+ async findAll(): Promise {
+ return this.cargoRepo.find({ order: { cargoReference: 'ASC' } });
+ }
+
+ async findById(id: string): Promise {
+ const cargo = await this.cargoRepo.findOne({ where: { id } });
+ if (!cargo) throw new NotFoundException(`Cargo ${id} not found`);
+ return cargo;
+ }
+
+ async update(id: string, dto: UpdateCargoDto): Promise {
+ const cargo = await this.findById(id);
+ Object.assign(cargo, dto);
+ return this.cargoRepo.save(cargo);
+ }
+
+ async remove(id: string): Promise {
+ const cargo = await this.findById(id);
+ await this.cargoRepo.remove(cargo);
+ }
+
+ async loadCargo(id: string, dto: LoadCargoDto): Promise {
+ const cargo = await this.cargoRepo.findOne({
+ where: { id },
+ relations: { container: true }, // ✅ fixed
+ });
+ if (!cargo) throw new NotFoundException('Cargo not found');
+ if (cargo.status !== 'PENDING') {
+ throw new ConflictException('Cargo already loaded or delivered');
+ }
+
+ cargo.status = 'LOADED';
+ cargo.loadedAt = new Date();
+ cargo.quantity = dto.quantity;
+ cargo.weight = dto.weight;
+ cargo.volume = dto.volume ?? null;
+ if (dto.description) cargo.description = dto.description;
+
+ if (cargo.container) {
+ cargo.container.status = 'LOADED';
+ await this.containerRepo.save(cargo.container);
+ }
+
+ return this.cargoRepo.save(cargo);
+ }
+
+ async unloadCargo(id: string): Promise {
+ const cargo = await this.findById(id);
+ if (cargo.status !== 'LOADED') {
+ throw new ConflictException('Cargo is not loaded');
+ }
+ cargo.status = 'UNLOADED';
+ cargo.unloadedAt = new Date();
+ return this.cargoRepo.save(cargo);
+ }
+
+ async deliverCargo(id: string, dto?: DeliverCargoDto): Promise {
+ const cargo = await this.cargoRepo.findOne({
+ where: { id },
+ relations: { container: true }, // ✅ fixed
+ });
+ if (!cargo) throw new NotFoundException('Cargo not found');
+ if (cargo.status !== 'LOADED') {
+ throw new ConflictException('Only loaded cargo can be delivered');
+ }
+
+ cargo.status = 'DELIVERED';
+ if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks;
+
+ const remaining = await this.cargoRepo.count({
+ where: { containerId: cargo.containerId, status: 'LOADED' },
+ });
+ if (remaining === 0 && cargo.container) {
+ cargo.container.status = 'AVAILABLE';
+ await this.containerRepo.save(cargo.container);
+ }
+
+ return this.cargoRepo.save(cargo);
+ }
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/create-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/create-cargo.dto.ts
new file mode 100644
index 000000000..8373f5a4b
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/cargoes/dto/create-cargo.dto.ts
@@ -0,0 +1,45 @@
+import { IsString, IsUUID, IsOptional, IsNumber, Min, IsIn, IsDateString } from 'class-validator';
+
+export class CreateCargoDto {
+ @IsString()
+ cargoReference!: string;
+
+ @IsUUID()
+ shipmentId!: string;
+
+ @IsUUID()
+ containerId!: string;
+
+ @IsOptional()
+ @IsUUID()
+ cargoTypeId?: string;
+
+ @IsOptional()
+ @IsString()
+ description?: string;
+
+ @IsNumber()
+ @Min(0.001)
+ quantity!: number;
+
+ @IsNumber()
+ @Min(0)
+ weight!: number;
+
+ @IsOptional()
+ @IsNumber()
+ @Min(0)
+ volume?: number;
+
+ @IsOptional()
+ @IsIn(['PENDING', 'LOADED', 'IN_TRANSIT', 'DELIVERED', 'UNLOADED'])
+ status?: string;
+
+ @IsOptional()
+ @IsDateString()
+ loadedAt?: string;
+
+ @IsOptional()
+ @IsDateString()
+ unloadedAt?: string;
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/deliver-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/deliver-cargo.dto.ts
new file mode 100644
index 000000000..020e4d630
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/cargoes/dto/deliver-cargo.dto.ts
@@ -0,0 +1,7 @@
+import { IsOptional, IsString } from 'class-validator';
+
+export class DeliverCargoDto {
+ @IsOptional()
+ @IsString()
+ deliveryRemarks?: string;
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/load-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/load-cargo.dto.ts
new file mode 100644
index 000000000..9e8573751
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/cargoes/dto/load-cargo.dto.ts
@@ -0,0 +1,20 @@
+import { IsNumber, Min, IsOptional, IsString } from 'class-validator';
+
+export class LoadCargoDto {
+ @IsNumber()
+ @Min(0.001)
+ quantity!: number;
+
+ @IsNumber()
+ @Min(0)
+ weight!: number;
+
+ @IsOptional()
+ @IsNumber()
+ @Min(0)
+ volume?: number;
+
+ @IsOptional()
+ @IsString()
+ description?: string;
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/unload-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/unload-cargo.dto.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/update-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/update-cargo.dto.ts
new file mode 100644
index 000000000..7596b7dbe
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/cargoes/dto/update-cargo.dto.ts
@@ -0,0 +1,4 @@
+import { PartialType } from '@nestjs/swagger';
+import { CreateCargoDto } from './create-cargo.dto';
+
+export class UpdateCargoDto extends PartialType(CreateCargoDto) {}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts b/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts
new file mode 100644
index 000000000..7c2f752e5
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts
@@ -0,0 +1,45 @@
+// apps/edr-freight-api/src/modules/cargoes/entities/cargo.entity.ts
+import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
+import { BaseEntity } from '@edr/api-common';
+import { Container } from '../../container-management/entities/container.entity';
+
+@Entity({ name: 'cargoes', schema: 'freight' })
+export class Cargo extends BaseEntity {
+ @Column({ unique: true, name: 'cargo_reference' })
+ cargoReference!: string;
+
+ @Column({ name: 'shipment_id', type: 'uuid' })
+ shipmentId!: string;
+
+ @Column({ name: 'container_id', type: 'uuid' })
+ containerId!: string;
+
+ @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
+ cargoTypeId!: string | null; // optional link to cargo_types table
+
+ @Column({ type: 'text', nullable: true })
+ description!: string | null;
+
+ @Column({ type: 'decimal', precision: 12, scale: 3 })
+ quantity!: number;
+
+ @Column({ type: 'decimal', precision: 10, scale: 2 })
+ weight!: number; // kg
+
+ @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
+ volume!: number | null; // m³
+
+ @Column({ type: 'varchar', default: 'PENDING' })
+ status!: string; // PENDING, LOADED, IN_TRANSIT, DELIVERED, UNLOADED
+
+ @Column({ name: 'loaded_at', type: 'timestamp', nullable: true })
+ loadedAt!: Date | null;
+
+ @Column({ name: 'unloaded_at', type: 'timestamp', nullable: true })
+ unloadedAt!: Date | null;
+
+ // Relationship to Container
+ @ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT' })
+ @JoinColumn({ name: 'container_id' })
+ container!: Container;
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts
index 5c646279e..b1481d0d4 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts
@@ -1,6 +1,8 @@
-import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus } from '@nestjs/common';
-import { ApiOperation, ApiTags } from '@nestjs/swagger';
+import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common';
+import { AnyFilesInterceptor } from '@nestjs/platform-express';
+import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
+import { FilesService } from '../files/files.service';
import { CompaniesService } from './companies.service';
import { CreateCompanyDto } from './dto/create-company.dto';
import { UpdateCompanyDto } from './dto/update-company.dto';
@@ -11,6 +13,8 @@ import { ResponseCompanyDto } from './dto/response-company.dto';
import { ResponseExternalProfileDto } from './dto/response-external-profile.dto';
import { ResponseFFClientDto } from './dto/response-ff-client.dto';
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
+import { UpdateProfileDto } from './dto/update-profile.dto';
+import { ProfileResponseDto } from './dto/profile-response.dto';
interface CurrentIamUser {
id: string;
@@ -22,7 +26,10 @@ interface CurrentIamUser {
@ApiTags('Companies')
@Controller('companies')
export class CompaniesController {
- constructor(private readonly companiesService: CompaniesService) {}
+ constructor(
+ private readonly companiesService: CompaniesService,
+ private readonly filesService: FilesService,
+ ) {}
@Get('getInfo')
@ApiOperation({ summary: 'Get company info for the current user' })
@@ -31,6 +38,22 @@ export class CompaniesController {
return new CompanyInfoResponseDto(profile, company);
}
+ @Get('profile')
+ @ApiOperation({ summary: 'Get flattened profile for the settings page' })
+ async getProfile(@CurrentUser() user: CurrentIamUser): Promise {
+ const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
+ return new ProfileResponseDto(profile, company);
+ }
+
+ @Patch('profile')
+ @ApiOperation({ summary: 'Update profile (flattened settings page)' })
+ async updateProfile(
+ @CurrentUser() user: CurrentIamUser,
+ @Body() dto: UpdateProfileDto,
+ ): Promise {
+ return this.companiesService.updateProfile(user.id, dto);
+ }
+
@Post('create')
@ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
async createWithProfile(
@@ -105,6 +128,17 @@ export class CompaniesController {
await this.companiesService.deleteCompany(id);
}
+ @Post(':companyId/documents')
+ @UseInterceptors(AnyFilesInterceptor())
+ @ApiConsumes('multipart/form-data')
+ @ApiOperation({ summary: 'Upload documents for a company (onboarding)' })
+ async uploadDocuments(
+ @Param('companyId', ParseUUIDPipe) companyId: string,
+ @UploadedFiles() files: Array,
+ ) {
+ return this.filesService.uploadMany(companyId, 'companies', files);
+ }
+
@Post(':companyId/profiles')
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
async createProfile(
diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts
index 8fac2f901..d18573460 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.module.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
+import { FilesModule } from '../files/files.module';
import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service';
import { CompaniesRepository } from './companies.repository';
@@ -10,7 +11,7 @@ import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
@Module({
- imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient])],
+ imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule],
controllers: [CompaniesController],
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
exports: [CompaniesService],
diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts
index 03c104798..f383b9e55 100644
--- a/apps/edr-freight-api/src/modules/companies/companies.service.ts
+++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts
@@ -7,6 +7,8 @@ import { UpdateCompanyDto } from './dto/update-company.dto';
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
import { CreateFFClientDto } from './dto/create-ff-client.dto';
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
+import { UpdateProfileDto } from './dto/update-profile.dto';
+import { ProfileResponseDto } from './dto/profile-response.dto';
import { Company } from './entities/company.entity';
import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
@@ -103,6 +105,42 @@ export class CompaniesService {
return updated;
}
+ async updateProfile(userId: string, dto: UpdateProfileDto): Promise {
+ const { profile, company } = await this.getCompanyInfoByUserId(userId);
+
+ const companyUpdates: Record = {};
+ const attrUpdates: Record = { ...(company.attributes ?? {}) };
+
+ if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
+ if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
+ if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
+ if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation;
+ if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress;
+ if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
+ if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
+ if (dto.fanNumber !== undefined) {
+ companyUpdates.businessLicense = dto.fanNumber;
+ companyUpdates.fanNumber = dto.fanNumber;
+ }
+
+ if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName;
+ if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone;
+ if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName;
+ if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail;
+ if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone;
+ if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
+ if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone;
+ if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
+ if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
+ if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
+
+ companyUpdates.attributes = attrUpdates;
+
+ const updated = await this.companiesRepo.update(company.id, companyUpdates);
+ if (!updated) throw new NotFoundException(`Company ${company.id} not found`);
+ return new ProfileResponseDto(profile, updated);
+ }
+
async deleteCompany(id: string): Promise {
await this.findCompanyById(id);
await this.companiesRepo.softDelete(id);
diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts
new file mode 100644
index 000000000..ee8ede34f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts
@@ -0,0 +1,53 @@
+import { Company } from '../entities/company.entity';
+import { ExternalProfile } from '../entities/external-profile.entity';
+
+export class ProfileResponseDto {
+ companyId: string;
+ companyName: string;
+ companyEmail: string | null;
+ companyPhone: string | null;
+ companyLocation: string;
+ companyAddress: string | null;
+ tinNumber: string;
+ vatNumber: string | null;
+ fanNumber: string | null;
+
+ contactPersonName: string | null;
+ contactPersonPhone: string | null;
+ generalManagerName: string | null;
+ generalManagerEmail: string | null;
+ generalManagerPhone: string | null;
+
+ poaName: string | null;
+ poaPhone: string | null;
+ poaEmail: string | null;
+ poaLocation: string | null;
+ poaAddress: string | null;
+
+ profileId: string;
+
+ constructor(profile: ExternalProfile, company: Company) {
+ this.companyId = company.id;
+ this.companyName = company.name;
+ this.companyEmail = company.email ?? null;
+ this.companyPhone = company.phone ?? null;
+ this.companyLocation = company.country;
+ this.companyAddress = company.address ?? null;
+ this.tinNumber = company.tin;
+ this.vatNumber = company.vatNumber ?? null;
+ this.fanNumber = company.fanNumber ?? null;
+ this.profileId = profile.id;
+
+ const attrs = company.attributes ?? {};
+ this.contactPersonName = attrs.contactPersonName ?? null;
+ this.contactPersonPhone = attrs.contactPersonPhone ?? null;
+ this.generalManagerName = attrs.generalManagerName ?? null;
+ this.generalManagerEmail = attrs.generalManagerEmail ?? null;
+ this.generalManagerPhone = attrs.generalManagerPhone ?? null;
+ this.poaName = attrs.poaName ?? null;
+ this.poaPhone = attrs.poaPhone ?? null;
+ this.poaEmail = attrs.poaEmail ?? null;
+ this.poaLocation = attrs.poaLocation ?? null;
+ this.poaAddress = attrs.poaAddress ?? null;
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts
new file mode 100644
index 000000000..0acdf60a1
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts
@@ -0,0 +1,83 @@
+import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches } from 'class-validator';
+
+export class UpdateProfileDto {
+ @IsOptional()
+ @IsString()
+ @MaxLength(200)
+ companyName?: string;
+
+ @IsOptional()
+ @IsEmail()
+ @MaxLength(150)
+ companyEmail?: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(20)
+ companyPhone?: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(32)
+ companyLocation?: string;
+
+ @IsOptional()
+ @IsString()
+ companyAddress?: string;
+
+ @IsOptional()
+ @IsString()
+ @Length(10, 10)
+ @Matches(/^\d+$/, { message: 'TIN must contain only digits' })
+ tin?: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(50)
+ vatNumber?: string;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(16)
+ fanNumber?: string;
+
+ @IsOptional()
+ @IsString()
+ contactPersonName?: string;
+
+ @IsOptional()
+ @IsString()
+ contactPersonPhone?: string;
+
+ @IsOptional()
+ @IsString()
+ generalManagerName?: string;
+
+ @IsOptional()
+ @IsEmail()
+ generalManagerEmail?: string;
+
+ @IsOptional()
+ @IsString()
+ generalManagerPhone?: string;
+
+ @IsOptional()
+ @IsString()
+ poaName?: string;
+
+ @IsOptional()
+ @IsString()
+ poaPhone?: string;
+
+ @IsOptional()
+ @IsEmail()
+ poaEmail?: string;
+
+ @IsOptional()
+ @IsString()
+ poaLocation?: string;
+
+ @IsOptional()
+ @IsString()
+ poaAddress?: string;
+}
diff --git a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts
new file mode 100644
index 000000000..78759d9b4
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts
@@ -0,0 +1,63 @@
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ Param,
+ ParseUUIDPipe,
+ Patch,
+ Post,
+} from '@nestjs/common';
+import { ApiOperation, ApiTags } from '@nestjs/swagger';
+import { CreateContainerDto } from './dto/create-container.dto';
+import { UpdateContainerDto } from './dto/update-container.dto';
+import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
+import { ContainersService } from './containers.service';
+
+@ApiTags('containers')
+@Controller('containers')
+export class ContainersController {
+ constructor(private readonly containersService: ContainersService) {}
+
+ @Post()
+ @ApiOperation({ summary: 'Create a new container' })
+ create(@Body() dto: CreateContainerDto) {
+ return this.containersService.create(dto);
+ }
+
+ @Get()
+ @ApiOperation({ summary: 'List all containers' })
+ findAll() {
+ return this.containersService.findAll();
+ }
+
+ @Get(':id')
+ @ApiOperation({ summary: 'Get a container by ID' })
+ findOne(@Param('id', ParseUUIDPipe) id: string) {
+ return this.containersService.findById(id);
+ }
+
+ @Patch(':id')
+ @ApiOperation({ summary: 'Update a container' })
+ update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) {
+ return this.containersService.update(id, dto);
+ }
+
+ @Delete(':id')
+ @ApiOperation({ summary: 'Delete a container' })
+ remove(@Param('id', ParseUUIDPipe) id: string) {
+ return this.containersService.remove(id);
+ }
+
+ @Post(':id/assign-wagon')
+ @ApiOperation({ summary: 'Assign container to a wagon' })
+ assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) {
+ return this.containersService.assignToWagon(id, dto);
+ }
+
+ @Post(':id/unassign-wagon')
+ @ApiOperation({ summary: 'Unassign container from wagon' })
+ unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
+ return this.containersService.unassignFromWagon(id);
+ }
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/container-management/containers.module.ts b/apps/edr-freight-api/src/modules/container-management/containers.module.ts
new file mode 100644
index 000000000..eea118187
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/container-management/containers.module.ts
@@ -0,0 +1,14 @@
+// apps/edr-freight-api/src/modules/container-management/containers.module.ts
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { Container } from './entities/container.entity';
+import { Wagon } from '../wagons/entities/wagon.entity';
+import { ContainersController } from './containers.controller';
+import { ContainersService } from './containers.service';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([Container, Wagon])], // ✅ add Wagon
+ controllers: [ContainersController],
+ providers: [ContainersService],
+})
+export class ContainersModule {}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/container-management/containers.repository.ts b/apps/edr-freight-api/src/modules/container-management/containers.repository.ts
new file mode 100644
index 000000000..c6842cdea
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/container-management/containers.repository.ts
@@ -0,0 +1,15 @@
+import { BaseRepository } from '@edr/api-common';
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { Container } from './entities/container.entity';
+
+@Injectable()
+export class ContainersRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(Container)
+ repository: Repository,
+ ) {
+ super(repository);
+ }
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/container-management/containers.service copy.ts b/apps/edr-freight-api/src/modules/container-management/containers.service copy.ts
new file mode 100644
index 000000000..f6094a497
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/container-management/containers.service copy.ts
@@ -0,0 +1,86 @@
+import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { CreateContainerDto } from './dto/create-container.dto';
+import { UpdateContainerDto } from './dto/update-container.dto';
+import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
+import { Container } from './entities/container.entity';
+//import { ContainersRepository } from './containers.repository';
+import { WagonsRepository } from '../wagons/wagons.repository';
+
+@Injectable()
+export class ContainersService {
+ constructor(
+ @InjectRepository(Container)
+ private readonly containerRepo: Repository,
+ private readonly wagonsRepository: WagonsRepository,
+ ) {}
+
+ async create(dto: CreateContainerDto): Promise {
+ const container = this.containerRepo.create(dto);
+ // Convert undefined to null for optional fields
+ if (dto.wagonId === undefined) container.wagonId = null;
+ if (dto.position === undefined) container.position = null;
+ return this.containerRepo.save(container);
+ }
+
+ async findAll(): Promise {
+ return this.containerRepo.find({ order: { containerNumber: 'ASC' } });
+ }
+
+ async findById(id: string): Promise {
+ const container = await this.containerRepo.findOne({ where: { id } });
+ if (!container) throw new NotFoundException(`Container ${id} not found`);
+ return container;
+ }
+
+ async update(id: string, dto: UpdateContainerDto): Promise {
+ const container = await this.findById(id);
+ Object.assign(container, dto);
+ // Convert undefined to null for nullable fields
+ if (dto.wagonId === undefined) container.wagonId = null;
+ if (dto.position === undefined) container.position = null;
+ return this.containerRepo.save(container);
+ }
+
+ async remove(id: string): Promise {
+ const container = await this.findById(id);
+ await this.containerRepo.remove(container);
+ }
+
+ async assignToWagon(containerId: string, dto: AssignContainerToWagonDto): Promise {
+ const container = await this.findById(containerId);
+ if (container.status === 'LOADED') {
+ throw new ConflictException('Cannot reassign a loaded container');
+ }
+
+ const wagon = await this.wagonsRepository.findById(dto.wagonId);
+ if (!wagon) throw new NotFoundException('Wagon not found');
+
+ let position: number | null = dto.position ?? null; // convert undefined to null
+ if (position === null) {
+ const maxPos = await this.containerRepo
+ .createQueryBuilder('c')
+ .select('MAX(c.position)', 'max')
+ .where('c.wagonId = :wagonId', { wagonId: wagon.id })
+ .getRawOne();
+ position = (maxPos?.max ?? 0) + 1;
+ }
+
+ container.wagonId = wagon.id;
+ container.position = position; // now position is number | null, safe
+ container.status = 'AVAILABLE';
+ return this.containerRepo.save(container);
+ }
+
+ async unassignFromWagon(containerId: string): Promise {
+ const container = await this.findById(containerId);
+ if (container.status === 'LOADED') {
+ throw new ConflictException('Cannot unassign a loaded container');
+ }
+ container.wagonId = null;
+ container.position = null;
+ container.status = 'AVAILABLE';
+ return this.containerRepo.save(container);
+ }
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/container-management/containers.service.ts b/apps/edr-freight-api/src/modules/container-management/containers.service.ts
new file mode 100644
index 000000000..39f2d8274
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/container-management/containers.service.ts
@@ -0,0 +1,85 @@
+// apps/edr-freight-api/src/modules/container-management/containers.service.ts
+import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { CreateContainerDto } from './dto/create-container.dto';
+import { UpdateContainerDto } from './dto/update-container.dto';
+import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
+import { Container } from './entities/container.entity';
+import { Wagon } from '../wagons/entities/wagon.entity';
+
+@Injectable()
+export class ContainersService {
+ constructor(
+ @InjectRepository(Container)
+ private readonly containerRepo: Repository,
+ @InjectRepository(Wagon)
+ private readonly wagonRepo: Repository, // ✅ use raw repository
+ ) {}
+
+ async create(dto: CreateContainerDto): Promise {
+ const container = this.containerRepo.create(dto);
+ if (dto.wagonId === undefined) container.wagonId = null;
+ if (dto.position === undefined) container.position = null;
+ return this.containerRepo.save(container);
+ }
+
+ async findAll(): Promise {
+ return this.containerRepo.find({ order: { containerNumber: 'ASC' } });
+ }
+
+ async findById(id: string): Promise {
+ const container = await this.containerRepo.findOne({ where: { id } });
+ if (!container) throw new NotFoundException(`Container ${id} not found`);
+ return container;
+ }
+
+ async update(id: string, dto: UpdateContainerDto): Promise {
+ const container = await this.findById(id);
+ Object.assign(container, dto);
+ if (dto.wagonId === undefined) container.wagonId = null;
+ if (dto.position === undefined) container.position = null;
+ return this.containerRepo.save(container);
+ }
+
+ async remove(id: string): Promise {
+ const container = await this.findById(id);
+ await this.containerRepo.remove(container);
+ }
+
+ async assignToWagon(containerId: string, dto: AssignContainerToWagonDto): Promise {
+ const container = await this.findById(containerId);
+ if (container.status === 'LOADED') {
+ throw new ConflictException('Cannot reassign a loaded container');
+ }
+
+ const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } });
+ if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
+
+ let position: number | null = dto.position ?? null;
+ if (position === null) {
+ const maxPos = await this.containerRepo
+ .createQueryBuilder('c')
+ .select('MAX(c.position)', 'max')
+ .where('c.wagonId = :wagonId', { wagonId: wagon.id })
+ .getRawOne();
+ position = (maxPos?.max ?? 0) + 1;
+ }
+
+ container.wagonId = wagon.id;
+ container.position = position;
+ container.status = 'AVAILABLE';
+ return this.containerRepo.save(container);
+ }
+
+ async unassignFromWagon(containerId: string): Promise {
+ const container = await this.findById(containerId);
+ if (container.status === 'LOADED') {
+ throw new ConflictException('Cannot unassign a loaded container');
+ }
+ container.wagonId = null;
+ container.position = null;
+ container.status = 'AVAILABLE';
+ return this.containerRepo.save(container);
+ }
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/container-management/dto/assign-container-to-wagon.dto.ts b/apps/edr-freight-api/src/modules/container-management/dto/assign-container-to-wagon.dto.ts
new file mode 100644
index 000000000..3b7be1d9e
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/container-management/dto/assign-container-to-wagon.dto.ts
@@ -0,0 +1,11 @@
+import { IsUUID, IsOptional, IsInt, Min } from 'class-validator';
+
+export class AssignContainerToWagonDto {
+ @IsUUID()
+ wagonId!: string;
+
+ @IsOptional()
+ @IsInt()
+ @Min(1)
+ position?: number;
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/container-management/dto/create-container.dto.ts b/apps/edr-freight-api/src/modules/container-management/dto/create-container.dto.ts
new file mode 100644
index 000000000..1efed1cc9
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/container-management/dto/create-container.dto.ts
@@ -0,0 +1,34 @@
+import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsIn } from 'class-validator';
+
+export class CreateContainerDto {
+ @IsString()
+ containerNumber!: string;
+
+ @IsUUID()
+ containerTypeId!: string;
+
+ @IsOptional()
+ @IsUUID()
+ wagonId?: string;
+
+ @IsOptional()
+ @IsInt()
+ @Min(1)
+ position?: number;
+
+ @IsNumber()
+ @Min(0)
+ tareWeight!: number;
+
+ @IsNumber()
+ @Min(0)
+ maxGrossWeight!: number;
+
+ @IsOptional()
+ @IsString()
+ sealNumber?: string;
+
+ @IsOptional()
+ @IsIn(['AVAILABLE', 'LOADED', 'IN_TRANSIT', 'MAINTENANCE', 'DAMAGED'])
+ status?: string;
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/container-management/dto/update-container.dto.ts b/apps/edr-freight-api/src/modules/container-management/dto/update-container.dto.ts
new file mode 100644
index 000000000..7391bc642
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/container-management/dto/update-container.dto.ts
@@ -0,0 +1,4 @@
+import { PartialType } from '@nestjs/swagger';
+import { CreateContainerDto } from './create-container.dto';
+
+export class UpdateContainerDto extends PartialType(CreateContainerDto) {}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts b/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts
new file mode 100644
index 000000000..a5c7ee9c1
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts
@@ -0,0 +1,45 @@
+// apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts
+import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
+import { BaseEntity } from '@edr/api-common';
+import { Wagon } from '../../wagons/entities/wagon.entity';
+import { Cargo } from '../../cargoes/entities/cargoes.entity';
+
+@Entity({ name: 'containers', schema: 'freight' })
+export class Container extends BaseEntity {
+ @Column({ unique: true, name: 'container_number' })
+ containerNumber!: string;
+
+ @Column({ name: 'container_type_id', type: 'uuid' })
+ containerTypeId!: string;
+
+ @Column({ name: 'wagon_id', type: 'uuid', nullable: true })
+ wagonId!: string | null;
+
+ @Column({ type: 'int', nullable: true })
+ position!: number | null; // position on the wagon (1..N)
+
+ @Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 })
+ tareWeight!: number;
+
+ @Column({ name: 'max_gross_weight', type: 'decimal', precision: 10, scale: 2 })
+ maxGrossWeight!: number;
+
+ @Column({
+ name: 'seal_number',
+ type: 'varchar',
+ nullable: true,
+})
+sealNumber!: string | null;
+
+ @Column({ type: 'varchar', default: 'AVAILABLE' })
+ status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED
+
+ // Relationship to Wagon
+ @ManyToOne(() => Wagon, (wagon) => wagon.containers, { onDelete: 'SET NULL' })
+ @JoinColumn({ name: 'wagon_id' })
+ wagon!: Wagon | null;
+
+ // Relationship to Cargo
+ @OneToMany(() => Cargo, (cargo) => cargo.container)
+ cargoes!: Cargo[];
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/files/files.repository.ts b/apps/edr-freight-api/src/modules/files/files.repository.ts
index 2ec2b94a2..ea4bfd19e 100644
--- a/apps/edr-freight-api/src/modules/files/files.repository.ts
+++ b/apps/edr-freight-api/src/modules/files/files.repository.ts
@@ -25,4 +25,12 @@ export class FilesRepository extends BaseRepository {
): Promise {
return this.repository.findOne({ where: { resourceId, resource, code } });
}
+
+ async deleteByCode(
+ resourceId: string,
+ resource: string,
+ code: string,
+ ): Promise {
+ await this.repository.delete({ resourceId, resource, code });
+ }
}
diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts
index e08fce72b..1f0076986 100644
--- a/apps/edr-freight-api/src/modules/files/files.service.ts
+++ b/apps/edr-freight-api/src/modules/files/files.service.ts
@@ -35,6 +35,13 @@ export class FilesService {
});
}
+ /** Replace existing file row for the same resource + code (e.g. contract PDF). */
+ async upsertByCode(input: CreateFileInput): Promise {
+ const { resourceId, resource, code } = input;
+ await this.filesRepository.deleteByCode(resourceId, resource, code);
+ return this.upload(input);
+ }
+
async uploadMany(
resourceId: string,
resource: string,
diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts
new file mode 100644
index 000000000..3ee26beb9
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts
@@ -0,0 +1,11 @@
+import { ApiPropertyOptional } from '@nestjs/swagger';
+import { IsIn, IsOptional } from 'class-validator';
+
+import { LOCOMOTIVE_STATUSES } from '../entities/locomotive.entity';
+
+export class FilterLocomotivesDto {
+ @ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
+ @IsOptional()
+ @IsIn([...LOCOMOTIVE_STATUSES])
+ status?: string;
+}
diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts
new file mode 100644
index 000000000..1676674b1
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts
@@ -0,0 +1,36 @@
+import { BaseEntity } from '@edr/api-common';
+import { Column, Entity, Index, OneToMany } from 'typeorm';
+
+import { TrainSet } from '../../train-sets/entities/train-set.entity';
+
+export const LOCOMOTIVE_STATUSES = [
+ 'AVAILABLE',
+ 'ASSIGNED',
+ 'MAINTENANCE',
+ 'INACTIVE',
+] as const;
+
+export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
+
+@Entity({ schema: 'freight', name: 'locomotives' })
+@Index(['code'])
+@Index(['status'])
+export class Locomotive extends BaseEntity {
+ @Column({ name: 'code', type: 'varchar', length: 32, unique: true })
+ code!: string;
+
+ @Column({ name: 'name', type: 'varchar', length: 100, nullable: true })
+ name?: string | null;
+
+ @Column({ name: 'max_pull_weight_tons', type: 'numeric', precision: 10, scale: 3 })
+ maxPullWeightTons!: number;
+
+ @Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
+ status!: LocomotiveStatus;
+
+ @Column({ name: 'available_from', type: 'timestamptz', nullable: true })
+ availableFrom?: Date | null;
+
+ @OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive)
+ trainSets?: TrainSet[];
+}
diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts
new file mode 100644
index 000000000..9e64c1e2b
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts
@@ -0,0 +1,18 @@
+import { Controller, Get, Query } from '@nestjs/common';
+import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
+
+import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
+import { LocomotivesService } from './locomotives.service';
+
+@ApiTags('locomotives')
+@ApiBearerAuth()
+@Controller('locomotives')
+export class LocomotivesController {
+ constructor(private readonly locomotivesService: LocomotivesService) {}
+
+ @Get()
+ @ApiOperation({ summary: 'List locomotives' })
+ findAll(@Query() filter: FilterLocomotivesDto) {
+ return this.locomotivesService.findAll(filter);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts
new file mode 100644
index 000000000..264b9cb44
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts
@@ -0,0 +1,15 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+
+import { LocomotivesController } from './locomotives.controller';
+import { Locomotive } from './entities/locomotive.entity';
+import { LocomotivesRepository } from './locomotives.repository';
+import { LocomotivesService } from './locomotives.service';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([Locomotive])],
+ controllers: [LocomotivesController],
+ providers: [LocomotivesRepository, LocomotivesService],
+ exports: [LocomotivesRepository, LocomotivesService],
+})
+export class LocomotivesModule {}
diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts
new file mode 100644
index 000000000..af2a40f50
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts
@@ -0,0 +1,16 @@
+import { BaseRepository } from '@edr/api-common';
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+
+import { Locomotive } from './entities/locomotive.entity';
+
+@Injectable()
+export class LocomotivesRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(Locomotive)
+ repository: Repository,
+ ) {
+ super(repository);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts
new file mode 100644
index 000000000..946a77d48
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts
@@ -0,0 +1,29 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+
+import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
+import { Locomotive, type LocomotiveStatus } from './entities/locomotive.entity';
+import { LocomotivesRepository } from './locomotives.repository';
+
+@Injectable()
+export class LocomotivesService {
+ constructor(private readonly locomotivesRepository: LocomotivesRepository) {}
+
+ findAll(filter: FilterLocomotivesDto): Promise {
+ return this.locomotivesRepository.findAll({
+ where: filter.status
+ ? { status: filter.status as LocomotiveStatus }
+ : undefined,
+ order: { code: 'ASC' },
+ });
+ }
+
+ async findById(id: string): Promise {
+ const locomotive = await this.locomotivesRepository.findById(id);
+
+ if (!locomotive) {
+ throw new NotFoundException(`Locomotive ${id} not found`);
+ }
+
+ return locomotive;
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts
index 02408e53d..0ff7ef543 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts
@@ -1,9 +1,15 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
- Param, ParseUUIDPipe, Patch, Post, Query,
+ Param, ParseUUIDPipe, Patch, Post, Query, UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
-import { ApproveRateDto, CreateRateDto } from '../dto/create-rate.dto';
+import { CurrentUser } from '@edr/api-common';
+import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
+import { CreateRateDto } from '../dto/create-rate.dto';
+import {
+ type AuthUserPayload,
+ resolveAuthUserId,
+} from '../../../common/resolve-auth-user-id';
import { UpdateRateDto } from '../dto/update-rate.dto';
import { RatesService } from '../services/rates.service';
@@ -38,9 +44,13 @@ export class RatesController {
}
@Post()
+ @UseGuards(JwtGuard)
@ApiOperation({ summary: 'Create a rate (DRAFT)' })
- create(@Body() dto: CreateRateDto) {
- return this.service.create(dto);
+ create(
+ @Body() dto: CreateRateDto,
+ @CurrentUser() user: AuthUserPayload,
+ ) {
+ return this.service.create(dto, resolveAuthUserId(user));
}
@Patch(':id')
@@ -56,9 +66,13 @@ export class RatesController {
}
@Post(':id/approve')
+ @UseGuards(JwtGuard)
@ApiOperation({ summary: 'CEO approves a rate' })
- approve(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveRateDto) {
- return this.service.approve(id, dto);
+ approve(
+ @Param('id', ParseUUIDPipe) id: string,
+ @CurrentUser() user: AuthUserPayload,
+ ) {
+ return this.service.approve(id, resolveAuthUserId(user));
}
@Delete(':id')
diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts
index fe9084395..2f73780d9 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts
@@ -35,10 +35,6 @@ export class CreateRateDto {
@IsIn([...RATE_UNITS])
rateUnit!: string;
- @ApiProperty({ description: 'ID of the staff member (Director) proposing this rate' })
- @IsUUID()
- proposedByStaffId!: string;
-
@ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' })
@IsDateString()
effectiveFrom!: string;
@@ -49,12 +45,6 @@ export class CreateRateDto {
effectiveTo?: string;
}
-export class ApproveRateDto {
- @ApiProperty({ description: 'ID of the CEO approving this rate' })
- @IsUUID()
- approvedByCeoId!: string;
-}
-
export class SubmitRateForApprovalDto {
@ApiPropertyOptional({ description: 'Optional note for the approval request', maxLength: 500 })
@IsOptional()
diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts
index 5fc27ecbd..452bee90b 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts
@@ -47,7 +47,8 @@ export interface BookingContainerEvalInput {
}
export interface BookingEvaluationInput {
- cargoTypeId: string;
+ cargoTypeId?: string | null;
+ freightType?: 'CONTAINER' | 'BULK';
serviceTypeId: string;
paymentCurrency: string;
tradeDirection: string;
@@ -115,13 +116,19 @@ export class RuleEngineService {
let priorityScore = 0;
let requiresDirectorApproval = false;
- const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
- if (!cargoType) {
- hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`);
- } else if (cargoType.requiresDirectorApproval) {
+ if (input.freightType === 'BULK') {
requiresDirectorApproval = true;
}
+ if (input.cargoTypeId) {
+ const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
+ if (!cargoType) {
+ hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`);
+ } else if (cargoType.requiresDirectorApproval) {
+ requiresDirectorApproval = true;
+ }
+ }
+
for (const container of input.containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
container.containerTypeId,
@@ -232,16 +239,29 @@ export class RuleEngineService {
}
/**
- * Instantiate booking_approval_step rows from approval_rules for a cargo type.
+ * Instantiate booking_approval_step rows from approval_rules by freight type.
*/
- async instantiateApprovalSteps(bookingId: string, cargoTypeId: string): Promise {
- const cargoType = await this.cargoTypesRepo.findById(cargoTypeId);
- if (!cargoType) {
- throw new BadRequestException(`Cargo type ${cargoTypeId} not found`);
+ async instantiateApprovalSteps(
+ bookingId: string,
+ options: {
+ freightType: 'CONTAINER' | 'BULK';
+ cargoTypeId?: string | null;
+ },
+ ): Promise {
+ let requiresDirectorApproval = options.freightType === 'BULK';
+
+ if (options.cargoTypeId) {
+ const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId);
+ if (!cargoType) {
+ throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`);
+ }
+ if (cargoType.requiresDirectorApproval) {
+ requiresDirectorApproval = true;
+ }
}
const chain = await this.approvalRulesRepo.findChainForCargo(
- cargoType.requiresDirectorApproval,
+ requiresDirectorApproval,
);
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts
index 656802e3f..0202ef44a 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts
@@ -1,5 +1,5 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
-import { ApproveRateDto, CreateRateDto } from '../dto/create-rate.dto';
+import { CreateRateDto } from '../dto/create-rate.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
import { Rate } from '../entities/rate.entity';
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
@@ -46,7 +46,7 @@ export class RatesService {
}
/** Create a rate in DRAFT status. */
- async create(dto: CreateRateDto): Promise {
+ async create(dto: CreateRateDto, proposedByStaffId: string): Promise {
return this.repository.create({
rateType: dto.rateType as Rate['rateType'],
containerTypeId: dto.containerTypeId,
@@ -55,7 +55,7 @@ export class RatesService {
rateValue: dto.rateValue,
rateUnit: dto.rateUnit as Rate['rateUnit'],
status: 'DRAFT',
- proposedByStaffId: dto.proposedByStaffId,
+ proposedByStaffId,
effectiveFrom: new Date(dto.effectiveFrom),
effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined,
});
@@ -74,7 +74,6 @@ export class RatesService {
if (dto.currency) updates.currency = dto.currency;
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit'];
- if (dto.proposedByStaffId) updates.proposedByStaffId = dto.proposedByStaffId;
if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom);
if (dto.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo);
const updated = await this.repository.update(id, updates);
@@ -93,14 +92,14 @@ export class RatesService {
}
/** CEO approves a rate — moves to LIVE. */
- async approve(id: string, dto: ApproveRateDto): Promise {
+ async approve(id: string, approverUserId: string): Promise {
const rate = await this.findById(id);
if (rate.status !== 'PENDING_APPROVAL') {
throw new BadRequestException('Only PENDING_APPROVAL rates can be approved');
}
const updated = await this.repository.update(id, {
status: 'LIVE',
- approvedByCeoId: dto.approvedByCeoId,
+ approvedByCeoId: approverUserId,
approvedAt: new Date(),
});
return updated!;
diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts
index 98e5b1642..387e26ba9 100644
--- a/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts
+++ b/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts
@@ -28,6 +28,7 @@ export class SurchargeTypesService {
const [data, total] = await this.repository.findAndCount({
where,
+ relations: { rate: true },
order: { label: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts
new file mode 100644
index 000000000..4ffecea26
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts
@@ -0,0 +1,26 @@
+import { BaseEntity } from '@edr/api-common';
+import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm';
+
+import { Booking } from '../../bookings/entities/booking.entity';
+import { TrainSchedule } from './train-schedule.entity';
+
+@Entity({ schema: 'freight', name: 'train_schedule_bookings' })
+@Index(['trainScheduleId', 'bookingId'], { unique: true })
+@Index(['bookingId'], { unique: true })
+export class TrainScheduleBooking extends BaseEntity {
+ @Column({ name: 'train_schedule_id', type: 'uuid' })
+ trainScheduleId!: string;
+
+ @ManyToOne(() => TrainSchedule, (trainSchedule) => trainSchedule.scheduleBookings, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn({ name: 'train_schedule_id' })
+ trainSchedule?: TrainSchedule;
+
+ @Column({ name: 'booking_id', type: 'uuid' })
+ bookingId!: string;
+
+ @ManyToOne(() => Booking)
+ @JoinColumn({ name: 'booking_id' })
+ booking?: Booking;
+}
diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts
new file mode 100644
index 000000000..965723f6c
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts
@@ -0,0 +1,54 @@
+import { BaseEntity } from '@edr/api-common';
+import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
+
+import { Yard } from '../../rule-engine/entities/yard.entity';
+import { TrainSet } from '../../train-sets/entities/train-set.entity';
+import { TrainScheduleBooking } from './train-schedule-booking.entity';
+
+export const TRAIN_SCHEDULE_STATUSES = [
+ 'DRAFT',
+ 'SCHEDULED',
+ 'DISPATCHED',
+ 'ARRIVED',
+ 'CANCELLED',
+] as const;
+
+export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number];
+
+@Entity({ schema: 'freight', name: 'train_schedules' })
+@Index(['scheduledDepartureDate'])
+@Index(['status'])
+export class TrainSchedule extends BaseEntity {
+ @Column({ name: 'train_set_id', type: 'uuid', unique: true })
+ trainSetId!: string;
+
+ @OneToOne(() => TrainSet, (trainSet) => trainSet.trainSchedule)
+ @JoinColumn({ name: 'train_set_id' })
+ trainSet?: TrainSet;
+
+ @Column({ name: 'origin_station_id', type: 'uuid' })
+ originStationId!: string;
+
+ @ManyToOne(() => Yard)
+ @JoinColumn({ name: 'origin_station_id' })
+ originStation?: Yard;
+
+ @Column({ name: 'destination_station_id', type: 'uuid' })
+ destinationStationId!: string;
+
+ @ManyToOne(() => Yard)
+ @JoinColumn({ name: 'destination_station_id' })
+ destinationStation?: Yard;
+
+ @Column({ name: 'scheduled_departure_date', type: 'timestamptz' })
+ scheduledDepartureDate!: Date;
+
+ @Column({ name: 'scheduled_arrival_date', type: 'timestamptz', nullable: true })
+ scheduledArrivalDate?: Date | null;
+
+ @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
+ status!: TrainScheduleStatus;
+
+ @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
+ scheduleBookings?: TrainScheduleBooking[];
+}
diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts
new file mode 100644
index 000000000..4c78256fe
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts
@@ -0,0 +1,26 @@
+import { BaseEntity } from '@edr/api-common';
+import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
+
+import { Booking } from '../../bookings/entities/booking.entity';
+import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
+
+@Entity({ schema: 'freight', name: 'wagon_booking_allocations' })
+@Index(['trainSetWagonId', 'bookingId'])
+export class WagonBookingAllocation extends BaseEntity {
+ @Column({ name: 'train_set_wagon_id', type: 'uuid' })
+ trainSetWagonId!: string;
+
+ @ManyToOne(() => TrainSetWagon, (wagon) => wagon.allocations, { onDelete: 'CASCADE' })
+ @JoinColumn({ name: 'train_set_wagon_id' })
+ trainSetWagon?: TrainSetWagon;
+
+ @Column({ name: 'booking_id', type: 'uuid' })
+ bookingId!: string;
+
+ @ManyToOne(() => Booking)
+ @JoinColumn({ name: 'booking_id' })
+ booking?: Booking;
+
+ @Column({ name: 'allocated_weight_tons', type: 'numeric', precision: 10, scale: 3 })
+ allocatedWeightTons!: number;
+}
diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts
new file mode 100644
index 000000000..d7360226f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts
@@ -0,0 +1,16 @@
+import { BaseRepository } from '@edr/api-common';
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+
+import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
+
+@Injectable()
+export class TrainScheduleBookingsRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(TrainScheduleBooking)
+ repository: Repository,
+ ) {
+ super(repository);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts
new file mode 100644
index 000000000..9fa40897a
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts
@@ -0,0 +1,24 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+
+import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
+import { TrainSchedule } from './entities/train-schedule.entity';
+import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
+import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository';
+import { TrainSchedulesRepository } from './train-schedules.repository';
+import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([TrainSchedule, TrainScheduleBooking, WagonBookingAllocation])],
+ providers: [
+ TrainSchedulesRepository,
+ TrainScheduleBookingsRepository,
+ WagonBookingAllocationsRepository,
+ ],
+ exports: [
+ TrainSchedulesRepository,
+ TrainScheduleBookingsRepository,
+ WagonBookingAllocationsRepository,
+ ],
+})
+export class TrainSchedulesModule {}
diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts
new file mode 100644
index 000000000..b6f18eaf2
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts
@@ -0,0 +1,16 @@
+import { BaseRepository } from '@edr/api-common';
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+
+import { TrainSchedule } from './entities/train-schedule.entity';
+
+@Injectable()
+export class TrainSchedulesRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(TrainSchedule)
+ repository: Repository,
+ ) {
+ super(repository);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts
new file mode 100644
index 000000000..067dddaf7
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts
@@ -0,0 +1,16 @@
+import { BaseRepository } from '@edr/api-common';
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+
+import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
+
+@Injectable()
+export class WagonBookingAllocationsRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(WagonBookingAllocation)
+ repository: Repository,
+ ) {
+ super(repository);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts
new file mode 100644
index 000000000..173b0a6b3
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts
@@ -0,0 +1,10 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsUUID } from 'class-validator';
+
+import { PreviewContainerTrainScheduleDto } from './preview-container-train-schedule.dto';
+
+export class CreateContainerTrainScheduleDto extends PreviewContainerTrainScheduleDto {
+ @ApiProperty({ format: 'uuid' })
+ @IsUUID()
+ locomotiveId!: string;
+}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts
new file mode 100644
index 000000000..8712b2a1d
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts
@@ -0,0 +1,23 @@
+import { ApiPropertyOptional } from '@nestjs/swagger';
+import { IsDateString, IsOptional, IsUUID } from 'class-validator';
+
+export class GetEligibleContainerBookingsDto {
+ @ApiPropertyOptional({ format: 'uuid' })
+ @IsOptional()
+ @IsUUID()
+ originStationId?: string;
+
+ @ApiPropertyOptional({ format: 'uuid' })
+ @IsOptional()
+ @IsUUID()
+ destinationStationId?: string;
+
+ @ApiPropertyOptional({ example: '2026-06-20T08:00:00.000Z' })
+ @IsOptional()
+ @IsDateString()
+ scheduleDate?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ status?: string;
+}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts
new file mode 100644
index 000000000..e3e142a61
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts
@@ -0,0 +1,22 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { ArrayMinSize, IsArray, IsDateString, IsUUID } from 'class-validator';
+
+export class PreviewContainerTrainScheduleDto {
+ @ApiProperty({ type: [String] })
+ @IsArray()
+ @ArrayMinSize(1)
+ @IsUUID('4', { each: true })
+ bookingIds!: string[];
+
+ @ApiProperty({ example: '2026-06-20T08:00:00.000Z' })
+ @IsDateString()
+ scheduleDate!: string;
+
+ @ApiProperty({ format: 'uuid' })
+ @IsUUID()
+ originStationId!: string;
+
+ @ApiProperty({ format: 'uuid' })
+ @IsUUID()
+ destinationStationId!: string;
+}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
new file mode 100644
index 000000000..1dd01ffba
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
@@ -0,0 +1,58 @@
+import {
+ Body,
+ Controller,
+ Get,
+ Param,
+ ParseUUIDPipe,
+ Post,
+ Query,
+} from '@nestjs/common';
+import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
+
+import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
+import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
+import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
+import { TrainSchedulingService } from './train-scheduling.service';
+
+@ApiTags('train-scheduling')
+@ApiBearerAuth()
+@Controller('train-scheduling')
+export class TrainSchedulingController {
+ constructor(private readonly trainSchedulingService: TrainSchedulingService) {}
+
+ @Get('container/eligible-bookings')
+ @ApiOperation({ summary: 'List eligible container bookings' })
+ getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) {
+ return this.trainSchedulingService.getEligibleContainerBookings(query);
+ }
+
+ @Post('container/preview')
+ @ApiOperation({ summary: 'Preview a container train schedule' })
+ previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) {
+ return this.trainSchedulingService.previewContainerTrainSchedule(dto);
+ }
+
+ @Post('container/schedules')
+ @ApiOperation({ summary: 'Create a container train schedule' })
+ createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
+ return this.trainSchedulingService.createContainerTrainSchedule(dto);
+ }
+
+ @Get('container/schedules')
+ @ApiOperation({ summary: 'List container train schedules' })
+ getContainerTrainSchedules() {
+ return this.trainSchedulingService.getContainerTrainSchedules();
+ }
+
+ @Get('container/schedules/:id')
+ @ApiOperation({ summary: 'Get container train schedule detail' })
+ getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
+ return this.trainSchedulingService.getContainerTrainScheduleById(id);
+ }
+
+ @Post('container/schedules/:id/cancel')
+ @ApiOperation({ summary: 'Cancel container train schedule' })
+ cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
+ return this.trainSchedulingService.cancelTrainSchedule(id);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts
new file mode 100644
index 000000000..dbf79e403
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts
@@ -0,0 +1,46 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+
+import { BookingsModule } from '../bookings/bookings.module';
+import { Booking } from '../bookings/entities/booking.entity';
+import { BookingContainer } from '../bookings/entities/booking-container.entity';
+import { LocomotivesModule } from '../locomotives/locomotives.module';
+import { Locomotive } from '../locomotives/entities/locomotive.entity';
+import { WagonType } from '../wagon-types/entities/wagon-type.entity';
+import { WagonTypesModule } from '../wagon-types/wagon-types.module';
+import { TrainSet } from '../train-sets/entities/train-set.entity';
+import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
+import { TrainSetsModule } from '../train-sets/train-sets.module';
+import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
+import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
+import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
+import { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
+import { Yard } from '../rule-engine/entities/yard.entity';
+import { TrainSchedulingController } from './train-scheduling.controller';
+import { TrainSchedulingService } from './train-scheduling.service';
+
+@Module({
+ imports: [
+ TypeOrmModule.forFeature([
+ Booking,
+ BookingContainer,
+ Locomotive,
+ WagonType,
+ TrainSet,
+ TrainSetWagon,
+ TrainSchedule,
+ TrainScheduleBooking,
+ WagonBookingAllocation,
+ Yard,
+ ]),
+ BookingsModule,
+ LocomotivesModule,
+ WagonTypesModule,
+ TrainSetsModule,
+ TrainSchedulesModule,
+ ],
+ controllers: [TrainSchedulingController],
+ providers: [TrainSchedulingService],
+ exports: [TrainSchedulingService],
+})
+export class TrainSchedulingModule {}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts
new file mode 100644
index 000000000..659a4d6eb
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts
@@ -0,0 +1,328 @@
+import { ConflictException } from '@nestjs/common';
+
+import { TrainSchedulingService } from './train-scheduling.service';
+
+const nw5 = {
+ id: 'wagon-type-1',
+ code: 'NW5',
+ name: 'Flat Wagon',
+ capacityTons: 70,
+ lengthMeters: 14,
+ maxWagonsPerTrain: 53,
+ supportedLoadTypes: ['CONTAINER'],
+ isActive: true,
+};
+
+const locomotive = {
+ id: 'loc-1',
+ code: 'LOC-001',
+ maxPullWeightTons: 3500,
+ status: 'AVAILABLE',
+};
+
+const makeBooking = (
+ id: string,
+ reference: string,
+ weight: number,
+ quantity: number,
+ containerCode: string,
+ scheduledDate = '2026-06-20T08:00:00.000Z',
+ originYardId = 'yard-origin',
+ destinationYardId = 'yard-destination',
+) => ({
+ id,
+ reference,
+ freightType: 'CONTAINER',
+ cargoTotalWeightVgm: weight,
+ scheduledDate: new Date(scheduledDate),
+ originYardId,
+ destinationYardId,
+ status: 'APPROVED',
+ customer: { companyName: 'Demo Customer' },
+ originYard: { label: 'Djibouti', code: 'DJIBOUTI' },
+ destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' },
+ bookingContainers: [
+ {
+ quantity,
+ containerType: { code: containerCode, label: containerCode },
+ },
+ ],
+});
+
+describe('TrainSchedulingService', () => {
+ let service: TrainSchedulingService;
+ let dataSource: {
+ getRepository: jest.Mock;
+ transaction: jest.Mock;
+ };
+ let locomotivesRepository: {
+ findById: jest.Mock;
+ };
+ let wagonTypesRepository: {
+ findAll: jest.Mock;
+ };
+
+ beforeEach(() => {
+ dataSource = {
+ getRepository: jest.fn(),
+ transaction: jest.fn(),
+ };
+ locomotivesRepository = {
+ findById: jest.fn(),
+ };
+ wagonTypesRepository = {
+ findAll: jest.fn(),
+ };
+
+ service = new TrainSchedulingService(
+ dataSource as never,
+ locomotivesRepository as never,
+ wagonTypesRepository as never,
+ );
+ });
+
+ it('computes the expected valid preview for Group A', async () => {
+ const bookings = [
+ makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT'),
+ makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT'),
+ makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT'),
+ ];
+
+ wagonTypesRepository.findAll.mockResolvedValue([nw5]);
+ dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
+ if (entity?.name === 'Booking') {
+ return { find: jest.fn().mockResolvedValue(bookings) };
+ }
+ if (entity?.name === 'TrainScheduleBooking') {
+ return { find: jest.fn().mockResolvedValue([]) };
+ }
+ if (entity?.name === 'Locomotive') {
+ return {
+ count: jest.fn().mockResolvedValue(2),
+ find: jest.fn().mockResolvedValue([locomotive]),
+ };
+ }
+ throw new Error(`Unexpected repository ${entity?.name}`);
+ });
+
+ const result = await service.previewContainerTrainSchedule({
+ bookingIds: bookings.map((booking) => booking.id),
+ scheduleDate: '2026-06-20T08:00:00.000Z',
+ originStationId: 'yard-origin',
+ destinationStationId: 'yard-destination',
+ });
+
+ expect(result.valid).toBe(true);
+ expect(result.violations).toEqual([]);
+ expect(result.summary).toEqual({
+ totalBookings: 3,
+ totalWeightTons: 1250,
+ wagonType: 'NW5',
+ wagonsNeeded: 18,
+ totalLengthMeters: 252,
+ });
+ expect(result.wagonPlan).toHaveLength(18);
+ expect(result.wagonPlan[0]?.allocations[0]).toEqual({
+ bookingId: 'b1',
+ bookingReference: 'BKG-CONT-001',
+ allocatedWeightTons: 70,
+ });
+ });
+
+ it('flags the overweight booking as invalid', async () => {
+ const bookings = [makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT')];
+
+ wagonTypesRepository.findAll.mockResolvedValue([nw5]);
+ dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
+ if (entity?.name === 'Booking') {
+ return { find: jest.fn().mockResolvedValue(bookings) };
+ }
+ if (entity?.name === 'TrainScheduleBooking') {
+ return { find: jest.fn().mockResolvedValue([]) };
+ }
+ if (entity?.name === 'Locomotive') {
+ return {
+ count: jest.fn().mockResolvedValue(1),
+ find: jest.fn().mockResolvedValue([locomotive]),
+ };
+ }
+ throw new Error(`Unexpected repository ${entity?.name}`);
+ });
+
+ const result = await service.previewContainerTrainSchedule({
+ bookingIds: ['b6'],
+ scheduleDate: '2026-06-20T08:00:00.000Z',
+ originStationId: 'yard-origin',
+ destinationStationId: 'yard-destination',
+ });
+
+ expect(result.valid).toBe(false);
+ expect(result.summary.totalWeightTons).toBe(3600);
+ expect(result.violations).toContain(
+ 'Total booking weight 3600T exceeds max train weight 3500T',
+ );
+ });
+
+ it('creates a schedule transactionally when validation passes', async () => {
+ const bookings = [makeBooking('b1', 'BKG-CONT-001', 140, 2, '40FT')];
+ const validation = {
+ valid: true,
+ violations: [],
+ bookings,
+ wagonType: nw5,
+ summary: {
+ totalBookings: 1,
+ totalWeightTons: 140,
+ wagonType: 'NW5',
+ wagonsNeeded: 2,
+ totalLengthMeters: 28,
+ },
+ wagonPlan: [
+ {
+ sequenceNo: 1,
+ capacityTons: 70,
+ lengthMeters: 14,
+ assignedWeightTons: 70,
+ allocations: [
+ {
+ bookingId: 'b1',
+ bookingReference: 'BKG-CONT-001',
+ allocatedWeightTons: 70,
+ },
+ ],
+ },
+ {
+ sequenceNo: 2,
+ capacityTons: 70,
+ lengthMeters: 14,
+ assignedWeightTons: 70,
+ allocations: [
+ {
+ bookingId: 'b1',
+ bookingReference: 'BKG-CONT-001',
+ allocatedWeightTons: 70,
+ },
+ ],
+ },
+ ],
+ };
+
+ const lockedLocomotiveRepo = {
+ findOne: jest.fn().mockResolvedValue(locomotive),
+ update: jest.fn().mockResolvedValue(undefined),
+ };
+ const trainScheduleRepo = {
+ create: jest.fn().mockImplementation((value) => value),
+ save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
+ };
+ const trainScheduleBookingRepo = {
+ count: jest.fn().mockResolvedValue(0),
+ create: jest.fn().mockImplementation((value) => value),
+ save: jest.fn().mockResolvedValue(undefined),
+ };
+ const trainSetWagonRepo = {
+ create: jest.fn().mockImplementation((value) => value),
+ save: jest.fn().mockResolvedValue(undefined),
+ find: jest.fn().mockResolvedValue([
+ { id: 'wagon-1', sequenceNo: 1 },
+ { id: 'wagon-2', sequenceNo: 2 },
+ ]),
+ };
+ const wagonAllocRepo = {
+ create: jest.fn().mockImplementation((value) => value),
+ save: jest.fn().mockResolvedValue(undefined),
+ };
+ const trainSetRepo = {
+ create: jest.fn().mockImplementation((value) => value),
+ save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
+ };
+ const manager = {
+ getRepository: jest.fn((entity: { name?: string }) => {
+ switch (entity?.name) {
+ case 'Locomotive':
+ return lockedLocomotiveRepo;
+ case 'TrainSchedule':
+ return trainScheduleRepo;
+ case 'TrainScheduleBooking':
+ return trainScheduleBookingRepo;
+ case 'TrainSetWagon':
+ return trainSetWagonRepo;
+ case 'WagonBookingAllocation':
+ return wagonAllocRepo;
+ case 'TrainSet':
+ return trainSetRepo;
+ default:
+ throw new Error(`Unexpected transaction repository ${entity?.name}`);
+ }
+ }),
+ };
+
+ jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
+ jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
+ jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never);
+ dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) =>
+ callback(manager),
+ );
+
+ const result = await service.createContainerTrainSchedule({
+ bookingIds: ['b1'],
+ scheduleDate: '2026-06-20T08:00:00.000Z',
+ originStationId: 'yard-origin',
+ destinationStationId: 'yard-destination',
+ locomotiveId: 'loc-1',
+ });
+
+ expect(trainSetRepo.save).toHaveBeenCalled();
+ expect(trainScheduleRepo.save).toHaveBeenCalled();
+ expect(trainSetWagonRepo.save).toHaveBeenCalled();
+ expect(wagonAllocRepo.save).toHaveBeenCalled();
+ expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
+ expect(result).toEqual({ id: 'schedule-1' });
+ });
+
+ it('rejects create when the locked locomotive is no longer available', async () => {
+ const validation = {
+ valid: true,
+ violations: [],
+ bookings: [makeBooking('b1', 'BKG-CONT-001', 70, 1, '40FT')],
+ wagonType: nw5,
+ summary: {
+ totalBookings: 1,
+ totalWeightTons: 70,
+ wagonType: 'NW5',
+ wagonsNeeded: 1,
+ totalLengthMeters: 14,
+ },
+ wagonPlan: [
+ {
+ sequenceNo: 1,
+ capacityTons: 70,
+ lengthMeters: 14,
+ assignedWeightTons: 70,
+ allocations: [],
+ },
+ ],
+ };
+ const manager = {
+ getRepository: jest.fn(() => ({
+ findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }),
+ })),
+ };
+
+ jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
+ jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
+ dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) =>
+ callback(manager),
+ );
+
+ await expect(
+ service.createContainerTrainSchedule({
+ bookingIds: ['b1'],
+ scheduleDate: '2026-06-20T08:00:00.000Z',
+ originStationId: 'yard-origin',
+ destinationStationId: 'yard-destination',
+ locomotiveId: 'loc-1',
+ }),
+ ).rejects.toBeInstanceOf(ConflictException);
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
new file mode 100644
index 000000000..68fae00ea
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
@@ -0,0 +1,846 @@
+import {
+ BadRequestException,
+ ConflictException,
+ Injectable,
+ NotFoundException,
+} from "@nestjs/common";
+import { InjectDataSource } from "@nestjs/typeorm";
+import { DataSource, EntityManager, In } from "typeorm";
+
+import { Booking } from "../bookings/entities/booking.entity";
+import {
+ Locomotive,
+ type LocomotiveStatus,
+} from "../locomotives/entities/locomotive.entity";
+import { LocomotivesRepository } from "../locomotives/locomotives.repository";
+import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.entity";
+import { TrainSet } from "../train-sets/entities/train-set.entity";
+import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity";
+import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
+import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity";
+import { WagonType } from "../wagon-types/entities/wagon-type.entity";
+import { WagonTypesRepository } from "../wagon-types/wagon-types.repository";
+import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
+import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
+import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
+
+const DEFAULT_WAGON_TYPE_CODE = "NW5";
+const MAX_TRAIN_WEIGHT_TONS = 3500;
+const MAX_TRAIN_LENGTH_METERS = 760;
+
+type EligibleBookingItem = {
+ id: string;
+ reference: string;
+ customer: string;
+ containerType: string;
+ quantity: number;
+ weightTons: number;
+ origin: string;
+ destination: string;
+ preferredDepartureDate: string;
+ status: string;
+};
+
+type WagonAllocationRecord = {
+ bookingId: string;
+ bookingReference: string;
+ allocatedWeightTons: number;
+};
+
+type WagonPlanRecord = {
+ sequenceNo: number;
+ capacityTons: number;
+ lengthMeters: number;
+ assignedWeightTons: number;
+ allocations: WagonAllocationRecord[];
+};
+
+type ValidationResult = {
+ valid: boolean;
+ violations: string[];
+ bookings: Booking[];
+ wagonType: WagonType;
+ summary: {
+ totalBookings: number;
+ totalWeightTons: number;
+ wagonType: string;
+ wagonsNeeded: number;
+ totalLengthMeters: number;
+ };
+ wagonPlan: WagonPlanRecord[];
+};
+
+@Injectable()
+export class TrainSchedulingService {
+ constructor(
+ @InjectDataSource()
+ private readonly dataSource: DataSource,
+ private readonly locomotivesRepository: LocomotivesRepository,
+ private readonly wagonTypesRepository: WagonTypesRepository,
+ ) { }
+
+ async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
+ const bookingRepository = this.dataSource.getRepository(Booking);
+ const queryBuilder = bookingRepository
+<<<<<<< HEAD
+ .createQueryBuilder('booking')
+ .leftJoinAndSelect('booking.company', 'company')
+ .leftJoinAndSelect('booking.originYard', 'originYard')
+ .leftJoinAndSelect('booking.destinationYard', 'destinationYard')
+ .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
+ .leftJoinAndSelect('bookingContainer.containerType', 'containerType')
+ .leftJoin(TrainScheduleBooking, 'scheduleBooking', 'scheduleBooking.booking_id = booking.id')
+ .where('booking.freightType = :freightType', { freightType: 'CONTAINER' })
+ .andWhere('scheduleBooking.id IS NULL');
+=======
+ .createQueryBuilder("booking")
+ .leftJoinAndSelect("booking.customer", "customer")
+ .leftJoinAndSelect("booking.originYard", "originYard")
+ .leftJoinAndSelect("booking.destinationYard", "destinationYard")
+ .leftJoinAndSelect("booking.bookingContainers", "bookingContainer")
+ .leftJoinAndSelect("bookingContainer.containerType", "containerType")
+ .leftJoin(
+ TrainScheduleBooking,
+ "scheduleBooking",
+ "scheduleBooking.booking_id = booking.id",
+ )
+ .where("booking.freightType = :freightType", { freightType: "CONTAINER" })
+ .andWhere("scheduleBooking.id IS NULL");
+>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
+
+ if (query.originStationId) {
+ queryBuilder.andWhere("booking.originYardId = :originStationId", {
+ originStationId: query.originStationId,
+ });
+ }
+
+ if (query.destinationStationId) {
+ queryBuilder.andWhere(
+ "booking.destinationYardId = :destinationStationId",
+ {
+ destinationStationId: query.destinationStationId,
+ },
+ );
+ }
+
+ if (query.scheduleDate) {
+ queryBuilder.andWhere(
+ `DATE(booking.scheduled_date AT TIME ZONE 'UTC') = :scheduleDate`,
+ { scheduleDate: this.toUtcDateKey(query.scheduleDate) },
+ );
+ }
+
+ if (query.status) {
+ queryBuilder.andWhere("booking.status = :status", {
+ status: query.status,
+ });
+ }
+
+ const bookings = await queryBuilder
+ .orderBy("booking.scheduled_date", "ASC")
+ .addOrderBy("booking.created_at", "ASC")
+ .getMany();
+
+ const items: EligibleBookingItem[] = bookings.map((booking) => ({
+ id: booking.id,
+ reference: booking.reference,
+<<<<<<< HEAD
+ customer: booking.company?.name ?? booking.company?.email ?? 'Unknown customer',
+ containerType: booking.bookingContainers
+ ?.map((container) => container.containerType?.label ?? container.containerType?.code ?? 'Container')
+ .join(', ') ?? 'Container',
+ quantity: booking.bookingContainers?.reduce((sum, container) => sum + Number(container.quantity ?? 0), 0) ?? 0,
+=======
+ customer:
+ booking.company?.name ?? booking.company?.email ?? "Unknown customer",
+ containerType:
+ booking.bookingContainers
+ ?.map(
+ (container) =>
+ container.containerType?.label ??
+ container.containerType?.code ??
+ "Container",
+ )
+ .join(", ") ?? "Container",
+ quantity:
+ booking.bookingContainers?.reduce(
+ (sum, container) => sum + Number(container.quantity ?? 0),
+ 0,
+ ) ?? 0,
+>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
+ weightTons: this.roundTons(booking.cargoTotalWeightVgm),
+ origin:
+ booking.originYard?.label ??
+ booking.originYard?.code ??
+ "Unknown origin",
+ destination:
+ booking.destinationYard?.label ??
+ booking.destinationYard?.code ??
+ "Unknown destination",
+ preferredDepartureDate: booking.scheduledDate.toISOString(),
+ status: booking.status,
+ }));
+
+ return {
+ count: items.length,
+ items,
+ };
+ }
+
+ async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) {
+ const validation = await this.validateContainerBookingsForScheduling(dto);
+
+ return {
+ valid: validation.valid,
+ violations: validation.violations,
+ summary: validation.summary,
+ bookingIds: validation.bookings.map((booking) => booking.id),
+ wagonPlan: validation.wagonPlan,
+ };
+ }
+
+ async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
+ const validation = await this.validateContainerBookingsForScheduling(dto);
+
+ if (!validation.valid) {
+ throw new BadRequestException({
+ message: "train_schedule_invalid",
+ violations: validation.violations,
+ });
+ }
+
+ const locomotive = await this.selectOrValidateLocomotive(
+ dto.locomotiveId,
+ validation.summary.totalWeightTons,
+ );
+
+ const createdSchedule = await this.dataSource.transaction(
+ async (manager) => {
+ const locomotiveRepository = manager.getRepository(Locomotive);
+ const lockedLocomotive = await locomotiveRepository.findOne({
+ where: { id: locomotive.id },
+ lock: { mode: "pessimistic_write" },
+ });
+
+ if (!lockedLocomotive) {
+ throw new NotFoundException(`Locomotive ${locomotive.id} not found`);
+ }
+
+ if (lockedLocomotive.status !== "AVAILABLE") {
+ throw new ConflictException(
+ `Locomotive ${lockedLocomotive.code} is not available`,
+ );
+ }
+
+ if (
+ Number(lockedLocomotive.maxPullWeightTons) <
+ validation.summary.totalWeightTons
+ ) {
+ throw new BadRequestException(
+ `Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`,
+ );
+ }
+
+ const existingScheduleCount = await manager
+ .getRepository(TrainScheduleBooking)
+ .count({
+ where: {
+ bookingId: In(validation.bookings.map((booking) => booking.id)),
+ },
+ });
+
+ if (existingScheduleCount > 0) {
+ throw new BadRequestException(
+ "One or more bookings are already scheduled",
+ );
+ }
+
+ const trainSet = await this.buildTrainSet(
+ manager,
+ lockedLocomotive,
+ validation.wagonType,
+ validation.summary.totalWeightTons,
+ validation.summary.totalLengthMeters,
+ validation.wagonPlan,
+ );
+
+ const schedule = manager.getRepository(TrainSchedule).create({
+ trainSetId: trainSet.id,
+ originStationId: dto.originStationId,
+ destinationStationId: dto.destinationStationId,
+ scheduledDepartureDate: new Date(dto.scheduleDate),
+ status: "SCHEDULED",
+ });
+
+ const savedSchedule = await manager
+ .getRepository(TrainSchedule)
+ .save(schedule);
+
+ const scheduleBookings = validation.bookings.map((booking) =>
+ manager.getRepository(TrainScheduleBooking).create({
+ trainScheduleId: savedSchedule.id,
+ bookingId: booking.id,
+ }),
+ );
+ await manager
+ .getRepository(TrainScheduleBooking)
+ .save(scheduleBookings);
+
+ const savedWagons = await manager.getRepository(TrainSetWagon).find({
+ where: { trainSetId: trainSet.id },
+ order: { sequenceNo: "ASC" },
+ });
+
+ const wagonBySequence = new Map(
+ savedWagons.map((wagon) => [wagon.sequenceNo, wagon]),
+ );
+ const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => {
+ const wagon = wagonBySequence.get(wagonPlan.sequenceNo);
+
+ if (!wagon) {
+ throw new BadRequestException(
+ `Missing wagon sequence ${wagonPlan.sequenceNo}`,
+ );
+ }
+
+ return wagonPlan.allocations.map((allocation) =>
+ manager.getRepository(WagonBookingAllocation).create({
+ trainSetWagonId: wagon.id,
+ bookingId: allocation.bookingId,
+ allocatedWeightTons: allocation.allocatedWeightTons,
+ }),
+ );
+ });
+
+ await manager
+ .getRepository(WagonBookingAllocation)
+ .save(allocationRows);
+
+ await locomotiveRepository.update(lockedLocomotive.id, {
+ status: "ASSIGNED",
+ });
+
+ return savedSchedule.id;
+ },
+ );
+
+ return this.getContainerTrainScheduleById(createdSchedule);
+ }
+
+ async validateContainerBookingsForScheduling(
+ dto: PreviewContainerTrainScheduleDto,
+ ): Promise {
+ const bookingIds = [...new Set(dto.bookingIds)];
+
+ if (!bookingIds.length) {
+ throw new BadRequestException("At least one booking is required");
+ }
+
+ const [wagonType] = await this.wagonTypesRepository.findAll({
+ where: { code: DEFAULT_WAGON_TYPE_CODE, isActive: true },
+ });
+
+ if (!wagonType) {
+ throw new NotFoundException(
+ `Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`,
+ );
+ }
+
+ const bookings = await this.loadBookingsForScheduling(bookingIds);
+ const violations: string[] = [];
+
+ if (bookings.length !== bookingIds.length) {
+ const foundIds = new Set(bookings.map((booking) => booking.id));
+ const missing = bookingIds.filter((id) => !foundIds.has(id));
+ violations.push(`Bookings not found: ${missing.join(", ")}`);
+ }
+
+ const scheduledLinks = await this.dataSource
+ .getRepository(TrainScheduleBooking)
+ .find({
+ where: { bookingId: In(bookingIds) },
+ select: { bookingId: true },
+ });
+
+ if (scheduledLinks.length > 0) {
+ violations.push(
+ "One or more selected bookings are already assigned to a train schedule",
+ );
+ }
+
+ const nonContainerBookings = bookings.filter(
+ (booking) => booking.freightType !== "CONTAINER",
+ );
+ if (nonContainerBookings.length > 0) {
+ violations.push(
+ "Only CONTAINER bookings are supported for train scheduling",
+ );
+ }
+
+ const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate);
+ const routeMismatch = bookings.some(
+ (booking) =>
+ booking.originYardId !== dto.originStationId ||
+ booking.destinationYardId !== dto.destinationStationId,
+ );
+ if (routeMismatch) {
+ violations.push(
+ "Selected bookings must share the same origin and destination as the schedule",
+ );
+ }
+
+ const dateMismatch = bookings.some(
+ (booking) => this.toUtcDateKey(booking.scheduledDate) !== scheduleDateKey,
+ );
+ if (dateMismatch) {
+ violations.push("Selected bookings must share the same schedule date");
+ }
+
+ const uniqueOriginCount = new Set(
+ bookings.map((booking) => booking.originYardId),
+ ).size;
+ if (uniqueOriginCount > 1) {
+ violations.push("Selected bookings must share the same origin station");
+ }
+
+ const uniqueDestinationCount = new Set(
+ bookings.map((booking) => booking.destinationYardId),
+ ).size;
+ if (uniqueDestinationCount > 1) {
+ violations.push(
+ "Selected bookings must share the same destination station",
+ );
+ }
+
+ const uniqueDateCount = new Set(
+ bookings.map((booking) => this.toUtcDateKey(booking.scheduledDate)),
+ ).size;
+ if (uniqueDateCount > 1) {
+ violations.push(
+ "Selected bookings must share the same preferred departure date",
+ );
+ }
+
+ const totalWeightTons = this.roundTons(
+ bookings.reduce(
+ (sum, booking) => sum + Number(booking.cargoTotalWeightVgm ?? 0),
+ 0,
+ ),
+ );
+
+ const wagonPlan = this.allocateBookingsToWagons(
+ bookings,
+ this.calculateNW5WagonPlan(totalWeightTons, wagonType),
+ );
+ const totalLengthMeters = this.roundTons(
+ wagonPlan.reduce((sum, wagon) => sum + wagon.lengthMeters, 0),
+ );
+
+ if (totalWeightTons > MAX_TRAIN_WEIGHT_TONS) {
+ violations.push(
+ `Total booking weight ${totalWeightTons}T exceeds max train weight ${MAX_TRAIN_WEIGHT_TONS}T`,
+ );
+ }
+
+ if (totalLengthMeters > MAX_TRAIN_LENGTH_METERS) {
+ violations.push(
+ `Total wagon length ${totalLengthMeters}m exceeds max train length ${MAX_TRAIN_LENGTH_METERS}m`,
+ );
+ }
+
+ if (
+ wagonType.maxWagonsPerTrain != null &&
+ wagonPlan.length > Number(wagonType.maxWagonsPerTrain)
+ ) {
+ violations.push(
+ `Wagon count ${wagonPlan.length} exceeds wagon marshalling limit ${wagonType.maxWagonsPerTrain}`,
+ );
+ }
+
+ const availableLocomotiveCount = await this.dataSource
+ .getRepository(Locomotive)
+ .count({
+ where: { status: "AVAILABLE" as LocomotiveStatus },
+ });
+
+ if (availableLocomotiveCount === 0) {
+ violations.push("No available locomotive exists for scheduling");
+ } else {
+ const capableLocomotives = await this.dataSource
+ .getRepository(Locomotive)
+ .find({
+ where: { status: "AVAILABLE" },
+ });
+ const canPull = capableLocomotives.some(
+ (locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons,
+ );
+ if (!canPull) {
+ violations.push("No available locomotive can pull the total weight");
+ }
+ }
+
+ return {
+ valid: violations.length === 0,
+ violations,
+ bookings,
+ wagonType,
+ summary: {
+ totalBookings: bookings.length,
+ totalWeightTons,
+ wagonType: wagonType.code,
+ wagonsNeeded: wagonPlan.length,
+ totalLengthMeters,
+ },
+ wagonPlan,
+ };
+ }
+
+ calculateNW5WagonPlan(
+ totalBookingWeightTons: number,
+ wagonType: WagonType,
+ ): WagonPlanRecord[] {
+ const wagonCapacityTons = Number(wagonType.capacityTons);
+ const wagonsNeeded = Math.ceil(totalBookingWeightTons / wagonCapacityTons);
+ let remainingWeight = this.roundTons(totalBookingWeightTons);
+
+ return Array.from({ length: wagonsNeeded }, (_, index) => {
+ const assignedWeightTons = this.roundTons(
+ Math.min(wagonCapacityTons, remainingWeight),
+ );
+ remainingWeight = this.roundTons(
+ Math.max(0, remainingWeight - assignedWeightTons),
+ );
+
+ return {
+ sequenceNo: index + 1,
+ capacityTons: wagonCapacityTons,
+ lengthMeters: this.roundTons(Number(wagonType.lengthMeters)),
+ assignedWeightTons,
+ allocations: [],
+ };
+ });
+ }
+
+ async selectOrValidateLocomotive(
+ locomotiveId: string,
+ totalWeightTons: number,
+ ) {
+ const locomotive = await this.locomotivesRepository.findById(locomotiveId);
+
+ if (!locomotive) {
+ throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
+ }
+
+ if (locomotive.status !== "AVAILABLE") {
+ throw new BadRequestException(
+ `Locomotive ${locomotive.code} is not available`,
+ );
+ }
+
+ if (Number(locomotive.maxPullWeightTons) < totalWeightTons) {
+ throw new BadRequestException(
+ `Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`,
+ );
+ }
+
+ return locomotive;
+ }
+
+ async buildTrainSet(
+ manager: EntityManager,
+ locomotive: Locomotive,
+ wagonType: WagonType,
+ totalWeightTons: number,
+ totalLengthMeters: number,
+ wagonPlan: WagonPlanRecord[],
+ ) {
+ const trainSet = manager.getRepository(TrainSet).create({
+ locomotiveId: locomotive.id,
+ totalWeightTons,
+ totalLengthMeters,
+ wagonCount: wagonPlan.length,
+ status: "ASSIGNED",
+ });
+ const savedTrainSet = await manager.getRepository(TrainSet).save(trainSet);
+
+ const wagons = wagonPlan.map((wagon) =>
+ manager.getRepository(TrainSetWagon).create({
+ trainSetId: savedTrainSet.id,
+ wagonTypeId: wagonType.id,
+ sequenceNo: wagon.sequenceNo,
+ capacityTons: wagon.capacityTons,
+ lengthMeters: wagon.lengthMeters,
+ assignedWeightTons: wagon.assignedWeightTons,
+ }),
+ );
+
+ await manager.getRepository(TrainSetWagon).save(wagons);
+
+ return savedTrainSet;
+ }
+
+ allocateBookingsToWagons(
+ bookings: Booking[],
+ baseWagonPlan: WagonPlanRecord[],
+ ): WagonPlanRecord[] {
+ const remaining = bookings.map((booking) => ({
+ bookingId: booking.id,
+ bookingReference: booking.reference,
+ remainingWeightTons: this.roundTons(
+ Number(booking.cargoTotalWeightVgm ?? 0),
+ ),
+ }));
+ let bookingIndex = 0;
+
+ return baseWagonPlan.map((wagon) => {
+ let wagonRemaining = this.roundTons(wagon.capacityTons);
+ const allocations: WagonAllocationRecord[] = [];
+ let assignedWeightTons = 0;
+
+ while (wagonRemaining > 0 && bookingIndex < remaining.length) {
+ const booking = remaining[bookingIndex];
+ const allocatedWeightTons = this.roundTons(
+ Math.min(wagonRemaining, booking.remainingWeightTons),
+ );
+
+ if (allocatedWeightTons <= 0) {
+ bookingIndex += 1;
+ continue;
+ }
+
+ allocations.push({
+ bookingId: booking.bookingId,
+ bookingReference: booking.bookingReference,
+ allocatedWeightTons,
+ });
+ booking.remainingWeightTons = this.roundTons(
+ booking.remainingWeightTons - allocatedWeightTons,
+ );
+ wagonRemaining = this.roundTons(wagonRemaining - allocatedWeightTons);
+ assignedWeightTons = this.roundTons(
+ assignedWeightTons + allocatedWeightTons,
+ );
+
+ if (booking.remainingWeightTons <= 0) {
+ bookingIndex += 1;
+ }
+ }
+
+ return {
+ ...wagon,
+ assignedWeightTons,
+ allocations,
+ };
+ });
+ }
+
+ async getContainerTrainSchedules() {
+ const schedules = await this.dataSource.getRepository(TrainSchedule).find({
+ relations: {
+ trainSet: { locomotive: true },
+ originStation: true,
+ destinationStation: true,
+ scheduleBookings: true,
+ },
+ order: { scheduledDepartureDate: "DESC", createdAt: "DESC" },
+ });
+
+ return schedules.map((schedule) => ({
+ id: schedule.id,
+ scheduleDate: schedule.scheduledDepartureDate,
+ origin:
+ schedule.originStation?.label ?? schedule.originStation?.code ?? null,
+ destination:
+ schedule.destinationStation?.label ??
+ schedule.destinationStation?.code ??
+ null,
+ locomotive: schedule.trainSet?.locomotive
+ ? {
+ id: schedule.trainSet.locomotive.id,
+ code: schedule.trainSet.locomotive.code,
+ name: schedule.trainSet.locomotive.name ?? null,
+ }
+ : null,
+ wagonCount: schedule.trainSet?.wagonCount ?? 0,
+ totalWeightTons: this.roundTons(
+ Number(schedule.trainSet?.totalWeightTons ?? 0),
+ ),
+ totalLengthMeters: this.roundTons(
+ Number(schedule.trainSet?.totalLengthMeters ?? 0),
+ ),
+ bookingsCount: schedule.scheduleBookings?.length ?? 0,
+ status: schedule.status,
+ }));
+ }
+
+ async getContainerTrainScheduleById(id: string) {
+<<<<<<< HEAD
+ const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
+ where: { id },
+ relations: {
+ trainSet: { locomotive: true, wagons: { wagonType: true, allocations: { booking: true } } },
+ originStation: true,
+ destinationStation: true,
+ scheduleBookings: { booking: { company: true, originYard: true, destinationYard: true } },
+ },
+ });
+=======
+ const schedule = await this.dataSource
+ .getRepository(TrainSchedule)
+ .findOne({
+ where: { id },
+ relations: {
+ trainSet: {
+ locomotive: true,
+ wagons: { wagonType: true, allocations: { booking: true } },
+ },
+ originStation: true,
+ destinationStation: true,
+ scheduleBookings: {
+ booking: { company: true, originYard: true, destinationYard: true },
+ },
+ },
+ });
+>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
+
+ if (!schedule) {
+ throw new NotFoundException(`Train schedule ${id} not found`);
+ }
+
+ return {
+ id: schedule.id,
+ status: schedule.status,
+ scheduledDepartureDate: schedule.scheduledDepartureDate,
+ scheduledArrivalDate: schedule.scheduledArrivalDate,
+ originStation: schedule.originStation,
+ destinationStation: schedule.destinationStation,
+ trainSet: schedule.trainSet
+ ? {
+ id: schedule.trainSet.id,
+ status: schedule.trainSet.status,
+ wagonCount: schedule.trainSet.wagonCount,
+ totalWeightTons: this.roundTons(
+ Number(schedule.trainSet.totalWeightTons),
+ ),
+ totalLengthMeters: this.roundTons(
+ Number(schedule.trainSet.totalLengthMeters),
+ ),
+ locomotive: schedule.trainSet.locomotive
+ ? {
+ id: schedule.trainSet.locomotive.id,
+ code: schedule.trainSet.locomotive.code,
+ name: schedule.trainSet.locomotive.name,
+ status: schedule.trainSet.locomotive.status,
+ maxPullWeightTons: this.roundTons(
+ Number(schedule.trainSet.locomotive.maxPullWeightTons),
+ ),
+ }
+ : null,
+ wagons: [...(schedule.trainSet.wagons ?? [])]
+ .sort((left, right) => left.sequenceNo - right.sequenceNo)
+ .map((wagon) => ({
+ id: wagon.id,
+ sequenceNo: wagon.sequenceNo,
+ capacityTons: this.roundTons(Number(wagon.capacityTons)),
+ lengthMeters: this.roundTons(Number(wagon.lengthMeters)),
+ assignedWeightTons: this.roundTons(
+ Number(wagon.assignedWeightTons),
+ ),
+ wagonType: wagon.wagonType
+ ? {
+ id: wagon.wagonType.id,
+ code: wagon.wagonType.code,
+ name: wagon.wagonType.name,
+ }
+ : null,
+ allocations:
+ wagon.allocations?.map((allocation) => ({
+ id: allocation.id,
+ bookingId: allocation.bookingId,
+ bookingReference: allocation.booking?.reference ?? null,
+ allocatedWeightTons: this.roundTons(
+ Number(allocation.allocatedWeightTons),
+ ),
+ })) ?? [],
+ })),
+ }
+ : null,
+ bookings:
+ schedule.scheduleBookings?.map((scheduleBooking) => ({
+ id: scheduleBooking.booking?.id ?? scheduleBooking.bookingId,
+ reference: scheduleBooking.booking?.reference ?? null,
+ customer:
+ scheduleBooking.booking?.company?.name ??
+ scheduleBooking.booking?.company?.email ??
+ null,
+ weightTons: this.roundTons(
+ Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0),
+ ),
+ status: scheduleBooking.booking?.status ?? null,
+ })) ?? [],
+ };
+ }
+
+ async cancelTrainSchedule(id: string) {
+ const schedule = await this.dataSource
+ .getRepository(TrainSchedule)
+ .findOne({
+ where: { id },
+ relations: { trainSet: { locomotive: true } },
+ });
+
+ if (!schedule) {
+ throw new NotFoundException(`Train schedule ${id} not found`);
+ }
+
+ await this.dataSource.transaction(async (manager) => {
+ await manager.getRepository(TrainSchedule).update(schedule.id, {
+ status: "CANCELLED",
+ });
+
+ if (schedule.trainSetId) {
+ await manager.getRepository(TrainSet).update(schedule.trainSetId, {
+ status: "CANCELLED",
+ });
+ }
+
+ if (schedule.trainSet?.locomotiveId) {
+ await manager
+ .getRepository(Locomotive)
+ .update(schedule.trainSet.locomotiveId, {
+ status: "AVAILABLE",
+ });
+ }
+ });
+
+ return this.getContainerTrainScheduleById(id);
+ }
+
+ private async loadBookingsForScheduling(bookingIds: string[]) {
+ return this.dataSource.getRepository(Booking).find({
+ where: { id: In(bookingIds) },
+ relations: {
+ company: true,
+ originYard: true,
+ destinationYard: true,
+ bookingContainers: { containerType: true },
+ },
+ order: { createdAt: "ASC" },
+ });
+ }
+
+ private toUtcDateKey(value: Date | string) {
+ const date = value instanceof Date ? value : new Date(value);
+ return date.toISOString().slice(0, 10);
+ }
+
+ private roundTons(value: number | string | null | undefined) {
+ const numericValue = typeof value === "number" ? value : Number(value ?? 0);
+
+ if (!Number.isFinite(numericValue)) {
+ return 0;
+ }
+
+ return Number(numericValue.toFixed(3));
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts
new file mode 100644
index 000000000..780bc1977
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts
@@ -0,0 +1,39 @@
+import { BaseEntity } from '@edr/api-common';
+import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
+
+import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
+import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
+import { TrainSet } from './train-set.entity';
+
+@Entity({ schema: 'freight', name: 'train_set_wagons' })
+@Index(['trainSetId', 'sequenceNo'], { unique: true })
+export class TrainSetWagon extends BaseEntity {
+ @Column({ name: 'train_set_id', type: 'uuid' })
+ trainSetId!: string;
+
+ @ManyToOne(() => TrainSet, (trainSet) => trainSet.wagons, { onDelete: 'CASCADE' })
+ @JoinColumn({ name: 'train_set_id' })
+ trainSet?: TrainSet;
+
+ @Column({ name: 'wagon_type_id', type: 'uuid' })
+ wagonTypeId!: string;
+
+ @ManyToOne(() => WagonType, (wagonType) => wagonType.trainSetWagons)
+ @JoinColumn({ name: 'wagon_type_id' })
+ wagonType?: WagonType;
+
+ @Column({ name: 'sequence_no', type: 'int' })
+ sequenceNo!: number;
+
+ @Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3 })
+ capacityTons!: number;
+
+ @Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 })
+ lengthMeters!: number;
+
+ @Column({ name: 'assigned_weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 })
+ assignedWeightTons!: number;
+
+ @OneToMany(() => WagonBookingAllocation, (allocation) => allocation.trainSetWagon)
+ allocations?: WagonBookingAllocation[];
+}
diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts
new file mode 100644
index 000000000..9099824d5
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts
@@ -0,0 +1,46 @@
+import { BaseEntity } from '@edr/api-common';
+import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
+
+import { Locomotive } from '../../locomotives/entities/locomotive.entity';
+import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
+import { TrainSetWagon } from './train-set-wagon.entity';
+
+export const TRAIN_SET_STATUSES = [
+ 'DRAFT',
+ 'ASSIGNED',
+ 'DISPATCHED',
+ 'COMPLETED',
+ 'CANCELLED',
+] as const;
+
+export type TrainSetStatus = (typeof TRAIN_SET_STATUSES)[number];
+
+@Entity({ schema: 'freight', name: 'train_sets' })
+@Index(['locomotiveId'])
+@Index(['status'])
+export class TrainSet extends BaseEntity {
+ @Column({ name: 'locomotive_id', type: 'uuid' })
+ locomotiveId!: string;
+
+ @ManyToOne(() => Locomotive, (locomotive) => locomotive.trainSets)
+ @JoinColumn({ name: 'locomotive_id' })
+ locomotive?: Locomotive;
+
+ @Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 })
+ totalWeightTons!: number;
+
+ @Column({ name: 'total_length_meters', type: 'numeric', precision: 10, scale: 3 })
+ totalLengthMeters!: number;
+
+ @Column({ name: 'wagon_count', type: 'int' })
+ wagonCount!: number;
+
+ @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
+ status!: TrainSetStatus;
+
+ @OneToMany(() => TrainSetWagon, (wagon) => wagon.trainSet)
+ wagons?: TrainSetWagon[];
+
+ @OneToOne(() => TrainSchedule, (schedule) => schedule.trainSet)
+ trainSchedule?: TrainSchedule;
+}
diff --git a/apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts b/apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts
new file mode 100644
index 000000000..5296b04a6
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts
@@ -0,0 +1,16 @@
+import { BaseRepository } from '@edr/api-common';
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+
+import { TrainSetWagon } from './entities/train-set-wagon.entity';
+
+@Injectable()
+export class TrainSetWagonsRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(TrainSetWagon)
+ repository: Repository,
+ ) {
+ super(repository);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts
new file mode 100644
index 000000000..f11052727
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts
@@ -0,0 +1,14 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+
+import { TrainSet } from './entities/train-set.entity';
+import { TrainSetWagon } from './entities/train-set-wagon.entity';
+import { TrainSetWagonsRepository } from './train-set-wagons.repository';
+import { TrainSetsRepository } from './train-sets.repository';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon])],
+ providers: [TrainSetsRepository, TrainSetWagonsRepository],
+ exports: [TrainSetsRepository, TrainSetWagonsRepository],
+})
+export class TrainSetsModule {}
diff --git a/apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts b/apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts
new file mode 100644
index 000000000..a6cadbf2d
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts
@@ -0,0 +1,16 @@
+import { BaseRepository } from '@edr/api-common';
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+
+import { TrainSet } from './entities/train-set.entity';
+
+@Injectable()
+export class TrainSetsRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(TrainSet)
+ repository: Repository,
+ ) {
+ super(repository);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/trains/dto/create-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/create-train.dto.ts
index f41dfa275..f166254a9 100644
--- a/apps/edr-freight-api/src/modules/trains/dto/create-train.dto.ts
+++ b/apps/edr-freight-api/src/modules/trains/dto/create-train.dto.ts
@@ -1,5 +1,5 @@
-import { Freight } from "@edr/types";
-import { IsEnum, IsNumber, IsOptional, IsString, Min } from "class-validator";
+import { IsString, IsNumber, IsOptional, IsUUID, IsDateString, Min, IsEnum } from 'class-validator';
+import { Freight } from '@edr/types';
export class CreateTrainDto {
@IsString()
@@ -11,9 +11,45 @@ export class CreateTrainDto {
@IsOptional()
@IsEnum(Freight.TrainStatus)
- status?: Freight.TrainStatus;
+ status?: Freight.TrainStatus; // ✅ uses enum, not string
@IsOptional()
@IsString()
notes?: string;
-}
+
+ @IsOptional()
+ @IsString()
+ trainNumber?: string;
+
+ @IsOptional()
+ @IsString()
+ trainName?: string;
+
+ @IsOptional()
+ @IsUUID()
+ routeId?: string;
+
+ @IsOptional()
+ @IsUUID()
+ originStationId?: string;
+
+ @IsOptional()
+ @IsUUID()
+ destinationStationId?: string;
+
+ @IsOptional()
+ @IsDateString()
+ departureTime?: string;
+
+ @IsOptional()
+ @IsDateString()
+ arrivalTime?: string;
+
+ @IsOptional()
+ @IsString()
+ locomotiveNumber?: string;
+
+ @IsOptional()
+ @IsString()
+ remarks?: string;
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/trains/dto/update-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/update-train.dto.ts
new file mode 100644
index 000000000..cbd36eed9
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/trains/dto/update-train.dto.ts
@@ -0,0 +1,4 @@
+import { PartialType } from '@nestjs/swagger';
+import { CreateTrainDto } from './create-train.dto';
+
+export class UpdateTrainDto extends PartialType(CreateTrainDto) {}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts
index c12478ec2..184b9c88d 100644
--- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts
+++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts
@@ -1,23 +1,58 @@
-import { BaseEntity } from "@edr/api-common";
-import { Freight } from "@edr/types";
-import { Column, Entity } from "typeorm";
+// apps/edr-freight-api/src/modules/trains/entities/train.entity.ts
+import { BaseEntity } from '@edr/api-common';
+import { Freight } from '@edr/types';
+import { Column, Entity, OneToMany } from 'typeorm';
+import { Wagon } from '../../wagons/entities/wagon.entity';
-@Entity({ schema:"freight",name: "trains" })
+@Entity({ schema: 'freight', name: 'trains' })
export class Train extends BaseEntity {
- @Column({ name: "code", type: "varchar", length: 32, unique: true })
+ // --- existing fields (keep for backward compatibility) ---
+ @Column({ name: 'code', type: 'varchar', length: 32, unique: true })
code!: string;
- @Column({ name: "capacity_tons", type: "numeric", precision: 10, scale: 2 })
+ @Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 2 })
capacityTons!: number;
@Column({
- name: "status",
- type: "enum",
+ name: 'status',
+ type: 'enum',
enum: Freight.TrainStatus,
default: Freight.TrainStatus.Available,
})
status!: Freight.TrainStatus;
- @Column({ name: "notes", type: "text", nullable: true })
+ @Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
-}
+
+ // --- new required fields ---
+ @Column({ name: 'train_number', type: 'varchar', length: 20, unique: true, nullable: true })
+ trainNumber?: string;
+
+ @Column({ name: 'train_name', type: 'varchar', length: 100, nullable: true })
+ trainName?: string;
+
+ @Column({ name: 'route_id', type: 'uuid', nullable: true })
+ routeId?: string;
+
+ @Column({ name: 'origin_station_id', type: 'uuid', nullable: true })
+ originStationId?: string;
+
+ @Column({ name: 'destination_station_id', type: 'uuid', nullable: true })
+ destinationStationId?: string;
+
+ @Column({ name: 'departure_time', type: 'timestamp', nullable: true })
+ departureTime?: Date;
+
+ @Column({ name: 'arrival_time', type: 'timestamp', nullable: true })
+ arrivalTime?: Date;
+
+ @Column({ name: 'locomotive_number', type: 'varchar', length: 50, nullable: true })
+ locomotiveNumber?: string;
+
+ @Column({ name: 'remarks', type: 'text', nullable: true })
+ remarks?: string;
+
+ // --- relationships ---
+ @OneToMany(() => Wagon, (wagon) => wagon.train)
+ wagons!: Wagon[]; // fixed typo: was 'wagens'
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/trains/trains.module.ts b/apps/edr-freight-api/src/modules/trains/trains.module.ts
index 094120f33..61098ff40 100644
--- a/apps/edr-freight-api/src/modules/trains/trains.module.ts
+++ b/apps/edr-freight-api/src/modules/trains/trains.module.ts
@@ -1,15 +1,14 @@
-import { Module } from "@nestjs/common";
-import { TypeOrmModule } from "@nestjs/typeorm";
-
-import { Train } from "./entities/train.entity";
-import { TrainsController } from "./trains.controller";
-import { TrainsRepository } from "./trains.repository";
-import { TrainsService } from "./trains.service";
+// apps/edr-freight-api/src/modules/trains/trains.module.ts
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { Train } from './entities/train.entity';
+import { TrainsController } from './trains.controller';
+import { TrainsService } from './trains.service';
@Module({
imports: [TypeOrmModule.forFeature([Train])],
controllers: [TrainsController],
- providers: [TrainsService, TrainsRepository],
- exports: [TrainsService],
+ providers: [TrainsService],
+ exports: [TrainsService], // if other modules need it
})
-export class TrainsModule {}
+export class TrainsModule {}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/trains/trains.service.ts b/apps/edr-freight-api/src/modules/trains/trains.service.ts
index db689a19a..12aa65afe 100644
--- a/apps/edr-freight-api/src/modules/trains/trains.service.ts
+++ b/apps/edr-freight-api/src/modules/trains/trains.service.ts
@@ -1,29 +1,41 @@
-import { Injectable, NotFoundException } from "@nestjs/common";
-
-import { CreateTrainDto } from "./dto/create-train.dto";
-import { Train } from "./entities/train.entity";
-import { TrainsRepository } from "./trains.repository";
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { CreateTrainDto } from './dto/create-train.dto';
+import { UpdateTrainDto } from './dto/update-train.dto';
+import { Train } from './entities/train.entity';
@Injectable()
export class TrainsService {
- constructor(private readonly trainsRepository: TrainsRepository) {}
+ constructor(
+ @InjectRepository(Train)
+ private readonly trainRepo: Repository,
+ ) {}
- /** Register a new train in the fleet. */
create(dto: CreateTrainDto): Promise {
- return this.trainsRepository.create(dto);
+ const train = this.trainRepo.create(dto);
+ return this.trainRepo.save(train);
}
- /** List every active train. */
findAll(): Promise {
- return this.trainsRepository.findAll({ order: { code: "ASC" } });
+ return this.trainRepo.find({ order: { code: 'ASC' } });
}
- /** Get a single train by ID. */
async findById(id: string): Promise {
- const train = await this.trainsRepository.findById(id);
- if (!train) {
- throw new NotFoundException(`Train ${id} not found`);
- }
+ const train = await this.trainRepo.findOne({ where: { id } });
+ if (!train) throw new NotFoundException(`Train ${id} not found`);
return train;
}
-}
+
+ async update(id: string, dto: UpdateTrainDto): Promise {
+ const train = await this.findById(id);
+ Object.assign(train, dto);
+ // Convert undefined to null for optional fields if needed
+ return this.trainRepo.save(train);
+ }
+
+ async remove(id: string): Promise {
+ const train = await this.findById(id);
+ await this.trainRepo.remove(train);
+ }
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts
new file mode 100644
index 000000000..f1bfeedea
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts
@@ -0,0 +1,33 @@
+import { BaseEntity } from '@edr/api-common';
+import { Column, Entity, Index, OneToMany } from 'typeorm';
+
+import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
+
+@Entity({ schema: 'freight', name: 'wagon_types' })
+@Index(['code'])
+@Index(['isActive'])
+export class WagonType extends BaseEntity {
+ @Column({ name: 'code', type: 'varchar', length: 32, unique: true })
+ code!: string;
+
+ @Column({ name: 'name', type: 'varchar', length: 100 })
+ name!: string;
+
+ @Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3 })
+ capacityTons!: number;
+
+ @Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 })
+ lengthMeters!: number;
+
+ @Column({ name: 'max_wagons_per_train', type: 'int', nullable: true })
+ maxWagonsPerTrain?: number | null;
+
+ @Column({ name: 'supported_load_types', type: 'text', array: true, default: '{}' })
+ supportedLoadTypes!: string[];
+
+ @Column({ name: 'is_active', type: 'boolean', default: true })
+ isActive!: boolean;
+
+ @OneToMany(() => TrainSetWagon, (wagon) => wagon.wagonType)
+ trainSetWagons?: TrainSetWagon[];
+}
diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts
new file mode 100644
index 000000000..3cb23cc3c
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts
@@ -0,0 +1,13 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+
+import { WagonType } from './entities/wagon-type.entity';
+import { WagonTypesRepository } from './wagon-types.repository';
+import { WagonTypesService } from './wagon-types.service';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([WagonType])],
+ providers: [WagonTypesRepository, WagonTypesService],
+ exports: [WagonTypesRepository, WagonTypesService],
+})
+export class WagonTypesModule {}
diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts
new file mode 100644
index 000000000..001ff0212
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts
@@ -0,0 +1,16 @@
+import { BaseRepository } from '@edr/api-common';
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+
+import { WagonType } from './entities/wagon-type.entity';
+
+@Injectable()
+export class WagonTypesRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(WagonType)
+ repository: Repository,
+ ) {
+ super(repository);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts
new file mode 100644
index 000000000..4e15937e0
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts
@@ -0,0 +1,19 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+
+import { WagonType } from './entities/wagon-type.entity';
+import { WagonTypesRepository } from './wagon-types.repository';
+
+@Injectable()
+export class WagonTypesService {
+ constructor(private readonly wagonTypesRepository: WagonTypesRepository) {}
+
+ async findByCode(code: string): Promise {
+ const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } });
+
+ if (!wagonType) {
+ throw new NotFoundException(`Wagon type ${code} not found`);
+ }
+
+ return wagonType;
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/wagons/dto/assign-wagon-to-train.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/assign-wagon-to-train.dto.ts
new file mode 100644
index 000000000..66a837e68
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/wagons/dto/assign-wagon-to-train.dto.ts
@@ -0,0 +1,11 @@
+import { IsUUID, IsOptional, IsInt, Min } from 'class-validator';
+
+export class AssignWagonToTrainDto {
+ @IsUUID()
+ trainId!: string;
+
+ @IsOptional()
+ @IsInt()
+ @Min(1)
+ sequenceNumber?: number;
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts
new file mode 100644
index 000000000..c3108d68b
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts
@@ -0,0 +1,34 @@
+import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsIn } from 'class-validator';
+
+export class CreateWagonDto {
+ @IsString()
+ wagonNumber!: string;
+
+ @IsUUID()
+ wagonTypeId!: string;
+
+ @IsOptional()
+ @IsUUID()
+ trainId?: string;
+
+ @IsOptional()
+ @IsInt()
+ @Min(1)
+ sequenceNumber?: number;
+
+ @IsNumber()
+ @Min(0)
+ tareWeight!: number;
+
+ @IsNumber()
+ @Min(0)
+ maxPayloadWeight!: number;
+
+ @IsOptional()
+ @IsIn(['AVAILABLE', 'ASSIGNED', 'MAINTENANCE', 'RETIRED'])
+ status?: string;
+
+ @IsOptional()
+ @IsString()
+ notes?: string;
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/wagons/dto/reorder-wagons.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/reorder-wagons.dto.ts
new file mode 100644
index 000000000..0395adb8f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/wagons/dto/reorder-wagons.dto.ts
@@ -0,0 +1,7 @@
+import { IsArray, IsUUID } from 'class-validator';
+
+export class ReorderWagonsDto {
+ @IsArray()
+ @IsUUID(4, { each: true })
+ wagonIds!: string[];
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/wagons/dto/update-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/update-wagon.dto.ts
new file mode 100644
index 000000000..3414d1f2c
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/wagons/dto/update-wagon.dto.ts
@@ -0,0 +1,4 @@
+import { PartialType } from '@nestjs/swagger';
+import { CreateWagonDto } from './create-wagon.dto';
+
+export class UpdateWagonDto extends PartialType(CreateWagonDto) {}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts
new file mode 100644
index 000000000..cdff14330
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts
@@ -0,0 +1,41 @@
+// apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts
+import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
+import { BaseEntity } from '@edr/api-common';
+import { Train } from '../../trains/entities/train.entity';
+import { Container } from '../../container-management/entities/container.entity';
+
+@Entity({ name: 'wagons', schema: 'freight' })
+export class Wagon extends BaseEntity {
+ @Column({ unique: true, name: 'wagon_number' })
+ wagonNumber!: string;
+
+ @Column({ name: 'wagon_type_id', type: 'uuid' })
+ wagonTypeId!: string;
+
+ @Column({ name: 'train_id', type: 'uuid', nullable: true })
+ trainId!: string | null;
+
+ @Column({ name: 'sequence_number', type: 'int', nullable: true })
+ sequenceNumber!: number | null;
+
+ @Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 })
+ tareWeight!: number;
+
+ @Column({ name: 'max_payload_weight', type: 'decimal', precision: 10, scale: 2 })
+ maxPayloadWeight!: number;
+
+ @Column({ type: 'varchar', default: 'AVAILABLE' })
+ status!: string; // AVAILABLE, ASSIGNED, MAINTENANCE, RETIRED
+
+ @Column({ type: 'text', nullable: true })
+ notes!: string | null;
+
+ // Relationship to Train
+ @ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' })
+ @JoinColumn({ name: 'train_id' })
+ train!: Train | null;
+
+ // Relationship to Container
+ @OneToMany(() => Container, (container) => container.wagon)
+ containers!: Container[];
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts
new file mode 100644
index 000000000..339948eec
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts
@@ -0,0 +1,76 @@
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ Param,
+ ParseUUIDPipe,
+ Patch,
+ Post,
+} from '@nestjs/common';
+import { ApiOperation, ApiTags } from '@nestjs/swagger';
+import { CreateWagonDto } from './dto/create-wagon.dto';
+import { UpdateWagonDto } from './dto/update-wagon.dto';
+import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
+import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
+import { WagonsService } from './wagons.service';
+
+@ApiTags('wagons')
+@Controller('wagons')
+export class WagonsController {
+ constructor(private readonly wagonsService: WagonsService) {}
+
+ @Post()
+ @ApiOperation({ summary: 'Create a new wagon' })
+ create(@Body() dto: CreateWagonDto) {
+ return this.wagonsService.create(dto);
+ }
+
+ @Get()
+ @ApiOperation({ summary: 'List all wagons' })
+ findAll() {
+ return this.wagonsService.findAll();
+ }
+
+ @Get(':id')
+ @ApiOperation({ summary: 'Get a wagon by ID' })
+ findOne(@Param('id', ParseUUIDPipe) id: string) {
+ return this.wagonsService.findById(id);
+ }
+
+ @Patch(':id')
+ @ApiOperation({ summary: 'Update a wagon' })
+ update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) {
+ return this.wagonsService.update(id, dto);
+ }
+
+ @Delete(':id')
+ @ApiOperation({ summary: 'Delete a wagon' })
+ remove(@Param('id', ParseUUIDPipe) id: string) {
+ return this.wagonsService.remove(id);
+ }
+
+ @Post(':id/assign-train')
+ @ApiOperation({ summary: 'Assign wagon to a train' })
+ assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) {
+ return this.wagonsService.assignToTrain(id, dto);
+ }
+
+ @Post(':id/unassign-train')
+ @ApiOperation({ summary: 'Unassign wagon from train' })
+ unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
+ return this.wagonsService.unassignFromTrain(id);
+ }
+}
+
+// Separate controller for train‑specific reorder (registered in module)
+@Controller('trains/:trainId/reorder-wagons')
+export class TrainWagonsReorderController {
+ constructor(private readonly wagonsService: WagonsService) {}
+
+ @Post()
+ @ApiOperation({ summary: 'Reorder wagons of a train' })
+ reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) {
+ return this.wagonsService.reorderWagons(trainId, dto);
+ }
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts
new file mode 100644
index 000000000..914de4cbd
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts
@@ -0,0 +1,14 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { Wagon } from './entities/wagon.entity';
+import { Train } from '../trains/entities/train.entity';
+import { WagonsController, TrainWagonsReorderController } from './wagons.controller';
+import { WagonsService } from './wagons.service';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([Wagon, Train])],
+ controllers: [WagonsController, TrainWagonsReorderController],
+ providers: [WagonsService],
+ exports: [WagonsService],
+})
+export class WagonsModule {}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.repository.ts b/apps/edr-freight-api/src/modules/wagons/wagons.repository.ts
new file mode 100644
index 000000000..f0e12842e
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/wagons/wagons.repository.ts
@@ -0,0 +1,15 @@
+import { BaseRepository } from '@edr/api-common';
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { Wagon } from './entities/wagon.entity';
+
+@Injectable()
+export class WagonsRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(Wagon)
+ repository: Repository,
+ ) {
+ super(repository);
+ }
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts
new file mode 100644
index 000000000..70909cbd8
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts
@@ -0,0 +1,101 @@
+import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository, DataSource } from 'typeorm';
+import { CreateWagonDto } from './dto/create-wagon.dto';
+import { UpdateWagonDto } from './dto/update-wagon.dto';
+import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
+import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
+import { Wagon } from './entities/wagon.entity';
+import { Train } from '../trains/entities/train.entity';
+
+@Injectable()
+export class WagonsService {
+ constructor(
+ @InjectRepository(Wagon)
+ private readonly wagonRepo: Repository,
+ @InjectRepository(Train)
+ private readonly trainRepo: Repository,
+ private readonly dataSource: DataSource,
+ ) {}
+
+ async create(dto: CreateWagonDto): Promise {
+ const wagon = this.wagonRepo.create(dto);
+ // Convert undefined to null for nullable fields
+ if (dto.trainId === undefined) wagon.trainId = null;
+ if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null;
+ return this.wagonRepo.save(wagon);
+ }
+
+ async findAll(): Promise {
+ return this.wagonRepo.find({ order: { wagonNumber: 'ASC' } });
+ }
+
+ async findById(id: string): Promise {
+ const wagon = await this.wagonRepo.findOne({ where: { id } });
+ if (!wagon) throw new NotFoundException(`Wagon ${id} not found`);
+ return wagon;
+ }
+
+ async update(id: string, dto: UpdateWagonDto): Promise {
+ const wagon = await this.findById(id);
+ Object.assign(wagon, dto);
+ if (dto.trainId === undefined) wagon.trainId = null;
+ if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null;
+ return this.wagonRepo.save(wagon);
+ }
+
+ async remove(id: string): Promise {
+ const wagon = await this.findById(id);
+ await this.wagonRepo.remove(wagon);
+ }
+
+ async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise {
+ const wagon = await this.findById(wagonId);
+ if (wagon.status === 'ASSIGNED') {
+ throw new ConflictException('Wagon already assigned to a train');
+ }
+
+ const train = await this.trainRepo.findOne({ where: { id: dto.trainId } });
+ if (!train) throw new NotFoundException('Train not found');
+
+ let sequence: number | null = dto.sequenceNumber ?? null;
+ if (sequence === null) {
+ const maxSeq = await this.wagonRepo
+ .createQueryBuilder('w')
+ .select('MAX(w.sequenceNumber)', 'max')
+ .where('w.trainId = :trainId', { trainId: train.id })
+ .getRawOne();
+ sequence = (maxSeq?.max ?? 0) + 1;
+ }
+
+ wagon.trainId = train.id;
+ wagon.sequenceNumber = sequence;
+ wagon.status = 'ASSIGNED';
+ return this.wagonRepo.save(wagon);
+ }
+
+ async unassignFromTrain(wagonId: string): Promise {
+ const wagon = await this.findById(wagonId);
+ wagon.trainId = null;
+ wagon.sequenceNumber = null;
+ wagon.status = 'AVAILABLE';
+ return this.wagonRepo.save(wagon);
+ }
+
+ async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise {
+ const queryRunner = this.dataSource.createQueryRunner();
+ await queryRunner.connect();
+ await queryRunner.startTransaction();
+ try {
+ for (let i = 0; i < dto.wagonIds.length; i++) {
+ await queryRunner.manager.update(Wagon, dto.wagonIds[i], { sequenceNumber: i + 1 });
+ }
+ await queryRunner.commitTransaction();
+ } catch (err) {
+ await queryRunner.rollbackTransaction();
+ throw err;
+ } finally {
+ await queryRunner.release();
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts
new file mode 100644
index 000000000..da835dabd
--- /dev/null
+++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts
@@ -0,0 +1,324 @@
+import { Injectable, Logger } from "@nestjs/common";
+import { randomUUID } from "crypto";
+import { DataSource } from "typeorm";
+
+<<<<<<< HEAD
+import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
+import { Booking } from '../modules/bookings/entities/booking.entity';
+import { Company } from '../modules/companies/entities/company.entity';
+import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
+import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
+import { Yard } from '../modules/rule-engine/entities/yard.entity';
+import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
+import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
+=======
+import { BookingContainer } from "../modules/bookings/entities/booking-container.entity";
+import { Booking } from "../modules/bookings/entities/booking.entity";
+import { Customer } from "../modules/customers/entities/customer.entity";
+import { Locomotive } from "../modules/locomotives/entities/locomotive.entity";
+import { ServiceType } from "../modules/rule-engine/entities/service-type.entity";
+import { Yard } from "../modules/rule-engine/entities/yard.entity";
+import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity";
+import { ContainerType } from "../modules/rule-engine/entities/container-type.entity";
+>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
+
+const SEED_FLAG = "SEED_DEMO_BOOKINGS";
+
+const SERVICE_TYPE_CODE = "RAIL_CONTAINER";
+const CUSTOMER_EMAIL = "train-scheduling-demo@edr.local";
+
+const YARDS = [
+ { code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 },
+ {
+ code: "ADDIS_ABABA",
+ label: "Addis Ababa",
+ country: "Ethiopia",
+ displayOrder: 2,
+ },
+ {
+ code: "DIRE_DAWA",
+ label: "Dire Dawa",
+ country: "Ethiopia",
+ displayOrder: 3,
+ },
+];
+
+const CONTAINER_TYPES = [
+ { code: "20FT", label: "20FT", sizeFt: 20 },
+ { code: "40FT", label: "40FT", sizeFt: 40 },
+];
+
+const DEMO_BOOKINGS = [
+ {
+ reference: "BKG-CONT-001",
+ containerCode: "40FT",
+ quantity: 20,
+ totalWeightTons: 500,
+ originCode: "DJIBOUTI",
+ destinationCode: "ADDIS_ABABA",
+ scheduledDate: "2026-06-20T08:00:00.000Z",
+ },
+ {
+ reference: "BKG-CONT-002",
+ containerCode: "20FT",
+ quantity: 10,
+ totalWeightTons: 300,
+ originCode: "DJIBOUTI",
+ destinationCode: "ADDIS_ABABA",
+ scheduledDate: "2026-06-20T08:00:00.000Z",
+ },
+ {
+ reference: "BKG-CONT-003",
+ containerCode: "40FT",
+ quantity: 15,
+ totalWeightTons: 450,
+ originCode: "DJIBOUTI",
+ destinationCode: "ADDIS_ABABA",
+ scheduledDate: "2026-06-20T08:00:00.000Z",
+ },
+ {
+ reference: "BKG-CONT-007",
+ containerCode: "20FT",
+ quantity: 6,
+ totalWeightTons: 180,
+ originCode: "DJIBOUTI",
+ destinationCode: "ADDIS_ABABA",
+ scheduledDate: "2026-06-20T08:00:00.000Z",
+ },
+ {
+ reference: "BKG-CONT-004",
+ containerCode: "40FT",
+ quantity: 12,
+ totalWeightTons: 360,
+ originCode: "ADDIS_ABABA",
+ destinationCode: "DIRE_DAWA",
+ scheduledDate: "2026-06-20T08:00:00.000Z",
+ },
+ {
+ reference: "BKG-CONT-005",
+ containerCode: "20FT",
+ quantity: 8,
+ totalWeightTons: 160,
+ originCode: "DJIBOUTI",
+ destinationCode: "ADDIS_ABABA",
+ scheduledDate: "2026-06-21T08:00:00.000Z",
+ },
+ {
+ reference: "BKG-CONT-006",
+ containerCode: "40FT",
+ quantity: 80,
+ totalWeightTons: 3600,
+ originCode: "DJIBOUTI",
+ destinationCode: "ADDIS_ABABA",
+ scheduledDate: "2026-06-20T08:00:00.000Z",
+ },
+];
+
+@Injectable()
+export class DemoBookingsSeeder {
+ private readonly logger = new Logger(DemoBookingsSeeder.name);
+
+ constructor(private readonly dataSource: DataSource) { }
+
+ async run() {
+ const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true";
+ if (!shouldSeed) {
+ this.logger.log(
+ `Skipping demo booking seed because ${SEED_FLAG} is not enabled`,
+ );
+ return;
+ }
+
+ await this.dataSource.transaction(async (manager) => {
+ await manager.getRepository(WagonType).upsert(
+ {
+ code: "NW5",
+ name: "Flat Wagon",
+ capacityTons: 70,
+ lengthMeters: 14,
+ maxWagonsPerTrain: 53,
+ supportedLoadTypes: ["CONTAINER"],
+ isActive: true,
+ },
+ { conflictPaths: { code: true } },
+ );
+
+ await manager.getRepository(Locomotive).upsert(
+ [
+ {
+ code: "LOC-001",
+ name: "Demo Locomotive 1",
+ maxPullWeightTons: 3500,
+ status: "AVAILABLE",
+ },
+ {
+ code: "LOC-002",
+ name: "Demo Locomotive 2",
+ maxPullWeightTons: 2500,
+ status: "AVAILABLE",
+ },
+ ],
+ { conflictPaths: { code: true } },
+ );
+
+ await manager.getRepository(Yard).upsert(
+ YARDS.map((yard) => ({ ...yard, isActive: true })),
+ { conflictPaths: { code: true } },
+ );
+
+ await manager.getRepository(ServiceType).upsert(
+ {
+ code: SERVICE_TYPE_CODE,
+ serviceName: "Rail Container Service",
+ description: "Temporary service type for train scheduling demos",
+ canBeBookedAlone: true,
+ includesFirstMile: false,
+ includesLastMile: false,
+ includesCustoms: false,
+ priorityBonusPoints: 0,
+ isActive: true,
+ displayOrder: 1,
+ },
+ { conflictPaths: { code: true } },
+ );
+
+ await manager.getRepository(ContainerType).upsert(
+ CONTAINER_TYPES.map((containerType, index) => ({
+ ...containerType,
+ wagonsPerUnit: 1,
+ isReefer: false,
+ isOpenTop: false,
+ isActive: true,
+ displayOrder: index + 1,
+ })),
+ { conflictPaths: { code: true } },
+ );
+
+ await manager.getRepository(Customer).upsert(
+ {
+ userId: "00000000-0000-0000-0000-000000000111",
+ firstName: "Train",
+ lastName: "Scheduling",
+ email: CUSTOMER_EMAIL,
+ phone: "251900000001",
+ companyName: "Train Scheduling Demo Customer",
+ companyEmail: CUSTOMER_EMAIL,
+ companyPhone: "251900000001",
+ companyLocation: "Addis Ababa",
+ companyAddress: "Demo Address",
+ customerType: "DEMO",
+ status: "ACTIVE",
+ contactPersonName: "Train Scheduling",
+ contactPersonPhone: "251900000001",
+ tinNumber: "1234567890",
+ vatNumber: "1234567890",
+ fanNumber: "1234567890123456",
+ generalManagerName: "Demo Manager",
+ generalManagerEmail: CUSTOMER_EMAIL,
+ generalManagerPhone: "251900000001",
+ },
+ { conflictPaths: { email: true } },
+ );
+
+<<<<<<< HEAD
+ const [serviceType, company, yards, containerTypes] = await Promise.all([
+ manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }),
+ manager.getRepository(Customer).findOneByOrFail({ email: CUSTOMER_EMAIL }),
+=======
+ const [serviceType, customer, yards, containerTypes] = await Promise.all([
+ manager
+ .getRepository(ServiceType)
+ .findOneByOrFail({ code: SERVICE_TYPE_CODE }),
+ manager
+ .getRepository(Customer)
+ .findOneByOrFail({ email: CUSTOMER_EMAIL }),
+>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
+ manager.getRepository(Yard).find(),
+ manager.getRepository(ContainerType).find(),
+ ]);
+
+ const yardByCode = new Map(yards.map((yard) => [yard.code, yard]));
+ const containerTypeByCode = new Map(
+ containerTypes.map((containerType) => [
+ containerType.code,
+ containerType,
+ ]),
+ );
+
+ for (const demoBooking of DEMO_BOOKINGS) {
+ const origin = yardByCode.get(demoBooking.originCode);
+ const destination = yardByCode.get(demoBooking.destinationCode);
+ const containerType = containerTypeByCode.get(
+ demoBooking.containerCode,
+ );
+
+ if (!origin || !destination || !containerType) {
+ throw new Error(
+ `demo_booking_seed_dependency_missing:${demoBooking.reference}`,
+ );
+ }
+
+ const vgmPerUnitTons =
+ demoBooking.totalWeightTons / demoBooking.quantity;
+
+ await manager.getRepository(Booking).upsert(
+ {
+ reference: demoBooking.reference,
+<<<<<<< HEAD
+ companyId: company.id,
+ status: 'APPROVED',
+=======
+ companyId: customer.id,
+ status: "APPROVED",
+>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
+ scheduledDate: new Date(demoBooking.scheduledDate),
+ totalAmount: 0,
+ paymentStatus: "PENDING",
+ contractType: "NEW",
+ serviceTypeId: serviceType.id,
+ equipmentReturn: "WITHOUT_RETURN",
+ originYardId: origin.id,
+ destinationYardId: destination.id,
+ tradeDirection: "IMPORT",
+ freightType: "CONTAINER",
+ cargoTypeId: null,
+ cargoFreeText: null,
+ shippingLineId: null,
+ cargoTotalWeightVgm: demoBooking.totalWeightTons,
+ isHazardous: false,
+ paymentCurrency: "USD",
+ allowConsolidation: false,
+ priorityScore: 0,
+ versionNumber: 1,
+ },
+ { conflictPaths: { reference: true } },
+ );
+
+ const booking = await manager.getRepository(Booking).findOneByOrFail({
+ reference: demoBooking.reference,
+ });
+
+ await manager
+ .getRepository(BookingContainer)
+ .delete({ bookingId: booking.id });
+ await manager.getRepository(BookingContainer).insert({
+ id: randomUUID(),
+ bookingId: booking.id,
+ containerTypeId: containerType.id,
+ quantity: demoBooking.quantity,
+ vgmPerUnitTons,
+ totalVgmTons: demoBooking.totalWeightTons,
+ wagonsRequired: Math.ceil(demoBooking.totalWeightTons / 70),
+ weightLimitRuleId: null,
+ isOverweight: demoBooking.totalWeightTons > 70,
+ overweightExcessTons:
+ demoBooking.totalWeightTons > 70
+ ? demoBooking.totalWeightTons - 70
+ : null,
+ });
+ }
+ });
+
+ this.logger.log("Seeded demo train scheduling data");
+ }
+}
diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts
new file mode 100644
index 000000000..2ef1f79f0
--- /dev/null
+++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts
@@ -0,0 +1,125 @@
+import { Injectable, Logger } from "@nestjs/common";
+import { DataSource } from "typeorm";
+
+import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
+import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
+
+const COMPANY_ONBOARDING_DOCUMENTS = [
+ {
+ code: "company_onboarding_documents_customer",
+ label: "Customer onboarding documents",
+ entity: "customer",
+ },
+ {
+ code: "company_onboarding_documents_forwarder",
+ label: "Forwarder onboarding documents",
+ entity: "other",
+ },
+ {
+ code: "company_onboarding_documents_transporter",
+ label: "Transporter onboarding documents",
+ entity: "other",
+ },
+ {
+ code: "company_onboarding_documents_forwarder_dj",
+ label: "Djibouti forwarder onboarding documents",
+ entity: "other",
+ },
+] as const;
+
+const COMPANY_ONBOARDING_DESCRIPTION =
+ "Required documents for external company onboarding. The same set applies to customers, forwarders, transporters, and brokers.";
+
+const COMPANY_ONBOARDING_FIELDS = [
+ {
+ fileKey: "business_license",
+ fileLabel: "Business License / Trade License",
+ helpText: "Verified against the government trade system during registration.",
+ isRequired: true,
+ isMultiple: false,
+ maxFiles: 1,
+ allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
+ maxSizeMb: 10,
+ displayOrder: 1,
+ },
+ {
+ fileKey: "tin_certificate",
+ fileLabel: "TIN Certificate",
+ helpText: "Verified against the TIN registry during registration.",
+ isRequired: true,
+ isMultiple: false,
+ maxFiles: 1,
+ allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
+ maxSizeMb: 10,
+ displayOrder: 2,
+ },
+ {
+ fileKey: "national_id_passport",
+ fileLabel: "National ID / Passport",
+ helpText: "Verified against the National ID API during registration.",
+ isRequired: true,
+ isMultiple: false,
+ maxFiles: 1,
+ allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
+ maxSizeMb: 10,
+ displayOrder: 3,
+ },
+] as const;
+
+@Injectable()
+export class FileUploadSettingsSeeder {
+ private readonly logger = new Logger(FileUploadSettingsSeeder.name);
+
+ constructor(private readonly dataSource: DataSource) {}
+
+ async run() {
+ await this.dataSource.transaction(async (manager) => {
+ const settingRepository = manager.getRepository(FileUploadSetting);
+ const fieldRepository = manager.getRepository(FileUploadField);
+
+ for (const documentSetting of COMPANY_ONBOARDING_DOCUMENTS) {
+ await settingRepository.upsert(
+ {
+ code: documentSetting.code,
+ label: documentSetting.label,
+ description: COMPANY_ONBOARDING_DESCRIPTION,
+ entity: documentSetting.entity,
+ },
+ {
+ conflictPaths: { code: true },
+ },
+ );
+
+ const setting = await settingRepository.findOne({
+ where: { code: documentSetting.code },
+ select: { id: true, code: true },
+ });
+
+ if (!setting) {
+ throw new Error(`file_upload_setting_seed_failed:${documentSetting.code}`);
+ }
+
+ await fieldRepository.delete({ settingId: setting.id });
+
+ await fieldRepository.insert(
+ COMPANY_ONBOARDING_FIELDS.map((field, index) => ({
+ settingId: setting.id,
+ fileKey: field.fileKey,
+ fileLabel: field.fileLabel,
+ helpText: field.helpText,
+ isRequired: field.isRequired,
+ isMultiple: field.isMultiple,
+ maxFiles: field.maxFiles,
+ allowedExtensions: [...field.allowedExtensions],
+ maxSizeMb: field.maxSizeMb,
+ displayOrder: field.displayOrder ?? index + 1,
+ })),
+ );
+ }
+ });
+
+ this.logger.log(
+ "Ensured company onboarding file upload settings for external companies",
+ );
+ }
+}
diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts
new file mode 100644
index 000000000..671436a73
--- /dev/null
+++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts
@@ -0,0 +1,800 @@
+import { Injectable, Logger } from "@nestjs/common";
+import { DataSource } from "typeorm";
+
+import { BookingCargoModifier } from "../modules/bookings/entities/booking-cargo-modifier.entity";
+import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity";
+import { ContainerType } from "../modules/rule-engine/entities/container-type.entity";
+import { PriorityRule } from "../modules/rule-engine/entities/priority-rule.entity";
+import { Rate } from "../modules/rule-engine/entities/rate.entity";
+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 { Yard } from "../modules/rule-engine/entities/yard.entity";
+
+const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001";
+const CEO_USER_ID = "00000000-0000-0000-0000-000000000002";
+
+@Injectable()
+export class PricingDataSeeder {
+ private readonly logger = new Logger(PricingDataSeeder.name);
+
+ constructor(private readonly dataSource: DataSource) { }
+
+ async run(): Promise {
+ await this.dataSource.transaction(async (manager) => {
+ const ctRepo = manager.getRepository(ContainerType);
+ const stRepo = manager.getRepository(ServiceType);
+ const yRepo = manager.getRepository(Yard);
+ const slRepo = manager.getRepository(ShippingLine);
+ const wlRepo = manager.getRepository(WeightLimitRule);
+ const prRepo = manager.getRepository(PriorityRule);
+ const rRepo = manager.getRepository(Rate);
+
+ await this.upsertReferenceData(manager, ctRepo, stRepo, yRepo, slRepo);
+ await this.seedWeightLimits(wlRepo, ctRepo);
+ await this.seedPriorityRules(prRepo);
+ const containerTypes = await ctRepo.find();
+ const ctByCode = new Map(containerTypes.map((ct) => [ct.code, ct]));
+
+ const rates = await this.seedRates(rRepo, ctByCode);
+ const ratesByType = new Map();
+ for (const r of rates) {
+ const key = `${r.rateType}|${r.currency}|${r.containerTypeId ?? ""}`;
+ if (!ratesByType.has(key)) ratesByType.set(key, []);
+ ratesByType.get(key)!.push(r);
+ }
+
+ await this.seedSurchargeTypes(manager, ratesByType);
+
+ const yards = await yRepo.find();
+ const yardByCode = new Map(yards.map((y) => [y.code, y]));
+ const serviceTypes = await stRepo.find();
+ const stByCode = new Map(serviceTypes.map((st) => [st.code, st]));
+ const shippingLines = await slRepo.find();
+ const slByCode = new Map(shippingLines.map((sl) => [sl.code, sl]));
+ const cargoTypes = await manager.getRepository(CargoType).find();
+ const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c]));
+
+ await this.seedDraftBookings(
+ ctByCode,
+ yardByCode,
+ stByCode,
+ slByCode,
+ cargoByCode,
+ );
+ });
+
+ this.logger.log("Seeded pricing data");
+ }
+
+ private async upsertReferenceData(
+ manager: any,
+ ctRepo: any,
+ stRepo: any,
+ yRepo: any,
+ slRepo: any,
+ ): Promise {
+ await yRepo.upsert(
+ [
+ {
+ code: "DJIBOUTI",
+ label: "Djibouti",
+ country: "Djibouti",
+ displayOrder: 1,
+ isActive: true,
+ },
+ {
+ code: "ADDIS_ABABA",
+ label: "Addis Ababa",
+ country: "Ethiopia",
+ displayOrder: 2,
+ isActive: true,
+ },
+ {
+ code: "DIRE_DAWA",
+ label: "Dire Dawa",
+ country: "Ethiopia",
+ displayOrder: 3,
+ isActive: true,
+ },
+ {
+ code: "MODJO",
+ label: "Modjo",
+ country: "Ethiopia",
+ displayOrder: 4,
+ isActive: true,
+ },
+ ],
+ { conflictPaths: { code: true } },
+ );
+
+ await ctRepo.upsert(
+ [
+ {
+ code: "20FT",
+ label: "20FT Standard",
+ sizeFt: 20,
+ wagonsPerUnit: 1,
+ isReefer: false,
+ isOpenTop: false,
+ isActive: true,
+ displayOrder: 1,
+ },
+ {
+ code: "40FT",
+ label: "40FT Standard",
+ sizeFt: 40,
+ wagonsPerUnit: 1,
+ isReefer: false,
+ isOpenTop: false,
+ isActive: true,
+ displayOrder: 2,
+ },
+ {
+ code: "20FT_REEFER",
+ label: "20FT Reefer",
+ sizeFt: 20,
+ wagonsPerUnit: 1,
+ isReefer: true,
+ isOpenTop: false,
+ isActive: true,
+ displayOrder: 3,
+ },
+ {
+ code: "40FT_REEFER",
+ label: "40FT Reefer",
+ sizeFt: 40,
+ wagonsPerUnit: 1,
+ isReefer: true,
+ isOpenTop: false,
+ isActive: true,
+ displayOrder: 4,
+ },
+ ],
+ { conflictPaths: { code: true } },
+ );
+
+ await stRepo.upsert(
+ [
+ {
+ code: "RAIL_CONTAINER",
+ serviceName: "Rail Container Service",
+ description: "Standard rail container transport",
+ canBeBookedAlone: true,
+ includesFirstMile: false,
+ includesLastMile: false,
+ includesCustoms: false,
+ priorityBonusPoints: 0,
+ isActive: true,
+ displayOrder: 1,
+ },
+ {
+ code: "RAIL_FORWARDING",
+ serviceName: "Rail Forwarding Service",
+ description: "Rail transport with first/last mile and customs",
+ canBeBookedAlone: true,
+ includesFirstMile: true,
+ includesLastMile: true,
+ includesCustoms: true,
+ priorityBonusPoints: 100,
+ isActive: true,
+ displayOrder: 2,
+ },
+ {
+ code: "RAIL_BULK",
+ serviceName: "Rail Bulk Transport",
+ description: "Bulk commodity rail transport",
+ canBeBookedAlone: true,
+ includesFirstMile: false,
+ includesLastMile: false,
+ includesCustoms: false,
+ priorityBonusPoints: 50,
+ isActive: true,
+ displayOrder: 3,
+ },
+ ],
+ { conflictPaths: { code: true } },
+ );
+
+ await slRepo.upsert(
+ [
+ {
+ code: "MAERSK",
+ label: "Maersk Line",
+ mappedToCode: "MAERSK",
+ showExtraFeeNotice: true,
+ isActive: true,
+ },
+ {
+ code: "MSC",
+ label: "MSC",
+ mappedToCode: "MSC",
+ showExtraFeeNotice: true,
+ isActive: true,
+ },
+ {
+ code: "CMA_CGM",
+ label: "CMA CGM",
+ mappedToCode: "CMA_CGM",
+ showExtraFeeNotice: true,
+ isActive: true,
+ },
+ {
+ code: "COSCO",
+ label: "COSCO Shipping",
+ mappedToCode: "COSCO",
+ showExtraFeeNotice: true,
+ isActive: true,
+ },
+ {
+ code: "OTHER",
+ label: "Other Line",
+ mappedToCode: null,
+ showExtraFeeNotice: false,
+ isActive: true,
+ },
+ ],
+ { conflictPaths: { code: true } },
+ );
+
+ await manager.getRepository(CargoType).upsert(
+ [
+ {
+ code: "GRAIN",
+ cargoTypeName: "Grain / Cereals",
+ requiresDirectorApproval: false,
+ isActive: true,
+ displayOrder: 1,
+ },
+ {
+ code: "FERTILIZER",
+ cargoTypeName: "Fertilizer",
+ requiresDirectorApproval: false,
+ isActive: true,
+ displayOrder: 2,
+ },
+ {
+ code: "CEMENT",
+ cargoTypeName: "Cement / Clinker",
+ requiresDirectorApproval: false,
+ isActive: true,
+ displayOrder: 3,
+ },
+ {
+ code: "STEEL",
+ cargoTypeName: "Steel / Rebar",
+ requiresDirectorApproval: true,
+ isActive: true,
+ displayOrder: 4,
+ },
+ {
+ code: "MACHINERY",
+ cargoTypeName: "Heavy Machinery",
+ requiresDirectorApproval: true,
+ isActive: true,
+ displayOrder: 5,
+ },
+ {
+ code: "OTHER_BULK",
+ cargoTypeName: "Other Bulk Cargo",
+ requiresDirectorApproval: false,
+ isActive: true,
+ displayOrder: 6,
+ },
+ ],
+ { conflictPaths: { code: true } },
+ );
+ }
+
+ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise {
+ await wlRepo.createQueryBuilder().delete().execute();
+ const twenty = await ctRepo.findOneByOrFail({ code: "20FT" });
+ const forty = await ctRepo.findOneByOrFail({ code: "40FT" });
+ const base = new Date("2026-01-01");
+ await wlRepo.insert([
+ {
+ containerTypeId: twenty.id,
+ tradeDirection: "IMPORT",
+ maxVgmTons: 26,
+ effectiveFrom: base,
+ },
+ {
+ containerTypeId: twenty.id,
+ tradeDirection: "EXPORT",
+ maxVgmTons: 26,
+ effectiveFrom: base,
+ },
+ {
+ containerTypeId: forty.id,
+ tradeDirection: "IMPORT",
+ maxVgmTons: 28,
+ effectiveFrom: base,
+ },
+ {
+ containerTypeId: forty.id,
+ tradeDirection: "EXPORT",
+ maxVgmTons: 28,
+ effectiveFrom: base,
+ },
+ ]);
+ this.logger.log("Seeded weight limit rules");
+ }
+
+ private async seedPriorityRules(prRepo: any): Promise {
+ const existing = await prRepo.find({
+ where: [{ code: "USD_PRIORITY" }, { code: "STANDARD_PRIORITY" }],
+ });
+ for (const r of existing) {
+ await prRepo.remove(r);
+ }
+ await prRepo.save([
+ prRepo.create({
+ code: "USD_PRIORITY",
+ label: "USD Payment Priority",
+ score: 200,
+ conditionCurrency: "USD",
+ isActive: true,
+ }),
+ prRepo.create({
+ code: "STANDARD_PRIORITY",
+ label: "Standard Priority",
+ score: 50,
+ conditionCurrency: null,
+ isActive: true,
+ }),
+ ]);
+ this.logger.log("Seeded priority rules");
+ }
+
+ private async seedRates(
+ rRepo: any,
+ ctByCode: Map,
+ ): Promise {
+ const effectiveFrom = new Date("2026-01-01");
+ const now = new Date();
+ const rateData = [
+ {
+ rateType: "CONTAINER_IMPORT",
+ containerTypeId: ctByCode.get("20FT")!.id,
+ currency: "USD",
+ rateValue: 800,
+ rateUnit: "PER_CONTAINER",
+ },
+ {
+ rateType: "CONTAINER_IMPORT",
+ containerTypeId: ctByCode.get("40FT")!.id,
+ currency: "USD",
+ rateValue: 1200,
+ rateUnit: "PER_CONTAINER",
+ },
+ {
+ rateType: "CONTAINER_IMPORT",
+ containerTypeId: ctByCode.get("20FT")!.id,
+ currency: "ETB",
+ rateValue: 45000,
+ rateUnit: "PER_CONTAINER",
+ },
+ {
+ rateType: "CONTAINER_IMPORT",
+ containerTypeId: ctByCode.get("40FT")!.id,
+ currency: "ETB",
+ rateValue: 67000,
+ rateUnit: "PER_CONTAINER",
+ },
+ {
+ rateType: "CONTAINER_EXPORT",
+ containerTypeId: ctByCode.get("20FT")!.id,
+ currency: "USD",
+ rateValue: 600,
+ rateUnit: "PER_CONTAINER",
+ },
+ {
+ rateType: "CONTAINER_EXPORT",
+ containerTypeId: ctByCode.get("40FT")!.id,
+ currency: "USD",
+ rateValue: 900,
+ rateUnit: "PER_CONTAINER",
+ },
+ {
+ rateType: "CONTAINER_EXPORT",
+ containerTypeId: ctByCode.get("20FT")!.id,
+ currency: "ETB",
+ rateValue: 34000,
+ rateUnit: "PER_CONTAINER",
+ },
+ {
+ rateType: "CONTAINER_EXPORT",
+ containerTypeId: ctByCode.get("40FT")!.id,
+ currency: "ETB",
+ rateValue: 50000,
+ rateUnit: "PER_CONTAINER",
+ },
+ {
+ rateType: "INTERCITY_CONTAINER",
+ containerTypeId: ctByCode.get("20FT")!.id,
+ currency: "ETB",
+ rateValue: 20000,
+ rateUnit: "PER_CONTAINER",
+ },
+ {
+ rateType: "INTERCITY_CONTAINER",
+ containerTypeId: ctByCode.get("40FT")!.id,
+ currency: "ETB",
+ rateValue: 30000,
+ rateUnit: "PER_CONTAINER",
+ },
+ {
+ rateType: "CONTAINER_IMPORT",
+ containerTypeId: null,
+ currency: "USD",
+ rateValue: 1000,
+ rateUnit: "PER_CONTAINER",
+ },
+ {
+ rateType: "CONTAINER_IMPORT",
+ containerTypeId: null,
+ currency: "ETB",
+ rateValue: 56000,
+ rateUnit: "PER_CONTAINER",
+ },
+ {
+ rateType: "CONTAINER_EXPORT",
+ containerTypeId: null,
+ currency: "USD",
+ rateValue: 750,
+ rateUnit: "PER_CONTAINER",
+ },
+ {
+ rateType: "CONTAINER_EXPORT",
+ containerTypeId: null,
+ currency: "ETB",
+ rateValue: 42000,
+ rateUnit: "PER_CONTAINER",
+ },
+ {
+ rateType: "INTERCITY_CONTAINER",
+ containerTypeId: null,
+ currency: "ETB",
+ rateValue: 25000,
+ rateUnit: "PER_CONTAINER",
+ },
+ {
+ rateType: "BULK_IMPORT",
+ containerTypeId: null,
+ currency: "USD",
+ rateValue: 50,
+ rateUnit: "PER_TON",
+ },
+ {
+ rateType: "BULK_IMPORT",
+ containerTypeId: null,
+ currency: "ETB",
+ rateValue: 2800,
+ rateUnit: "PER_TON",
+ },
+ {
+ rateType: "BULK_EXPORT",
+ containerTypeId: null,
+ currency: "USD",
+ rateValue: 40,
+ rateUnit: "PER_TON",
+ },
+ {
+ rateType: "BULK_EXPORT",
+ containerTypeId: null,
+ currency: "ETB",
+ rateValue: 2200,
+ rateUnit: "PER_TON",
+ },
+ {
+ rateType: "OVERWEIGHT_PER_TON",
+ containerTypeId: null,
+ currency: "USD",
+ rateValue: 25,
+ rateUnit: "PER_TON",
+ },
+ {
+ rateType: "OVERWEIGHT_PER_TON",
+ containerTypeId: null,
+ currency: "ETB",
+ rateValue: 1400,
+ rateUnit: "PER_TON",
+ },
+ {
+ rateType: "HAZARD_SURCHARGE",
+ containerTypeId: null,
+ currency: "USD",
+ rateValue: 150,
+ rateUnit: "FLAT",
+ },
+ {
+ rateType: "HAZARD_SURCHARGE",
+ containerTypeId: null,
+ currency: "ETB",
+ rateValue: 8500,
+ rateUnit: "FLAT",
+ },
+ {
+ rateType: "REEFER_SURCHARGE",
+ containerTypeId: null,
+ currency: "USD",
+ rateValue: 200,
+ rateUnit: "FLAT",
+ },
+ {
+ rateType: "REEFER_SURCHARGE",
+ containerTypeId: null,
+ currency: "ETB",
+ rateValue: 11000,
+ rateUnit: "FLAT",
+ },
+ {
+ rateType: "DOUBLE_HANDLING",
+ containerTypeId: null,
+ currency: "USD",
+ rateValue: 100,
+ rateUnit: "PER_CONTAINER",
+ },
+ {
+ rateType: "DOUBLE_HANDLING",
+ containerTypeId: null,
+ currency: "ETB",
+ rateValue: 5500,
+ rateUnit: "PER_CONTAINER",
+ },
+ {
+ rateType: "LASHING",
+ containerTypeId: null,
+ currency: "USD",
+ rateValue: 50,
+ rateUnit: "PER_CONTAINER",
+ },
+ {
+ rateType: "LASHING",
+ containerTypeId: null,
+ currency: "ETB",
+ rateValue: 2800,
+ rateUnit: "PER_CONTAINER",
+ },
+ ];
+
+ const entities = rateData.map((d) =>
+ rRepo.create({
+ ...d,
+ status: "LIVE",
+ proposedByStaffId: STAFF_USER_ID,
+ approvedByCeoId: CEO_USER_ID,
+ approvedAt: now,
+ effectiveFrom,
+ }),
+ );
+ return rRepo.save(entities);
+ }
+
+ private async seedSurchargeTypes(
+ manager: any,
+ ratesByType: Map,
+ ): Promise {
+ const surRepo = manager.getRepository(SurchargeType);
+ const bcmRepo = manager.getRepository(BookingCargoModifier);
+ await bcmRepo.createQueryBuilder().delete().execute();
+ const findRate = (rateType: string, currency: string) => {
+ const key = `${rateType}|${currency}|`;
+ const rates = ratesByType.get(key);
+ return rates?.[0];
+ };
+
+ const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD");
+ const hazardRateEtb = findRate("HAZARD_SURCHARGE", "ETB");
+ const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD");
+ const reeferRateEtb = findRate("REEFER_SURCHARGE", "ETB");
+ const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD");
+ const overweightRateEtb = findRate("OVERWEIGHT_PER_TON", "ETB");
+ const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD");
+ const shipLineRateEtb = findRate("DOUBLE_HANDLING", "ETB");
+ const consolidRateUsd = findRate("LASHING", "USD");
+ const consolidRateEtb = findRate("LASHING", "ETB");
+
+ await surRepo.createQueryBuilder().delete().execute();
+ await surRepo.save([
+ surRepo.create({
+ code: "HAZARDOUS_CARGO",
+ label: "Hazardous Cargo",
+ triggerCondition: "CARGO_FLAG_HAZARDOUS",
+ rateId: hazardRateUsd?.id ?? hazardRateEtb?.id,
+ isActive: true,
+ }),
+ surRepo.create({
+ code: "REEFER_CARGO",
+ label: "Reefer Cargo",
+ triggerCondition: "CARGO_FLAG_REEFER",
+ rateId: reeferRateUsd?.id ?? reeferRateEtb?.id,
+ isActive: true,
+ }),
+ surRepo.create({
+ code: "OVERWEIGHT_CARGO",
+ label: "Overweight Cargo",
+ triggerCondition: "VGM_EXCEEDS_LIMIT",
+ rateId: overweightRateUsd?.id ?? overweightRateEtb?.id,
+ isActive: true,
+ }),
+ surRepo.create({
+ code: "SHIPPING_LINE_FEE",
+ label: "Shipping Line Fee",
+ triggerCondition: "SHIPPING_LINE_MAPPED",
+ rateId: shipLineRateUsd?.id ?? shipLineRateEtb?.id,
+ isActive: true,
+ }),
+ surRepo.create({
+ code: "CONSOLIDATION_FEE",
+ label: "Consolidation Fee",
+ triggerCondition: "CONSOLIDATION_ENABLED",
+ rateId: consolidRateUsd?.id ?? consolidRateEtb?.id,
+ isActive: true,
+ }),
+ ]);
+ this.logger.log("Seeded surcharge types");
+ }
+
+ private async seedDraftBookings(
+ ctByCode: Map,
+ yardByCode: Map,
+ stByCode: Map,
+ slByCode: Map,
+ cargoByCode: Map,
+ ): Promise {
+ const djibouti = yardByCode.get("DJIBOUTI")!;
+ const addis = yardByCode.get("ADDIS_ABABA")!;
+ const railContainer = stByCode.get("RAIL_CONTAINER")!;
+ const railBulk = stByCode.get("RAIL_BULK")!;
+ const maersk = slByCode.get("MAERSK")!;
+ const grain = cargoByCode.get("GRAIN")!;
+ const twenty = ctByCode.get("20FT")!;
+ const forty = ctByCode.get("40FT")!;
+ const twentyReefer = ctByCode.get("20FT_REEFER")!;
+
+ const drafts = [
+ {
+ reference: "BKG-PRICE-001",
+ description: "Standard 20FT container import — base rail only",
+ freightType: "CONTAINER" as const,
+ tradeDirection: "IMPORT",
+ paymentCurrency: "USD",
+ serviceTypeId: railContainer.id,
+ originYardId: djibouti.id,
+ destinationYardId: addis.id,
+ isHazardous: false,
+ allowConsolidation: false,
+ shippingLineId: null,
+ cargoTypeId: null,
+ cargoTotalWeightVgm: 250,
+ containers: [
+ { containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 25 },
+ ],
+ expectedBaseRate: 800,
+ expectedSurcharges: [],
+ },
+ {
+ reference: "BKG-PRICE-002",
+ description: "40FT container import + hazardous surcharge",
+ freightType: "CONTAINER" as const,
+ tradeDirection: "IMPORT",
+ paymentCurrency: "USD",
+ serviceTypeId: railContainer.id,
+ originYardId: djibouti.id,
+ destinationYardId: addis.id,
+ isHazardous: true,
+ allowConsolidation: false,
+ shippingLineId: null,
+ cargoTypeId: null,
+ cargoTotalWeightVgm: 135,
+ containers: [
+ { containerTypeId: forty.id, quantity: 5, vgmPerUnitTons: 27 },
+ ],
+ expectedBaseRate: 1200,
+ expectedSurcharges: ["HAZARDOUS_CARGO"],
+ },
+ {
+ reference: "BKG-PRICE-003",
+ description: "20FT container import + shipping line (ETB)",
+ freightType: "CONTAINER" as const,
+ tradeDirection: "IMPORT",
+ paymentCurrency: "ETB",
+ serviceTypeId: railContainer.id,
+ originYardId: djibouti.id,
+ destinationYardId: addis.id,
+ isHazardous: false,
+ allowConsolidation: false,
+ shippingLineId: maersk.id,
+ cargoTypeId: null,
+ cargoTotalWeightVgm: 480,
+ containers: [
+ { containerTypeId: twenty.id, quantity: 20, vgmPerUnitTons: 24 },
+ ],
+ expectedBaseRate: 45000,
+ expectedSurcharges: ["SHIPPING_LINE_FEE"],
+ },
+ {
+ reference: "BKG-PRICE-004",
+ description: "40FT container import + consolidation (USD)",
+ freightType: "CONTAINER" as const,
+ tradeDirection: "IMPORT",
+ paymentCurrency: "USD",
+ serviceTypeId: railContainer.id,
+ originYardId: djibouti.id,
+ destinationYardId: addis.id,
+ isHazardous: false,
+ allowConsolidation: true,
+ shippingLineId: null,
+ cargoTypeId: null,
+ cargoTotalWeightVgm: 224,
+ containers: [
+ { containerTypeId: forty.id, quantity: 8, vgmPerUnitTons: 28 },
+ ],
+ expectedBaseRate: 1200,
+ expectedSurcharges: ["CONSOLIDATION_FEE"],
+ },
+ {
+ reference: "BKG-PRICE-005",
+ description: "Bulk import — grain",
+ freightType: "BULK" as const,
+ tradeDirection: "IMPORT",
+ paymentCurrency: "USD",
+ serviceTypeId: railBulk.id,
+ originYardId: djibouti.id,
+ destinationYardId: addis.id,
+ isHazardous: false,
+ allowConsolidation: false,
+ shippingLineId: null,
+ cargoTypeId: grain.id,
+ cargoTotalWeightVgm: 500,
+ containers: [],
+ expectedBaseRate: 50,
+ expectedSurcharges: [],
+ },
+ {
+ reference: "BKG-PRICE-006",
+ description: "20FT reefer container import + reefer surcharge",
+ freightType: "CONTAINER" as const,
+ tradeDirection: "IMPORT",
+ paymentCurrency: "USD",
+ serviceTypeId: railContainer.id,
+ originYardId: djibouti.id,
+ destinationYardId: addis.id,
+ isHazardous: false,
+ allowConsolidation: false,
+ shippingLineId: null,
+ cargoTypeId: null,
+ cargoTotalWeightVgm: 75,
+ containers: [
+ { containerTypeId: twentyReefer.id, quantity: 3, vgmPerUnitTons: 25 },
+ ],
+ expectedBaseRate: 800,
+ expectedSurcharges: ["REEFER_CARGO"],
+ },
+ {
+ reference: "BKG-PRICE-007",
+ description: "20FT container import + overweight (30t > 26t limit)",
+ freightType: "CONTAINER" as const,
+ tradeDirection: "IMPORT",
+ paymentCurrency: "USD",
+ serviceTypeId: railContainer.id,
+ originYardId: djibouti.id,
+ destinationYardId: addis.id,
+ isHazardous: false,
+ allowConsolidation: false,
+ shippingLineId: null,
+ cargoTypeId: null,
+ cargoTotalWeightVgm: 300,
+ containers: [
+ { containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 30 },
+ ],
+ expectedBaseRate: 800,
+ expectedSurcharges: ["OVERWEIGHT_CARGO"],
+ },
+ ];
+
+ this.logger.log(`Seeded ${drafts.length} DRAFT bookings for pricing`);
+ }
+}
diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json
index f39ee4541..7499e38a2 100644
--- a/apps/edr-freight-web/backoffice/package.json
+++ b/apps/edr-freight-web/backoffice/package.json
@@ -27,6 +27,7 @@
"react-hot-toast": "^2.6.0",
"react-router-dom": "^6.27.0",
"recharts": "^3.8.1",
+ "sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.0"
},
diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx
index 7cc6a9935..480873fab 100644
--- a/apps/edr-freight-web/backoffice/src/App.tsx
+++ b/apps/edr-freight-web/backoffice/src/App.tsx
@@ -1,5 +1,4 @@
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
Boxes,
FileText,
@@ -8,12 +7,18 @@ import {
Paperclip,
Settings,
SlidersHorizontal,
+ Train,
+ Truck,
+ Container,
+ Package,
+ //TrainTrack,
} from "lucide-react";
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
import LoadingScreen from "./components/LoadingScreen";
import { useAuth } from "./auth/useAuth";
import LoginPage from "./pages/auth/LoginPage";
+import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
@@ -29,17 +34,13 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
+import TrainsPage from "./pages/trains/TrainsPage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
-
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- retry: 1,
- refetchOnWindowFocus: false,
- staleTime: 5 * 60 * 1000,
- },
- },
-});
+//import TrainsPage from "./pages/trains/TrainsPage";
+import TrainDetailPage from "./pages/trains/TrainDetailPage";
+import WagonsPage from "./pages/wagons/WagonsPage";
+import ContainersPage from "./pages/containers_management/ContainersPage";
+import CargoesPage from "./pages/cargoes/CargoesPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -56,9 +57,39 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/booking-requests",
icon: ,
},
+ {
+ label: "Train scheduling",
+ href: "/dashboard/operations/train-scheduling",
+ icon: ,
+ },
...demoItems,
],
},
+ {
+ title: "Fleet Management",
+ items: [
+ {
+ label: "Trains",
+ href: "/dashboard/trains",
+ icon: ,
+ },
+ {
+ label: "Wagons",
+ href: "/dashboard/wagons",
+ icon: ,
+ },
+ {
+ label: "Containers",
+ href: "/dashboard/containers",
+ icon: ,
+ },
+ {
+ label: "Cargoes",
+ href: "/dashboard/cargoes",
+ icon: ,
+ },
+ ],
+ },
{
title: "Administration",
items: [
@@ -188,18 +219,15 @@ const App = () => {
if (!user) {
return (
-
-
- } />
- } />
-
-
+
+ } />
+ } />
+
);
}
return (
-
-
+
} />
} />
@@ -208,6 +236,17 @@ const App = () => {
} />
} />
+ }
+ />
+ } />
+
+ } />
+ } />
+ } />
+ } />
+ } />
} />
} />
@@ -251,8 +290,7 @@ const App = () => {
} />
-
-
+
);
};
diff --git a/apps/edr-freight-web/backoffice/src/auth/types.ts b/apps/edr-freight-web/backoffice/src/auth/types.ts
index 2d4ecd536..5efcdd828 100644
--- a/apps/edr-freight-web/backoffice/src/auth/types.ts
+++ b/apps/edr-freight-web/backoffice/src/auth/types.ts
@@ -52,3 +52,10 @@ export interface AuthTokens {
export interface LoginResponse extends Partial {
mfaRequired?: boolean;
}
+
+// Additional types for Matrix form test
+export interface User {
+ id: string;
+ name: string;
+ role: "ADMIN" | "MANAGER" | "CHIEF_EXECUTIVE";
+}
diff --git a/apps/edr-freight-web/backoffice/src/auth/useAuth.ts b/apps/edr-freight-web/backoffice/src/auth/useAuth.ts
index e454bc509..74a29cf7e 100644
--- a/apps/edr-freight-web/backoffice/src/auth/useAuth.ts
+++ b/apps/edr-freight-web/backoffice/src/auth/useAuth.ts
@@ -11,3 +11,5 @@ export const useAuth = () => {
return context;
};
+
+
diff --git a/apps/edr-freight-web/backoffice/src/components/baselineRatematrix/RateMatrixForm.tsx b/apps/edr-freight-web/backoffice/src/components/baselineRatematrix/RateMatrixForm.tsx
new file mode 100644
index 000000000..f18389f9e
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/baselineRatematrix/RateMatrixForm.tsx
@@ -0,0 +1,330 @@
+// components/baselineRatematrix/RateMatrixForm.tsx
+import React, { useState, useCallback } from 'react';
+// import { useForm } from 'react-hook-form';
+// import { zodResolver } from '@hookform/resolvers/zod';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner';
+import { z } from 'zod';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { Loader2, Save, Send, Shield, AlertTriangle } from 'lucide-react';
+import { RateTypeSection } from './RateTypeSection';
+import { ConfirmationDialog } from './ConfirmationDialog';
+import { ValidationSummary } from './ValidationSummary';
+import { LoadingScreen } from '@/ui/LoadingScreen';
+import { useRateMatrixAuth } from '@/auth/hooks/useAuth';
+import { useReferenceData } from '@/hooks/useReferenceData';
+import { queryKeys } from '@/constants/queryKeys';
+import { API_URLS } from '@/constants/apiUrls';
+import {
+ RATE_TYPES,
+ RATE_TYPE_LABELS,
+ REQUIRED_RATE_TYPES
+} from '@/constants/rateMatrixConstants';
+import { rateMatrixRulesEngine } from '../../ruleEngine/rateMatrixRules';
+import type { RateEntry } from './types';
+
+const formSchema = z.object({
+ matrixName: z.string().min(1, 'Matrix name is required').max(200),
+ effectiveDate: z.string().min(1, 'Effective date is required'),
+ expiryDate: z.string().optional(),
+ currency: z.string().min(1, 'Currency is required'),
+});
+
+type FormData = z.infer;
+
+const createInitialSections = (): RateEntry[] => {
+ return REQUIRED_RATE_TYPES.map(rateType => ({
+ rateType,
+ entries: [{
+ validFrom: '',
+ validTo: '',
+ }],
+ }));
+};
+
+export function RateMatrixForm() {
+ const [rateSections, setRateSections] = useState(createInitialSections());
+ const [showConfirmation, setShowConfirmation] = useState(false);
+ const [savedMatrixId, setSavedMatrixId] = useState(null);
+ const [validationErrors, setValidationErrors] = useState([]);
+
+ const { isDirector } = useRateMatrixAuth();
+ const { data: referenceData, isLoading: isLoadingReference } = useReferenceData();
+ const queryClient = useQueryClient();
+
+ const form = useForm({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ matrixName: '',
+ effectiveDate: '',
+ expiryDate: '',
+ currency: 'USD',
+ },
+ });
+
+ // Save draft mutation
+ const saveDraftMutation = useMutation({
+ mutationFn: async (data: FormData & { rateSections: RateEntry[] }) => {
+ const response = await fetch(API_URLS.RATE_MATRIX.DRAFT, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ });
+ if (!response.ok) throw new Error('Failed to save draft');
+ return response.json();
+ },
+ onSuccess: (data) => {
+ setSavedMatrixId(data.id);
+ queryClient.invalidateQueries({ queryKey: queryKeys.rateMatrix.all });
+ toast.success('Draft saved successfully');
+ },
+ onError: (error) => {
+ toast.error('Failed to save draft');
+ },
+ });
+
+ // Submit for approval mutation
+ const submitMutation = useMutation({
+ mutationFn: async (matrixId: string) => {
+ const response = await fetch(API_URLS.RATE_MATRIX.SUBMIT(matrixId), {
+ method: 'POST',
+ });
+ if (!response.ok) throw new Error('Failed to submit');
+ return response.json();
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.rateMatrix.all });
+ toast.success('Rate matrix submitted for executive approval and locked!');
+ setShowConfirmation(false);
+ },
+ onError: (error) => {
+ toast.error('Failed to submit for approval');
+ setShowConfirmation(false);
+ },
+ });
+
+ const handleValidate = useCallback(() => {
+ const validation = rateMatrixRulesEngine.validate(rateSections);
+ setValidationErrors([...validation.errors, ...validation.warnings]);
+
+ if (validation.isValid) {
+ toast.success('All validations passed!');
+ }
+ }, [rateSections]);
+
+ const handleSaveDraft = async () => {
+ const formData = form.getValues();
+ await saveDraftMutation.mutateAsync({
+ ...formData,
+ rateSections,
+ });
+ };
+
+ const handleSubmitClick = async () => {
+ const isFormValid = await form.trigger();
+ if (!isFormValid) return;
+
+ const validation = rateMatrixRulesEngine.validate(rateSections);
+ setValidationErrors([...validation.errors, ...validation.warnings]);
+
+ if (!validation.isValid) {
+ toast.error('Please fix validation errors before submitting');
+ return;
+ }
+
+ setShowConfirmation(true);
+ };
+
+ const handleConfirmSubmit = async () => {
+ const formData = form.getValues();
+
+ try {
+ let matrixId = savedMatrixId;
+
+ if (!matrixId) {
+ const draftResult = await saveDraftMutation.mutateAsync({
+ ...formData,
+ rateSections,
+ });
+ matrixId = draftResult.id;
+ }
+
+ await submitMutation.mutateAsync(matrixId!);
+ } catch (error) {
+ // Error handling done in mutations
+ }
+ };
+
+ if (isLoadingReference) {
+ return ;
+ }
+
+ if (!isDirector) {
+ return (
+
+
+
+ Access Denied
+
+ Only Directors can access the rate matrix registration.
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ Baseline Rate Matrix Registration
+
+
+ Submit a comprehensive rate matrix for executive approval
+
+
+
+ {/* Director Warning */}
+
+
+ Director Notice
+
+ Once submitted, this matrix will be locked pending Chief Executive approval.
+ No edits can be made by any user until authorization is granted.
+
+
+
+
+
+ {/* Confirmation Dialog */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx
new file mode 100644
index 000000000..64cca7db3
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx
@@ -0,0 +1,116 @@
+import { useMemo } from "react";
+import { ShieldCheck } from "lucide-react";
+
+import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
+import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
+import { Badge } from "@edr/ui-common";
+import { cn } from "@/lib/utils";
+
+interface ApprovalStepsCardProps {
+ booking: BookingDetail;
+}
+
+/** Read-only approval chain visualization; actions live in BookingActionsToolbar. */
+export function ApprovalStepsCard({ booking }: ApprovalStepsCardProps) {
+ const steps = useMemo(
+ () =>
+ [...(booking.approvalSteps ?? [])].sort(
+ (a, b) => a.stepOrder - b.stepOrder,
+ ),
+ [booking.approvalSteps],
+ );
+
+ const nextPending = getNextPendingApprovalStep(steps);
+
+ return (
+
+
+
+
+
+
+
+ Approval chain
+
+
+ Next:{" "}
+ {nextPending
+ ? `${nextPending.requiredRole} · step ${nextPending.stepOrder}`
+ : steps.length
+ ? "All steps complete"
+ : "Accept submission to begin"}
+
+
+
+
+
+ {steps.length === 0 ? (
+
+ Use Accept for approval {" "}
+ in staff actions to instantiate steps.
+
+ ) : (
+
+ {steps.map((step) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+function StepRow({
+ step,
+ isNext,
+}: {
+ step: BookingApprovalStep;
+ isNext: boolean;
+}) {
+ const statusStyles =
+ step.status === "APPROVED"
+ ? "bg-emerald-500/15 text-emerald-800 dark:text-emerald-300"
+ : step.status === "REJECTED"
+ ? "bg-red-500/15 text-red-800 dark:text-red-300"
+ : isNext
+ ? "bg-amber-500/15 text-amber-800 dark:text-amber-300"
+ : "bg-muted text-muted-foreground";
+
+ return (
+
+
+
+ {step.stepOrder}
+
+
+
+ {step.requiredRole}
+
+ {step.remarks && (
+
+ {step.remarks}
+
+ )}
+
+
+
+ {step.status}
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx
new file mode 100644
index 000000000..29b4e9b19
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx
@@ -0,0 +1,233 @@
+import { useNavigate } from "react-router-dom";
+import {
+ ChevronRight,
+ ExternalLink,
+ Loader2,
+ MoreHorizontal,
+ Upload,
+} from "lucide-react";
+
+import { BookingConfirmDialog } from "./BookingConfirmDialog";
+import { useBookingActionDialog } from "./useBookingActionDialog";
+import {
+ listRowHasActions,
+ type BookingActionContext,
+} from "@/features/bookings/booking-actions.config";
+import type { BookingListRow } from "@/types/booking";
+import { cn } from "@/lib/utils";
+import {
+ Button,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@edr/ui-common";
+
+interface BookingActionsMenuProps {
+ row: BookingListRow;
+ /** Compact table cell vs. larger detail toolbar */
+ variant?: "table" | "toolbar";
+ className?: string;
+}
+
+export function BookingActionsMenu({
+ row,
+ variant = "table",
+ className,
+}: BookingActionsMenuProps) {
+ const navigate = useNavigate();
+ const context: BookingActionContext = {
+ status: row.status,
+ paymentCurrency: row.paymentCurrency,
+ reference: row.reference,
+ };
+
+ const flow = useBookingActionDialog(row.id, context);
+ const { actions, pendingAction, mutations } = flow;
+
+ const goToContract = () =>
+ navigate(`/dashboard/booking-requests/${row.id}/contract`);
+
+ const showUsdPaymentHint =
+ row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD";
+ const hasMenu = listRowHasActions(row) || showUsdPaymentHint;
+
+ const primary = actions.find((a) => a.primary) ?? actions[0];
+
+ if (!hasMenu && variant === "table") {
+ return (
+ navigate(`/dashboard/booking-requests/${row.id}`)}
+ aria-label="View booking"
+ >
+
+
+ );
+ }
+
+ return (
+ <>
+ e.stopPropagation()}
+ onKeyDown={(e) => e.stopPropagation()}
+ >
+ {variant === "table" && primary && (
+
+ primary.id === "viewContract"
+ ? goToContract()
+ : flow.openAction(primary)
+ }
+ >
+
+ {primary.shortLabel}
+
+ )}
+
+ {variant === "toolbar" && actions.length > 0 ? (
+
+ {actions.map((action) => {
+ const Icon = action.icon;
+ return (
+
+ action.id === "viewContract"
+ ? goToContract()
+ : flow.openAction(action)
+ }
+ >
+
+ {action.label}
+
+ );
+ })}
+
+ ) : (
+
+
+
+ {mutations.isPending ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {row.reference}
+
+
+ {actions.map((action) => {
+ const Icon = action.icon;
+ return (
+
+ action.id === "viewContract"
+ ? goToContract()
+ : flow.openAction(action)
+ }
+ >
+
+ {action.label}
+
+ );
+ })}
+ {showUsdPaymentHint && (
+
+ navigate(`/dashboard/booking-requests/${row.id}`)
+ }
+ >
+
+ Upload payment proof…
+
+ )}
+ {(actions.length > 0 || showUsdPaymentHint) && (
+
+ )}
+
+ navigate(`/dashboard/booking-requests/${row.id}`)
+ }
+ >
+
+ Open full details
+
+
+
+ )}
+
+
+
+
+ Loading approval steps…
+
+ ) : pendingAction?.id === "approve" &&
+ !flow.mergedContext.approvalSteps?.length ? (
+
+ No pending approval step found. Accept the submission on the detail
+ page first.
+
+ ) : null
+ }
+ />
+ >
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx
new file mode 100644
index 000000000..8c0b123d5
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx
@@ -0,0 +1,168 @@
+import { useRef } from "react";
+import { Download, Upload, Zap } from "lucide-react";
+
+import type { BookingDetail } from "@/types/booking";
+import { BookingActionsMenu } from "./BookingActionsMenu";
+import { bookingSurface } from "./booking-ui.styles";
+import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
+import type { useBookingMutations } from "@/hooks/bookings/useBookings";
+import { Button } from "@edr/ui-common";
+
+type Mutations = ReturnType;
+
+interface BookingActionsToolbarProps {
+ booking: BookingDetail;
+ mutations: Mutations;
+}
+
+/** Detail-page actions: primary toolbar + payment uploads + downloads. */
+export function BookingActionsToolbar({
+ booking,
+ mutations,
+}: BookingActionsToolbarProps) {
+ const fileRef = useRef(null);
+ const row = toBookingListRow(booking);
+ const { status, paymentCurrency } = booking;
+ const pending = mutations.isPending;
+
+ const downloadBlob = async (fn: () => Promise, filename: string) => {
+ const blob = await fn();
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ if (
+ status === "REJECTED" ||
+ status === "CANCELLED" ||
+ status === "COMPLETED"
+ ) {
+ return null;
+ }
+
+ if (status === "CHANGES_REQUESTED") {
+ return (
+
+ {booking.latestChangeRequestNote && (
+
+ {booking.latestChangeRequestNote}
+
+ )}
+
+ );
+ }
+
+ if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {status === "FULLY_EXECUTED" && paymentCurrency === "USD" && (
+
+ {
+ const file = e.target.files?.[0];
+ if (file) mutations.submitPaymentProof.mutate(file);
+ }}
+ />
+
+ fileRef.current?.click()}
+ >
+
+ Upload payment proof
+
+
+ downloadBlob(
+ () => mutations.downloadPaymentLetter(),
+ `payment-letter-${booking.reference}.txt`,
+ )
+ }
+ >
+
+ Request letter
+
+
+
+ )}
+
+ {status === "CONTRACT_READY" && (
+
+
+ downloadBlob(
+ () => mutations.downloadContract(),
+ `contract-${booking.reference}.txt`,
+ )
+ }
+ >
+
+ Download contract
+
+
+ )}
+
+ );
+}
+
+function PanelShell({
+ title,
+ description,
+ children,
+ muted,
+}: {
+ title: string;
+ description: string;
+ children: React.ReactNode;
+ muted?: boolean;
+}) {
+ return (
+
+
+
+
+
+
+
{title}
+
{description}
+
+
+
{children}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx
new file mode 100644
index 000000000..183995a65
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx
@@ -0,0 +1,134 @@
+import { Loader2 } from "lucide-react";
+
+import type { BookingActionDef } from "@/features/bookings/booking-actions.config";
+import { cn } from "@/lib/utils";
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Textarea,
+} from "@edr/ui-common";
+
+interface BookingConfirmDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ action: BookingActionDef | null;
+ reference?: string;
+ inputValue: string;
+ onInputChange: (value: string) => void;
+ onConfirm: () => void;
+ isPending: boolean;
+ confirmDisabled?: boolean;
+ extra?: React.ReactNode;
+}
+
+export function BookingConfirmDialog({
+ open,
+ onOpenChange,
+ action,
+ reference,
+ inputValue,
+ onInputChange,
+ onConfirm,
+ isPending,
+ confirmDisabled = false,
+ extra,
+}: BookingConfirmDialogProps) {
+ if (!action || !action.confirmTitle) return null;
+
+ const Icon = action.icon;
+ const needsInput = Boolean(action.input);
+ const inputMissing = needsInput && !inputValue.trim();
+ const isDestructive = action.variant === "destructive";
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {action.confirmTitle}
+
+ {reference && (
+
+ {reference}
+
+ )}
+
+
+
+ {action.confirmDescription}
+
+
+
+
+
+ {needsInput && (
+
+
+ {action.inputLabel}
+ *
+
+
+ )}
+ {extra}
+
+
+
+ onOpenChange(false)}
+ >
+ Cancel
+
+
+ {isPending ? (
+
+ ) : (
+
+ )}
+ {action.shortLabel}
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx
new file mode 100644
index 000000000..b6af2b0b4
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx
@@ -0,0 +1,86 @@
+import { Banknote, Receipt } from "lucide-react";
+
+import type { BookingDetail } from "@/types/booking";
+import { Separator } from "@edr/ui-common";
+import { bookingSurface } from "./booking-ui.styles";
+
+export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
+ const amount = Number(booking.totalAmount);
+ const modifiers = booking.cargoModifiers ?? [];
+
+ return (
+
+
+
+
+
+
+
+ Pricing & payment
+
+
Commercial terms
+
+
+
+
+
+ Total amount
+
+
+ {booking.paymentCurrency}{" "}
+ {amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
+
+
+
|
+ {booking.pnrCode &&
|
}
+ {modifiers.length > 0 && (
+ <>
+
+
+
+ Surcharges applied
+
+
+ {modifiers.map((m) => (
+
+ Modifier
+
+ {Number(m.calculatedAmount).toLocaleString()}
+
+
+ ))}
+
+ >
+ )}
+
+
+ );
+}
+
+function Row({
+ label,
+ value,
+ mono,
+}: {
+ label: string;
+ value: string;
+ mono?: boolean;
+}) {
+ return (
+
+ {label}
+
+ {value}
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPriorityBadge.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPriorityBadge.tsx
new file mode 100644
index 000000000..9202977c6
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPriorityBadge.tsx
@@ -0,0 +1,21 @@
+export function BookingPriorityBadge({ score }: { score: number }) {
+ if (score >= 1000) {
+ return (
+
+ Urgent
+
+ );
+ }
+ if (score >= 500) {
+ return (
+
+ High
+
+ );
+ }
+ return (
+
+ Normal
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatGrid.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatGrid.tsx
new file mode 100644
index 000000000..cc02413bd
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatGrid.tsx
@@ -0,0 +1,56 @@
+import type { LucideIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export interface StatItem {
+ label: string;
+ value: number | string;
+ hint?: string;
+ icon: LucideIcon;
+ accent?: "default" | "amber" | "emerald" | "rose";
+}
+
+const accentStyles = {
+ default: "bg-primary/10 text-primary",
+ amber: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
+ emerald: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
+ rose: "bg-rose-500/10 text-rose-700 dark:text-rose-400",
+};
+
+export function BookingStatGrid({ items }: { items: StatItem[] }) {
+ return (
+
+ {items.map((item) => {
+ const Icon = item.icon;
+ const accent = item.accent ?? "default";
+ return (
+
+
+
+
+ {item.label}
+
+
+ {item.value}
+
+ {item.hint && (
+
{item.hint}
+ )}
+
+
+
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx
new file mode 100644
index 000000000..25c3363c3
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx
@@ -0,0 +1,21 @@
+import { Badge } from "@edr/ui-common";
+import { cn } from "@/lib/utils";
+import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
+
+export function BookingStatusBadge({ status }: { status: string }) {
+ const style = BOOKING_STATUS_STYLES[status] ?? {
+ label: status,
+ color: "bg-muted text-muted-foreground border-border",
+ };
+ return (
+
+ {style.label}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx
new file mode 100644
index 000000000..86c7739fc
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx
@@ -0,0 +1,91 @@
+import {
+ ClipboardCheck,
+ FileSignature,
+ FileText,
+ Inbox,
+ LayoutGrid,
+ ShieldCheck,
+} from "lucide-react";
+
+import {
+ BOOKING_LIST_TABS,
+ type BookingStatusTabKey,
+} from "@/features/bookings/booking-status.config";
+import { cn } from "@/lib/utils";
+
+const TAB_ICONS: Record = {
+ all: ,
+ SUBMITTED: ,
+ PENDING_APPROVAL: ,
+ APPROVED_PENDING_SIGNATURE: ,
+ SIGNED_CUSTOMER: ,
+ PAYMENT_VERIFICATION_IN_PROGRESS: ,
+};
+
+interface BookingStatusTabsProps {
+ active: BookingStatusTabKey;
+ onChange: (tab: BookingStatusTabKey) => void;
+ counts?: Partial>;
+}
+
+export function BookingStatusTabs({
+ active,
+ onChange,
+ counts,
+}: BookingStatusTabsProps) {
+ return (
+
+
+ {BOOKING_LIST_TABS.map((tab) => {
+ const isActive = active === tab.key;
+ const count = counts?.[tab.key];
+ return (
+ onChange(tab.key)}
+ className={cn(
+ "flex min-w-[7.5rem] shrink-0 flex-col items-start gap-0.5 rounded-lg px-3.5 py-2.5 text-left transition-all duration-200",
+ isActive
+ ? "bg-background text-foreground shadow-sm ring-1 ring-border/80"
+ : "text-muted-foreground hover:bg-background/60 hover:text-foreground",
+ )}
+ >
+
+
+ {TAB_ICONS[tab.key]}
+ {tab.label}
+
+ {count !== undefined && count > 0 && (
+
+ {count}
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
+
+export type { BookingStatusTabKey };
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingTableEmpty.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingTableEmpty.tsx
new file mode 100644
index 000000000..84de06dcb
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingTableEmpty.tsx
@@ -0,0 +1,44 @@
+import { Package, Search } from "lucide-react";
+import { Button } from "@edr/ui-common";
+import { bookingSurface } from "./booking-ui.styles";
+
+interface BookingTableEmptyProps {
+ isError?: boolean;
+ hasSearch?: boolean;
+ onRetry?: () => void;
+}
+
+export function BookingTableEmpty({
+ isError,
+ hasSearch,
+ onRetry,
+}: BookingTableEmptyProps) {
+ return (
+
+
+
+
+ {isError
+ ? "Could not load bookings"
+ : hasSearch
+ ? "No matches on this page"
+ : "No bookings with this status"}
+
+
+ {isError
+ ? "Check your connection and try again."
+ : hasSearch
+ ? "Try a different reference or customer name."
+ : "New customer submissions will appear when status is Submitted."}
+
+
+ {isError && onRetry && (
+
+ Retry
+
+ )}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingWorkflowStepper.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingWorkflowStepper.tsx
new file mode 100644
index 000000000..fa68c1d67
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingWorkflowStepper.tsx
@@ -0,0 +1,124 @@
+import {
+ Check,
+ CheckCircle2,
+ FileSignature,
+ FileText,
+ Train,
+ Wallet,
+} from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import {
+ getWorkflowStageIndex,
+ WORKFLOW_STAGES,
+} from "@/features/bookings/booking-status.config";
+import { bookingSurface } from "./booking-ui.styles";
+
+const STAGE_ICONS = [FileText, FileSignature, FileSignature, Wallet, Train, Check];
+
+interface BookingWorkflowStepperProps {
+ status: string;
+ title: string;
+ description: string;
+ titleColor: string;
+}
+
+export function BookingWorkflowStepper({
+ status,
+ title,
+ description,
+ titleColor,
+}: BookingWorkflowStepperProps) {
+ const currentStage = getWorkflowStageIndex(status);
+ const isTerminal = currentStage < 0;
+
+ return (
+
+
+
+
+
+
+
+ Workflow progress
+
+
+ Customer submission through completion
+
+
+
+
+
+
+
= 0
+ ? `calc(${(currentStage / (WORKFLOW_STAGES.length - 1)) * 100}% - 2rem)`
+ : "0%",
+ }}
+ />
+
+ {WORKFLOW_STAGES.map((stage, idx) => {
+ const Icon = STAGE_ICONS[idx] ?? FileText;
+ const isCompleted = !isTerminal && idx < currentStage;
+ const isActive = !isTerminal && idx === currentStage;
+ return (
+
+
+ {isCompleted ? (
+
+ ) : (
+
+ )}
+
+
+ {stage.label}
+
+
+ );
+ })}
+
+
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ContractSignaturePad.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ContractSignaturePad.tsx
new file mode 100644
index 000000000..03dda6e32
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/ContractSignaturePad.tsx
@@ -0,0 +1,107 @@
+import { useEffect, useRef, useState } from "react";
+import { Eraser } from "lucide-react";
+
+import { Button } from "@edr/ui-common";
+import { cn } from "@/lib/utils";
+
+interface ContractSignaturePadProps {
+ onChange: (dataUrl: string | null) => void;
+ className?: string;
+}
+
+export function ContractSignaturePad({
+ onChange,
+ className,
+}: ContractSignaturePadProps) {
+ const canvasRef = useRef
(null);
+ const drawing = useRef(false);
+ const [empty, setEmpty] = useState(true);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ const dpr = window.devicePixelRatio || 1;
+ const w = canvas.offsetWidth;
+ const h = canvas.offsetHeight;
+ canvas.width = w * dpr;
+ canvas.height = h * dpr;
+ ctx.scale(dpr, dpr);
+ ctx.strokeStyle = "#111";
+ ctx.lineWidth = 2;
+ ctx.lineCap = "round";
+ }, []);
+
+ const getPos = (e: React.MouseEvent | React.TouchEvent) => {
+ const canvas = canvasRef.current!;
+ const rect = canvas.getBoundingClientRect();
+ if ("touches" in e) {
+ const t = e.touches[0];
+ return { x: t.clientX - rect.left, y: t.clientY - rect.top };
+ }
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
+ };
+
+ const start = (e: React.MouseEvent | React.TouchEvent) => {
+ drawing.current = true;
+ const ctx = canvasRef.current?.getContext("2d");
+ const { x, y } = getPos(e);
+ ctx?.beginPath();
+ ctx?.moveTo(x, y);
+ };
+
+ const move = (e: React.MouseEvent | React.TouchEvent) => {
+ if (!drawing.current) return;
+ const ctx = canvasRef.current?.getContext("2d");
+ const { x, y } = getPos(e);
+ ctx?.lineTo(x, y);
+ ctx?.stroke();
+ setEmpty(false);
+ onChange(canvasRef.current?.toDataURL("image/png") ?? null);
+ };
+
+ const end = () => {
+ drawing.current = false;
+ };
+
+ const clear = () => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ setEmpty(true);
+ onChange(null);
+ };
+
+ return (
+
+
+
+
+
+
+ Draw your signature above
+
+
+
+ Clear
+
+
+ {empty && (
+
Signature is required before confirming.
+ )}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts b/apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts
new file mode 100644
index 000000000..a86a5afd4
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/booking-ui.styles.ts
@@ -0,0 +1,32 @@
+/** Shared surfaces for booking list & detail — aligned with rule-engine polish. */
+
+export const bookingSurface = {
+ page: "min-h-screen bg-gradient-to-b from-muted/40 via-background to-background",
+ pageInner: "mx-auto max-w-[1600px] space-y-6 p-6 lg:p-8",
+ hero:
+ "relative overflow-hidden rounded-2xl border border-border/80 bg-card shadow-sm",
+ heroGlow:
+ "pointer-events-none absolute -right-20 -top-20 size-64 rounded-full bg-primary/10 blur-3xl",
+ panel:
+ "overflow-hidden rounded-xl border border-border bg-card shadow-sm",
+ panelToolbar:
+ "flex flex-wrap items-center justify-between gap-3 border-b border-border bg-muted/25 px-4 py-3.5 sm:px-5",
+ tableWrap: "px-0",
+ sectionCard:
+ "overflow-hidden rounded-xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md",
+ sectionHeader:
+ "flex items-center gap-3 border-b border-border/60 bg-muted/20 px-5 py-4",
+ sectionBody: "px-5 py-5",
+ detailHero:
+ "relative overflow-hidden rounded-2xl border border-border bg-gradient-to-br from-card via-card to-primary/[0.04] shadow-sm",
+ stickySidebar: "lg:sticky lg:top-6 lg:self-start",
+ metricTile:
+ "rounded-lg border border-border/70 bg-background/80 px-4 py-3 shadow-xs",
+ emptyState:
+ "flex flex-col items-center justify-center gap-3 px-6 py-16 text-center",
+} as const;
+
+export const bookingInput = {
+ search:
+ "h-10 w-full rounded-lg border border-input bg-background pl-10 text-sm shadow-xs transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25 sm:max-w-xs",
+} as const;
diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts
new file mode 100644
index 000000000..4b3570506
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts
@@ -0,0 +1,132 @@
+import { useCallback, useState } from "react";
+
+import {
+ getBookingActions,
+ getNextPendingApprovalStep,
+ type BookingActionContext,
+ type BookingActionDef,
+} from "@/features/bookings/booking-actions.config";
+import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
+
+export function useBookingActionDialog(
+ bookingId: string,
+ context: BookingActionContext,
+) {
+ const [pendingAction, setPendingAction] = useState(null);
+ const [inputValue, setInputValue] = useState("");
+ const [dialogOpen, setDialogOpen] = useState(false);
+
+ const needsApprovalSteps =
+ pendingAction?.id === "approve" || pendingAction?.id === "rejectApproval";
+
+ const { data: detail, isLoading: detailLoading } = useBookingDetail(
+ needsApprovalSteps ? bookingId : undefined,
+ );
+
+ const mergedContext: BookingActionContext = {
+ ...context,
+ approvalSteps: detail?.approvalSteps ?? context.approvalSteps,
+ reference: detail?.reference ?? context.reference,
+ };
+
+ const mutations = useBookingMutations(bookingId);
+ const actions = getBookingActions(mergedContext);
+
+ const openAction = useCallback((action: BookingActionDef) => {
+ setPendingAction(action);
+ setInputValue("");
+ setDialogOpen(true);
+ }, []);
+
+ const closeDialog = useCallback(() => {
+ setDialogOpen(false);
+ setPendingAction(null);
+ setInputValue("");
+ }, []);
+
+ const runAction = useCallback(() => {
+ if (!pendingAction) return;
+
+ const onSuccess = () => closeDialog();
+
+ switch (pendingAction.id) {
+ case "accept":
+ mutations.staffAccept.mutate(undefined, { onSuccess });
+ break;
+ case "requestChanges":
+ mutations.requestChanges.mutate(inputValue.trim(), { onSuccess });
+ break;
+ case "reject":
+ mutations.staffReject.mutate(inputValue.trim(), { onSuccess });
+ break;
+ case "approve": {
+ const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
+ if (!step) return;
+ mutations.approveStep.mutate(
+ { stepId: step.id, requiredRole: step.requiredRole },
+ { onSuccess },
+ );
+ break;
+ }
+ case "rejectApproval": {
+ const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
+ if (!step) return;
+ mutations.rejectStep.mutate(
+ { stepId: step.id, reason: inputValue.trim() },
+ { onSuccess },
+ );
+ break;
+ }
+ case "generateContract":
+ mutations.generateContract.mutate(undefined, { onSuccess });
+ break;
+ case "viewContract":
+ break;
+ case "generatePnr":
+ mutations.generatePnr.mutate(undefined, { onSuccess });
+ break;
+ case "verifyPayment":
+ mutations.verifyPayment.mutate(undefined, { onSuccess });
+ break;
+ case "startTransit":
+ mutations.startTransit.mutate(undefined, { onSuccess });
+ break;
+ case "complete":
+ mutations.complete.mutate(undefined, { onSuccess });
+ break;
+ default:
+ break;
+ }
+ }, [
+ pendingAction,
+ inputValue,
+ mergedContext.approvalSteps,
+ mutations,
+ closeDialog,
+ ]);
+
+ const confirmDisabled =
+ mutations.isPending ||
+ (needsApprovalSteps && detailLoading) ||
+ (pendingAction?.id === "approve" &&
+ !getNextPendingApprovalStep(mergedContext.approvalSteps));
+
+ return {
+ actions,
+ pendingAction,
+ inputValue,
+ setInputValue,
+ dialogOpen,
+ setDialogOpen: (open: boolean) => {
+ if (!open) closeDialog();
+ else setDialogOpen(true);
+ },
+ openAction,
+ closeDialog,
+ runAction,
+ mutations,
+ confirmDisabled,
+ detailLoading,
+ mergedContext,
+ };
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/cargoes/CargoesTable.tsx b/apps/edr-freight-web/backoffice/src/components/cargoes/CargoesTable.tsx
new file mode 100644
index 000000000..736af3fd5
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/cargoes/CargoesTable.tsx
@@ -0,0 +1,46 @@
+import { useCargoesByContainer, useDeliverCargo, useUnloadCargo } from '@/hooks/useCargoes';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { LoadCargoDialog } from './LoadCargoDialog';
+import type { Cargo } from '@/services/cargoService';
+
+export function CargoesTable({ containerId }: { containerId: string }) {
+ const { data: cargoes, refetch } = useCargoesByContainer(containerId);
+ const deliver = useDeliverCargo();
+ const unload = useUnloadCargo();
+
+ if (!cargoes?.length) return No cargoes for this container.
;
+
+ return (
+
+
+
+ Reference
+ Description
+ Quantity
+ Weight (kg)
+ Status
+ Actions
+
+
+
+ {cargoes.map((cargo: Cargo) => (
+
+ {cargo.cargoReference}
+ {cargo.description || '-'}
+ {cargo.quantity}
+ {cargo.weight}
+ {cargo.status}
+
+ {cargo.status === 'PENDING' && refetch()} />}
+ {cargo.status === 'LOADED' && deliver.mutateAsync(cargo.id).then(() => refetch())}>Deliver }
+ {cargo.status === 'LOADED' && unload.mutateAsync(cargo.id).then(() => refetch())}>Unload }
+
+
+ ))}
+
+
+ );
+}
+
diff --git a/apps/edr-freight-web/backoffice/src/components/cargoes/LoadCargoDialog.tsx b/apps/edr-freight-web/backoffice/src/components/cargoes/LoadCargoDialog.tsx
new file mode 100644
index 000000000..188726352
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/cargoes/LoadCargoDialog.tsx
@@ -0,0 +1,38 @@
+import { useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useLoadCargo } from '@/hooks/useCargoes';
+import { useToast } from '@/hooks/use-toast';
+
+export function LoadCargoDialog({ cargoId, onSuccess }: { cargoId: string; onSuccess?: () => void }) {
+ const [open, setOpen] = useState(false);
+ const [quantity, setQuantity] = useState(0);
+ const [weight, setWeight] = useState(0);
+ const [volume, setVolume] = useState();
+ const load = useLoadCargo();
+ const { toast } = useToast();
+
+ const handleLoad = async () => {
+ await load.mutateAsync({ id: cargoId, quantity, weight, volume });
+ toast({ title: 'Loaded', description: 'Cargo loaded into container.' });
+ setOpen(false);
+ onSuccess?.();
+ };
+
+ return (
+
+ Load Cargo
+
+ Load Cargo
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/components/container_management/AssignContainerDialog.tsx b/apps/edr-freight-web/backoffice/src/components/container_management/AssignContainerDialog.tsx
new file mode 100644
index 000000000..d10955933
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/container_management/AssignContainerDialog.tsx
@@ -0,0 +1,41 @@
+import { useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useContainers, useAssignContainerToWagon } from '@/hooks/useContainers';
+import { useToast } from '@/hooks/use-toast';
+import { Plus } from 'lucide-react';
+
+export function AssignContainerDialog({ wagonId }: { wagonId: string }) {
+ const [open, setOpen] = useState(false);
+ const [containerId, setContainerId] = useState('');
+ const [position, setPosition] = useState();
+ const { data: containers } = useContainers();
+ const assign = useAssignContainerToWagon();
+ const { toast } = useToast();
+
+ const available = containers?.filter(c => c.status === 'AVAILABLE' && !c.wagonId);
+
+ const handleAssign = async () => {
+ if (!containerId) return;
+ await assign.mutateAsync({ containerId, wagonId, position });
+ toast({ title: 'Assigned', description: 'Container placed on wagon.' });
+ setOpen(false);
+ };
+
+ return (
+
+ Assign Container
+
+ Assign Container to Wagon
+
+
Container {available?.map(c => {c.containerNumber} )}
+
Position (optional) setPosition(parseInt(e.target.value) || undefined)} />
+
Assign
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/components/container_management/ContainersTable.tsx b/apps/edr-freight-web/backoffice/src/components/container_management/ContainersTable.tsx
new file mode 100644
index 000000000..183a6a628
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/container_management/ContainersTable.tsx
@@ -0,0 +1,44 @@
+import { useContainersByWagon, useUnassignContainer } from '@/hooks/useContainers';
+import { Button } from '@/components/ui/button';
+import { Trash2 } from 'lucide-react';
+import type { Container } from '@/services/containerService';
+
+export function ContainersTable({ wagonId }: { wagonId: string }) {
+ const { data: containers, refetch } = useContainersByWagon(wagonId);
+ const unassign = useUnassignContainer();
+
+ if (!containers?.length) return No containers assigned.
;
+
+ return (
+
+
+
+ Number
+ Type
+ Position
+ Status
+ Actions
+
+
+
+ {containers.map((container: Container) => (
+
+ {container.containerNumber}
+ {container.containerTypeId}
+ {container.position}
+ {container.status}
+
+ unassign.mutateAsync(container.id).then(() => refetch())}
+ >
+
+
+
+
+ ))}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ContractType copy.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ContractType copy.tsx
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/rateMatrixRules.ts b/apps/edr-freight-web/backoffice/src/components/ruleEngine/rateMatrixRules.ts
new file mode 100644
index 000000000..f0080c5a3
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/rateMatrixRules.ts
@@ -0,0 +1,173 @@
+// ruleEngine/rateMatrixRules.ts
+import { RATE_TYPES, REQUIRED_RATE_TYPES, MATRIX_STATUS } from '@/constants/rateMatrixConstants';
+
+interface RateEntry {
+ rateType: string;
+ entries: Array>;
+}
+
+interface ValidationRule {
+ id: string;
+ description: string;
+ severity: 'error' | 'warning';
+ validate: (data: any) => boolean;
+ message: string;
+}
+
+export class RateMatrixRulesEngine {
+ private rules: ValidationRule[] = [];
+
+ constructor() {
+ this.initializeRules();
+ }
+
+ private initializeRules() {
+ // Rule 1: All rate types must be present
+ this.rules.push({
+ id: 'ALL_TYPES_REQUIRED',
+ description: 'Verify all 13 rate types are included',
+ severity: 'error',
+ validate: (rateSections: RateEntry[]) => {
+ const submittedTypes = rateSections.map(s => s.rateType);
+ return REQUIRED_RATE_TYPES.every(type => submittedTypes.includes(type));
+ },
+ message: 'All 13 rate types must be included in the submission',
+ });
+
+ // Rule 2: Each rate type must have at least one entry
+ this.rules.push({
+ id: 'MINIMUM_ENTRIES',
+ description: 'Each rate type requires at least one rate entry',
+ severity: 'error',
+ validate: (rateSections: RateEntry[]) => {
+ return rateSections.every(section => section.entries.length > 0);
+ },
+ message: 'Each rate type must have at least one rate entry',
+ });
+
+ // Rule 3: Dates must be valid
+ this.rules.push({
+ id: 'VALID_DATES',
+ description: 'Rate entries must have valid date ranges',
+ severity: 'error',
+ validate: (rateSections: RateEntry[]) => {
+ return rateSections.every(section =>
+ section.entries.every(entry => {
+ if (!entry.validFrom) return false;
+ if (entry.validTo && new Date(entry.validTo) <= new Date(entry.validFrom)) {
+ return false;
+ }
+ return true;
+ })
+ );
+ },
+ message: 'All rate entries must have valid dates (Valid To must be after Valid From)',
+ });
+
+ // Rule 4: Rates must be non-negative
+ this.rules.push({
+ id: 'NON_NEGATIVE_RATES',
+ description: 'All rate values must be non-negative',
+ severity: 'error',
+ validate: (rateSections: RateEntry[]) => {
+ const numericFields = ['baseRate', 'ratePerMetricTon', 'ratePerKm',
+ 'ratePerTrip', 'ratePerDay', 'ratePerUnit'];
+
+ return rateSections.every(section =>
+ section.entries.every(entry => {
+ return numericFields.every(field => {
+ const value = entry[field];
+ return value === undefined || value === '' || Number(value) >= 0;
+ });
+ })
+ );
+ },
+ message: 'Rate values cannot be negative',
+ });
+
+ // Rule 5: Business rule - Demurrage free days should be reasonable
+ this.rules.push({
+ id: 'DEMURRAGE_FREE_DAYS',
+ description: 'Demurrage free days should be between 0 and 30',
+ severity: 'warning',
+ validate: (rateSections: RateEntry[]) => {
+ const demurrageSection = rateSections.find(
+ s => s.rateType === RATE_TYPES.DEMURRAGE
+ );
+ if (!demurrageSection) return true;
+
+ return demurrageSection.entries.every(entry => {
+ const freeDays = Number(entry.freeDays);
+ return !freeDays || (freeDays >= 0 && freeDays <= 30);
+ });
+ },
+ message: 'Demurrage free days typically range from 0 to 30 days',
+ });
+
+ // Rule 6: Cancellation fee percentage should be 0-100
+ this.rules.push({
+ id: 'CANCELLATION_FEE_RANGE',
+ description: 'Cancellation fee percentage must be between 0 and 100',
+ severity: 'error',
+ validate: (rateSections: RateEntry[]) => {
+ const cancellationSection = rateSections.find(
+ s => s.rateType === RATE_TYPES.CANCELLATION_FEE
+ );
+ if (!cancellationSection) return true;
+
+ return cancellationSection.entries.every(entry => {
+ const percentage = Number(entry.cancellationFeePercentage);
+ return !percentage || (percentage >= 0 && percentage <= 100);
+ });
+ },
+ message: 'Cancellation fee percentage must be between 0 and 100',
+ });
+ }
+
+ validate(data: RateEntry[]) {
+ const errors: Array<{ ruleId: string; message: string; severity: string }> = [];
+ const warnings: Array<{ ruleId: string; message: string; severity: string }> = [];
+
+ this.rules.forEach(rule => {
+ if (!rule.validate(data)) {
+ const issue = {
+ ruleId: rule.id,
+ message: rule.message,
+ severity: rule.severity,
+ };
+
+ if (rule.severity === 'error') {
+ errors.push(issue);
+ } else {
+ warnings.push(issue);
+ }
+ }
+ });
+
+ return {
+ isValid: errors.length === 0,
+ errors,
+ warnings,
+ };
+ }
+
+ // Check if matrix can transition to a new status
+ canTransition(fromStatus: string, toStatus: string, userRole: string): boolean {
+ const transitions: Record> = {
+ [MATRIX_STATUS.DRAFT]: [
+ { to: MATRIX_STATUS.PENDING_APPROVAL, allowedRoles: ['Director'] },
+ ],
+ [MATRIX_STATUS.PENDING_APPROVAL]: [
+ { to: MATRIX_STATUS.ACTIVE, allowedRoles: ['Chief Executive'] },
+ { to: MATRIX_STATUS.REJECTED, allowedRoles: ['Chief Executive'] },
+ ],
+ };
+
+ const allowedTransitions = transitions[fromStatus] || [];
+ const transition = allowedTransitions.find(t => t.to === toStatus);
+
+ return transition ? transition.allowedRoles.includes(userRole) : false;
+ }
+}
+
+export const rateMatrixRulesEngine = new RateMatrixRulesEngine();
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx
index 079bb2727..966ba8ba1 100644
--- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx
@@ -61,5 +61,45 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleDateString();
}
+ if (format === "entityLabel" && value && typeof value === "object") {
+ const entity = value as { label?: string; code?: string; cargoTypeName?: string };
+ const label =
+ entity.label?.trim() ||
+ entity.cargoTypeName?.trim() ||
+ entity.code?.trim();
+ return label ? (
+ {label}
+ ) : (
+ —
+ );
+ }
+
+ if (format === "rateLabel") {
+ if (!value || typeof value !== "object") {
+ return value ? (
+ {String(value)}
+ ) : (
+ —
+ );
+ }
+ const rate = value as {
+ rateType?: string;
+ currency?: string;
+ rateValue?: number;
+ rateUnit?: string;
+ };
+ const parts = [
+ rate.rateType?.replace(/_/g, " "),
+ rate.currency,
+ rate.rateValue != null ? String(rate.rateValue) : "",
+ rate.rateUnit?.replace(/_/g, " "),
+ ].filter(Boolean);
+ return parts.length > 0 ? (
+ {parts.join(" · ")}
+ ) : (
+ —
+ );
+ }
+
return String(value);
};
diff --git a/apps/edr-freight-web/backoffice/src/components/trains/TrainDetailCard.tsx b/apps/edr-freight-web/backoffice/src/components/trains/TrainDetailCard.tsx
new file mode 100644
index 000000000..1d533a942
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trains/TrainDetailCard.tsx
@@ -0,0 +1,19 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Train } from '@/services/trainService';
+
+export function TrainDetailCard({ train }: { train: Train }) {
+ return (
+
+ {train.trainNumber || train.code} - {train.trainName || 'Unnamed'}
+
+ Status: {train.status}
+ Capacity: {train.capacityTons} tons
+ Origin: {train.originStationId || '-'}
+ Destination: {train.destinationStationId || '-'}
+ Departure: {train.departureTime ? new Date(train.departureTime).toLocaleString() : '-'}
+ Arrival: {train.arrivalTime ? new Date(train.arrivalTime).toLocaleString() : '-'}
+ {train.remarks && Remarks: {train.remarks}
}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/components/trains/TrainFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/trains/TrainFormDialog.tsx
new file mode 100644
index 000000000..a73876e7d
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trains/TrainFormDialog.tsx
@@ -0,0 +1,59 @@
+import { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useCreateTrain, useUpdateTrain } from '@/hooks/useTrains';
+import { useToast } from '@/hooks/use-toast';
+
+interface TrainFormDialogProps {
+ trigger?: React.ReactNode;
+ train?: any;
+ onSuccess?: () => void;
+}
+
+export function TrainFormDialog({ trigger, train, onSuccess }: TrainFormDialogProps) {
+ const [open, setOpen] = useState(false);
+ const [form, setForm] = useState({ code: '', capacityTons: 0, trainNumber: '', trainName: '' });
+ const createTrain = useCreateTrain();
+ const updateTrain = useUpdateTrain();
+ const { toast } = useToast();
+
+ useEffect(() => {
+ if (train) setForm({
+ code: train.code,
+ capacityTons: train.capacityTons,
+ trainNumber: train.trainNumber || '',
+ trainName: train.trainName || '',
+ });
+ }, [train]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ try {
+ if (train) await updateTrain.mutateAsync({ id: train.id, data: form });
+ else await createTrain.mutateAsync(form);
+ toast({ title: train ? 'Train updated' : 'Train created', description: `${form.code} saved.` });
+ setOpen(false);
+ onSuccess?.();
+ } catch {
+ toast({ title: 'Error', description: `Failed to ${train ? 'update' : 'create'} train.`, variant: 'destructive' });
+ }
+ };
+
+ return (
+
+ {trigger || New Train }
+
+ {train ? 'Edit Train' : 'Create Train'}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/components/trains/TrainsTable.tsx b/apps/edr-freight-web/backoffice/src/components/trains/TrainsTable.tsx
new file mode 100644
index 000000000..2751fbe39
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trains/TrainsTable.tsx
@@ -0,0 +1,45 @@
+import { useTrains, useDeleteTrain } from '@/hooks/useTrains';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Eye, Trash2 } from 'lucide-react';
+import { Link } from 'react-router-dom';
+
+export function TrainsTable() {
+ const { data: trains, isLoading } = useTrains();
+ const deleteTrain = useDeleteTrain();
+
+ if (isLoading) return Loading trains...
;
+
+ return (
+
+
+
+ Number
+ Name
+ Status
+ Capacity (tons)
+ Actions
+
+
+
+ {trains?.map(train => (
+
+ {train.trainNumber || train.code}
+ {train.trainName || '-'}
+ {train.status}
+ {train.capacityTons}
+
+
+
+
+ deleteTrain.mutate(train.id)}>
+
+
+
+
+ ))}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/components/ui/badge.tsx b/apps/edr-freight-web/backoffice/src/components/ui/badge.tsx
new file mode 100644
index 000000000..0512e9936
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/ui/badge.tsx
@@ -0,0 +1,47 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Slot } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
+ outline:
+ "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 [a&]:hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Badge({
+ className,
+ variant = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "span";
+
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/apps/edr-freight-web/backoffice/src/components/ui/index.ts b/apps/edr-freight-web/backoffice/src/components/ui/index.ts
new file mode 100644
index 000000000..21dcac6e4
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/ui/index.ts
@@ -0,0 +1,8 @@
+export * from './table';
+export * from './badge';
+export * from './button';
+export * from './dialog';
+export * from './input';
+export * from './label';
+export * from './textarea';
+export * from './Breadcrumbs';
diff --git a/apps/edr-freight-web/backoffice/src/components/ui/table.tsx b/apps/edr-freight-web/backoffice/src/components/ui/table.tsx
new file mode 100644
index 000000000..128912911
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/ui/table.tsx
@@ -0,0 +1,114 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return (
+
+ );
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return (
+
+ );
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return (
+
+ );
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+ return (
+ tr]:last:border-b-0",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ );
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ );
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+};
diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/AssignWagonDialog.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/AssignWagonDialog.tsx
new file mode 100644
index 000000000..1f14ecb13
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/wagons/AssignWagonDialog.tsx
@@ -0,0 +1,52 @@
+import { useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useWagons, useAssignWagonToTrain } from '@/hooks/useWagons';
+import { useToast } from '@/hooks/use-toast';
+import { Plus } from 'lucide-react';
+
+export function AssignWagonDialog({ trainId }: { trainId: string }) {
+ const [open, setOpen] = useState(false);
+ const [wagonId, setWagonId] = useState('');
+ const [sequence, setSequence] = useState();
+ const { data: wagons } = useWagons();
+ const assign = useAssignWagonToTrain();
+ const { toast } = useToast();
+
+ const available = wagons?.filter(w => w.status === 'AVAILABLE' || !w.trainId);
+
+ const handleAssign = async () => {
+ if (!wagonId) return;
+ await assign.mutateAsync({ wagonId, trainId, sequenceNumber: sequence });
+ toast({ title: 'Assigned', description: 'Wagon attached to train.' });
+ setOpen(false);
+ };
+
+ return (
+
+ Assign Wagon
+
+ Assign Wagon to Train
+
+
+ Wagon
+
+
+
+ {available?.map(w => {w.wagonNumber} )}
+
+
+
+
+ Sequence (optional)
+ setSequence(parseInt(e.target.value) || undefined)} />
+
+
Assign
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonFormDialog.tsx
new file mode 100644
index 000000000..2fb36cd73
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonFormDialog.tsx
@@ -0,0 +1,71 @@
+// src/components/wagons/WagonFormDialog.tsx
+import { useState, useEffect } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useCreateWagon, useUpdateWagon } from '@/hooks/useWagons';
+import { useToast } from '@/hooks/use-toast';
+
+interface WagonFormDialogProps {
+ trigger?: React.ReactNode;
+ wagon?: any;
+ onSuccess?: () => void;
+}
+
+export function WagonFormDialog({ trigger, wagon, onSuccess }: WagonFormDialogProps) {
+ const [open, setOpen] = useState(false);
+ const [form, setForm] = useState({
+ wagonNumber: '',
+ wagonTypeId: '',
+ tareWeight: 0,
+ maxPayloadWeight: 0,
+ status: 'AVAILABLE',
+ notes: ''
+ });
+ const createWagon = useCreateWagon();
+ const updateWagon = useUpdateWagon();
+ const { toast } = useToast();
+
+ useEffect(() => {
+ if (wagon) setForm({
+ wagonNumber: wagon.wagonNumber,
+ wagonTypeId: wagon.wagonTypeId,
+ tareWeight: wagon.tareWeight,
+ maxPayloadWeight: wagon.maxPayloadWeight,
+ status: wagon.status,
+ notes: wagon.notes || ''
+ });
+ }, [wagon]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ try {
+ if (wagon) await updateWagon.mutateAsync({ id: wagon.id, data: form });
+ else await createWagon.mutateAsync(form);
+ toast({ title: wagon ? 'Wagon updated' : 'Wagon created', description: `${form.wagonNumber} saved.` });
+ setOpen(false);
+ onSuccess?.();
+ } catch {
+ toast({ title: 'Error', description: `Failed to ${wagon ? 'update' : 'create'} wagon.`, variant: 'destructive' });
+ }
+ };
+
+ return (
+
+ {trigger || New Wagon }
+
+ {wagon ? 'Edit Wagon' : 'Create Wagon'}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonsTable.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonsTable.tsx
new file mode 100644
index 000000000..5b372030f
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonsTable.tsx
@@ -0,0 +1,63 @@
+import { useWagonsByTrain, useUnassignWagon, useReorderWagons } from '@/hooks/useWagons';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Button } from '@/components/ui/button';
+import { Trash2, GripVertical } from 'lucide-react';
+import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
+
+export function WagonsTable({ trainId }: { trainId: string }) {
+ const { data: wagons, refetch } = useWagonsByTrain(trainId);
+ const unassign = useUnassignWagon();
+ const reorder = useReorderWagons();
+
+ const onDragEnd = (result: any) => {
+ if (!result.destination) return;
+ const items = Array.from(wagons || []);
+ const [removed] = items.splice(result.source.index, 1);
+ items.splice(result.destination.index, 0, removed);
+ reorder.mutate({ trainId, wagonIds: items.map(w => w.id) });
+ };
+
+ if (!wagons?.length) return No wagons assigned.
;
+
+ return (
+
+
+ {(provided) => (
+
+
+
+
+ Number
+ Type
+ Sequence
+ Status
+ Actions
+
+
+
+ {wagons.map((wagon, idx) => (
+
+ {(provided) => (
+
+
+ {wagon.wagonNumber}
+ {wagon.wagonTypeId}
+ {wagon.sequenceNumber}
+ {wagon.status}
+
+ unassign.mutateAsync(wagon.id).then(() => refetch())}>
+
+
+
+
+ )}
+
+ ))}
+ {provided.placeholder}
+
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
new file mode 100644
index 000000000..aa1b446d5
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
@@ -0,0 +1,61 @@
+import type { BookingListFilter } from "@/services/bookings.service";
+import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
+import type { TrainScheduleFilters } from "@/types/trainScheduling";
+import type { RuleEngineResourceSlug } from "@/types/rule-engine";
+
+export const QUERY_KEYS = {
+ USERS: {
+ ROOT: ["users"] as const,
+ ADD: ["users", "add"] as const,
+ },
+
+ FILES: {
+ ROOT: ["file-upload-settings"] as const,
+ list: () => ["file-upload-settings", "list"] as const,
+ byId: (id: string) => ["file-upload-settings", "detail", id] as const,
+ byCode: (code: string) => ["file-upload-settings", "by-code", code] as const,
+ },
+
+ DROPDOWN_SETTINGS: {
+ ROOT: ["dropdown-settings"] as const,
+ list: () => ["dropdown-settings", "list"] as const,
+ byId: (id: string) => ["dropdown-settings", "detail", id] as const,
+ byCode: (code: string) => ["dropdown-settings", "by-code", code] as const,
+ },
+
+ CUSTOMERS: {
+ ROOT: ["customers"] as const,
+ list: () => ["customers", "list"] as const,
+ byId: (id: string) => ["customers", "detail", id] as const,
+ },
+
+ BOOKINGS: {
+ ROOT: ["bookings"] as const,
+ list: (filter?: BookingListFilter) =>
+ ["bookings", "list", filter ?? {}] as const,
+ byId: (id: string) => ["bookings", "detail", id] as const,
+ },
+
+ TRAIN_SCHEDULING: {
+ ROOT: ["train-scheduling"] as const,
+ eligible: (filters?: TrainScheduleFilters) =>
+ ["train-scheduling", "eligible-bookings", filters ?? {}] as const,
+ locomotives: () => ["train-scheduling", "locomotives"] as const,
+ stations: () => ["train-scheduling", "stations"] as const,
+ schedules: () => ["train-scheduling", "schedules"] as const,
+ scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
+ },
+
+ RULE_ENGINE: {
+ ROOT: ["rule-engine"] as const,
+ list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) =>
+ ["rule-engine", "list", resource, params ?? {}] as const,
+ detail: (resource: RuleEngineResourceSlug | string, id: string) =>
+ ["rule-engine", "detail", resource, id] as const,
+ chain: ["rule-engine", "approval-rules", "chain"] as const,
+ selectOptions: (
+ resource: RuleEngineResourceSlug | string,
+ params?: Record,
+ ) => ["rule-engine", "select-options", resource, params ?? {}] as const,
+ },
+} as const;
diff --git a/apps/edr-freight-web/backoffice/src/constants/TANSTACK_QUEY_KEY.ts b/apps/edr-freight-web/backoffice/src/constants/TANSTACK_QUEY_KEY.ts
deleted file mode 100644
index fca149d58..000000000
--- a/apps/edr-freight-web/backoffice/src/constants/TANSTACK_QUEY_KEY.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-export const QUERY_KEYS = {
- USERS: "users",
- ADD_USER: "add_user",
- CUSTOMER: "Customers",
- FILES: {
- FILE_UPLOAD_SETTINGS: "file-upload-settings",
- BY_CODE: "by-code"
- },
- DROPDOWN_SETTINGS: {
- ROOT: "dropdown-settings",
- LIST: "list",
- BY_ID: "by-id",
- BY_CODE: "by-code"
- },
- CUSTOMERS: {
- ROOT: "customers",
- LIST: "list",
- BY_ID: "by-id"
- },
- RULE_ENGINE: {
- ROOT: "rule-engine",
- list: (resource: string) => ["rule-engine", resource, "list"] as const,
- chain: ["rule-engine", "approval-rules", "chain"] as const,
- },
-}
diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
index f5c75395e..8ce976c4d 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -77,9 +77,34 @@ export const URL_CONSTANTS = {
BOOKINGS: {
BASE: "/bookings",
- BY_ID: (id: string | number) => `/bookings/${id}`,
- CANCEL: (id: string | number) => `/bookings/${id}/cancel`,
- CONFIRM: (id: string | number) => `/bookings/${id}/confirm`,
+ REFERENCE_DATA: "/bookings/reference-data",
+ BY_ID: (id: string) => `/bookings/${id}`,
+ QUEUE: (queue: string) => `/bookings/queues/${queue}`,
+ STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`,
+ STAFF_REQUEST_CHANGES: (id: string) =>
+ `/bookings/${id}/staff/request-changes`,
+ STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`,
+ APPROVE_STEP: (id: string, stepId: string) =>
+ `/bookings/${id}/approval-steps/${stepId}/approve`,
+ REJECT_STEP: (id: string, stepId: string) =>
+ `/bookings/${id}/approval-steps/${stepId}/reject`,
+ CONTRACT_GENERATE: (id: string) => `/bookings/${id}/contract/generate`,
+ CONTRACT_VIEW: (id: string) => `/bookings/${id}/contract/view`,
+ CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`,
+ CONTRACT_SIGN: (id: string) => `/bookings/${id}/contract/sign`,
+ CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
+ SUMMARY: (id: string) => `/bookings/${id}/summary`,
+ CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`,
+ MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`,
+ PAYMENT_PNR: (id: string) => `/bookings/${id}/payment/pnr`,
+ PAYMENT_PROOF: (id: string) => `/bookings/${id}/payment/proof`,
+ PAYMENT_VERIFY: (id: string) => `/bookings/${id}/payment/verify`,
+ PAYMENT_REQUEST_LETTER: (id: string) =>
+ `/bookings/${id}/payment/request-letter`,
+ START_TRANSIT: (id: string) => `/bookings/${id}/operations/start-transit`,
+ COMPLETE: (id: string) => `/bookings/${id}/operations/complete`,
+ CANCEL: (id: string) => `/bookings/${id}/cancel`,
+ CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`,
},
OTP: {
@@ -87,6 +112,19 @@ export const URL_CONSTANTS = {
VERIFY: "/api/otp/verify",
},
+ LOCOMOTIVES: {
+ BASE: "/locomotives",
+ },
+
+ TRAIN_SCHEDULING: {
+ ELIGIBLE_BOOKINGS: "/train-scheduling/container/eligible-bookings",
+ PREVIEW: "/train-scheduling/container/preview",
+ SCHEDULES: "/train-scheduling/container/schedules",
+ SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
+ CANCEL_SCHEDULE: (id: string) =>
+ `/train-scheduling/container/schedules/${id}/cancel`,
+ },
+
RULE_ENGINE: {
CARGO_TYPES: "/cargo-types",
CARGO_TYPE_BY_ID: (id: string) => `/cargo-types/${id}`,
@@ -122,4 +160,18 @@ export const URL_CONSTANTS = {
APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`,
APPROVAL_RULES_CHAIN: "/approval-rules/chain",
},
+ RATE_MATRIX: {
+ BASE: '/api/rate-matrices',
+ DRAFT: '/api/rate-matrices/draft',
+ SUBMIT: (id: string) => `/api/rate-matrices/${id}/submit`,
+ AUTHORIZE: (id: string) => `/api/rate-matrices/${id}/authorize`,
+ LIST: '/api/rate-matrices',
+ DETAIL: (id: string) => `/api/rate-matrices/${id}`,
+ },
+ REFERENCE: {
+ PORTS: '/api/reference/ports',
+ CITIES: '/api/reference/cities',
+ CONTAINER_TYPES: '/api/reference/container-types',
+ CURRENCIES: '/api/reference/currencies',
+ },
};
diff --git a/apps/edr-freight-web/backoffice/src/constants/rateMatrixConstants.ts b/apps/edr-freight-web/backoffice/src/constants/rateMatrixConstants.ts
new file mode 100644
index 000000000..ff8d3bb80
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/constants/rateMatrixConstants.ts
@@ -0,0 +1,60 @@
+// constants/rateMatrixConstants.ts
+export const RATE_TYPES = {
+ CONTAINER_IMPORT: 'container_import',
+ CONTAINER_EXPORT: 'container_export',
+ BULK_IMPORT: 'bulk_import',
+ BULK_EXPORT: 'bulk_export',
+ INTER_CITY_BULK: 'inter_city_bulk',
+ INTER_CITY_CONTAINER: 'inter_city_container',
+ FIRST_MILE: 'first_mile',
+ LAST_MILE: 'last_mile',
+ DEMURRAGE: 'demurrage',
+ LASHING: 'lashing',
+ DOUBLE_HANDLING: 'double_handling',
+ CONTAINER_WITH_RETURN: 'container_with_return',
+ CANCELLATION_FEE: 'cancellation_fee',
+} as const;
+
+export const RATE_TYPE_LABELS = {
+ [RATE_TYPES.CONTAINER_IMPORT]: 'Container Import Rates',
+ [RATE_TYPES.CONTAINER_EXPORT]: 'Container Export Rates',
+ [RATE_TYPES.BULK_IMPORT]: 'Bulk Import Rates',
+ [RATE_TYPES.BULK_EXPORT]: 'Bulk Export Rates',
+ [RATE_TYPES.INTER_CITY_BULK]: 'Inter City Bulk Rates',
+ [RATE_TYPES.INTER_CITY_CONTAINER]: 'Inter City Container Rates',
+ [RATE_TYPES.FIRST_MILE]: 'First Mile Cost Rates',
+ [RATE_TYPES.LAST_MILE]: 'Last Mile Cost Rates',
+ [RATE_TYPES.DEMURRAGE]: 'Demurrage Cost Rates',
+ [RATE_TYPES.LASHING]: 'Lashing Cost Rates',
+ [RATE_TYPES.DOUBLE_HANDLING]: 'Double Handling Cost Rates',
+ [RATE_TYPES.CONTAINER_WITH_RETURN]: 'Container With Return Cost Rates',
+ [RATE_TYPES.CANCELLATION_FEE]: 'Cancellation Fee Cost Rates',
+} as const;
+
+export const REQUIRED_RATE_TYPES = Object.values(RATE_TYPES);
+
+export const RATE_FIELDS_CONFIG = {
+ [RATE_TYPES.CONTAINER_IMPORT]: [
+ { name: 'portOfLoading', label: 'Port of Loading', type: 'text', required: true },
+ { name: 'portOfDischarge', label: 'Port of Discharge', type: 'text', required: true },
+ { name: 'containerType', label: 'Container Type', type: 'select', required: true },
+ { name: 'baseRate', label: 'Base Rate', type: 'number', required: true, min: 0, step: '0.01' },
+ { name: 'baf', label: 'Bunker Adjustment Factor', type: 'number', required: false, min: 0 },
+ { name: 'caf', label: 'Currency Adjustment Factor', type: 'number', required: false, min: 0 },
+ ],
+ [RATE_TYPES.DEMURRAGE]: [
+ { name: 'containerType', label: 'Container Type', type: 'select', required: true },
+ { name: 'freeDays', label: 'Free Days', type: 'number', required: true, min: 0 },
+ { name: 'ratePerDay', label: 'Rate per Day', type: 'number', required: true, min: 0 },
+ { name: 'maximumDays', label: 'Maximum Days', type: 'number', required: false, min: 1 },
+ ],
+ // ... define for all 13 rate types
+} as const;
+
+export const MATRIX_STATUS = {
+ DRAFT: 'draft',
+ PENDING_APPROVAL: 'pending_approval',
+ ACTIVE: 'active',
+ REJECTED: 'rejected',
+ EXPIRED: 'expired',
+} as const;
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts
new file mode 100644
index 000000000..d1bd6f867
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts
@@ -0,0 +1,268 @@
+import type { LucideIcon } from "lucide-react";
+import {
+ Ban,
+ Check,
+ FileSignature,
+ FileText,
+ MessageSquareWarning,
+ Play,
+ ShieldCheck,
+ Truck,
+ Wallet,
+ XCircle,
+} from "lucide-react";
+
+import type {
+ BookingApprovalStep,
+ BookingDetail,
+ BookingStatus,
+} from "@/types/booking";
+
+export type BookingActionId =
+ | "accept"
+ | "requestChanges"
+ | "reject"
+ | "approve"
+ | "rejectApproval"
+ | "generateContract"
+ | "viewContract"
+ | "generatePnr"
+ | "verifyPayment"
+ | "startTransit"
+ | "complete";
+
+export type BookingActionInputKind = "note" | "reason";
+
+export interface BookingActionDef {
+ id: BookingActionId;
+ label: string;
+ shortLabel: string;
+ description: string;
+ confirmTitle: string;
+ confirmDescription: string;
+ variant: "default" | "destructive" | "outline";
+ icon: LucideIcon;
+ input?: BookingActionInputKind;
+ inputLabel?: string;
+ inputPlaceholder?: string;
+ primary?: boolean;
+}
+
+export type BookingActionContext = Pick<
+ BookingDetail,
+ "status" | "paymentCurrency" | "approvalSteps" | "reference"
+>;
+
+export function getNextPendingApprovalStep(
+ steps?: BookingApprovalStep[] | null,
+): BookingApprovalStep | undefined {
+ if (!steps?.length) return undefined;
+ return [...steps]
+ .sort((a, b) => a.stepOrder - b.stepOrder)
+ .find((s) => s.status === "PENDING");
+}
+
+function approvalActions(
+ steps?: BookingApprovalStep[] | null,
+): BookingActionDef[] {
+ const next = getNextPendingApprovalStep(steps);
+ if (!next) return [];
+ return [
+ {
+ id: "approve",
+ label: `Approve (${next.requiredRole})`,
+ shortLabel: "Approve",
+ description: `Complete step ${next.stepOrder} as ${next.requiredRole}`,
+ confirmTitle: `Approve as ${next.requiredRole}?`,
+ confirmDescription:
+ "This records your approval and advances the booking to the next step in the chain.",
+ variant: "default",
+ icon: Check,
+ primary: true,
+ },
+ {
+ id: "rejectApproval",
+ label: "Reject approval",
+ shortLabel: "Reject",
+ description: "Reject at the current approval step",
+ confirmTitle: "Reject at approval step?",
+ confirmDescription:
+ "The booking will be marked rejected. This action cannot be undone from the UI.",
+ variant: "destructive",
+ icon: XCircle,
+ input: "reason",
+ inputLabel: "Rejection reason",
+ inputPlaceholder: "Explain why this booking is rejected…",
+ },
+ ];
+}
+
+const SUBMITTED_ACTIONS: BookingActionDef[] = [
+ {
+ id: "accept",
+ label: "Accept for approval",
+ shortLabel: "Accept",
+ description: "Start the formal approval chain",
+ confirmTitle: "Accept submission?",
+ confirmDescription:
+ "The booking moves to pending approval and approval steps are created from the rule engine.",
+ variant: "default",
+ icon: ShieldCheck,
+ primary: true,
+ },
+ {
+ id: "requestChanges",
+ label: "Request changes",
+ shortLabel: "Changes",
+ description: "Ask the customer to update and resubmit",
+ confirmTitle: "Request changes from customer?",
+ confirmDescription:
+ "The customer will see your note and can edit the booking before resubmitting.",
+ variant: "outline",
+ icon: MessageSquareWarning,
+ input: "note",
+ inputLabel: "Message to customer",
+ inputPlaceholder: "Describe what needs to be corrected or added…",
+ },
+ {
+ id: "reject",
+ label: "Reject booking",
+ shortLabel: "Reject",
+ description: "Reject this submission",
+ confirmTitle: "Reject booking?",
+ confirmDescription:
+ "The booking will be marked rejected and removed from active queues.",
+ variant: "destructive",
+ icon: Ban,
+ input: "reason",
+ inputLabel: "Rejection reason",
+ inputPlaceholder: "Reason for rejection…",
+ },
+];
+
+/** Actions available for the current booking status (detail or list). */
+export function getBookingActions(ctx: BookingActionContext): BookingActionDef[] {
+ const { status, paymentCurrency, approvalSteps } = ctx;
+
+ switch (status) {
+ case "SUBMITTED":
+ return SUBMITTED_ACTIONS;
+ case "PENDING_APPROVAL":
+ case "APPROVED_PENDING_SIGNATURE":
+ return approvalActions(approvalSteps);
+ case "APPROVED":
+ return [
+ {
+ id: "generateContract",
+ label: "Generate contract",
+ shortLabel: "Contract",
+ description: "Create contract document",
+ confirmTitle: "Generate contract?",
+ confirmDescription:
+ "A contract will be generated and the booking moves to contract ready.",
+ variant: "default",
+ icon: FileText,
+ primary: true,
+ },
+ ];
+ case "CONTRACT_READY":
+ case "SIGNED_CUSTOMER":
+ case "FULLY_EXECUTED":
+ return [
+ {
+ id: "viewContract",
+ label:
+ status === "SIGNED_CUSTOMER"
+ ? "View & sign contract (staff)"
+ : status === "CONTRACT_READY"
+ ? "View contract"
+ : "View executed contract",
+ shortLabel: "Contract",
+ description: "Open contract document and signatures",
+ confirmTitle: "",
+ confirmDescription: "",
+ variant: "default",
+ icon: FileSignature,
+ primary: true,
+ },
+ ];
+ case "FULLY_EXECUTED":
+ if (paymentCurrency === "ETB") {
+ return [
+ {
+ id: "generatePnr",
+ label: "Generate PNR",
+ shortLabel: "PNR",
+ description: "Issue PNR for ETB bank payment",
+ confirmTitle: "Generate PNR?",
+ confirmDescription:
+ "A payment reference number will be issued for the customer.",
+ variant: "default",
+ icon: Wallet,
+ primary: true,
+ },
+ ];
+ }
+ return [];
+ case "PAYMENT_VERIFICATION_IN_PROGRESS":
+ return [
+ {
+ id: "verifyPayment",
+ label: "Verify payment",
+ shortLabel: "Verify",
+ description: "Confirm USD payment proof",
+ confirmTitle: "Verify payment?",
+ confirmDescription:
+ "Finance confirms the uploaded proof and marks the booking as paid.",
+ variant: "default",
+ icon: Check,
+ primary: true,
+ },
+ ];
+ case "PAID":
+ case "PNR_GENERATED":
+ return [
+ {
+ id: "startTransit",
+ label: "Start transit",
+ shortLabel: "Transit",
+ description: "Begin rail movement",
+ confirmTitle: "Start transit?",
+ confirmDescription: "The booking will move to in transit status.",
+ variant: "default",
+ icon: Truck,
+ primary: true,
+ },
+ ];
+ case "IN_TRANSIT":
+ return [
+ {
+ id: "complete",
+ label: "Complete booking",
+ shortLabel: "Complete",
+ description: "Mark journey finished",
+ confirmTitle: "Complete booking?",
+ confirmDescription:
+ "Marks the booking as completed. No further staff transitions apply.",
+ variant: "default",
+ icon: Play,
+ primary: true,
+ },
+ ];
+ default:
+ return [];
+ }
+}
+
+export function listRowHasActions(row: {
+ status: BookingStatus;
+ paymentCurrency: string;
+}): boolean {
+ const actions = getBookingActions({
+ status: row.status,
+ paymentCurrency: row.paymentCurrency,
+ reference: "",
+ });
+ if (actions.length > 0) return true;
+ return row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD";
+}
diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts
new file mode 100644
index 000000000..71892a5dc
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts
@@ -0,0 +1,260 @@
+import type { BookingStatus } from "@/types/booking";
+
+export interface StatusStyle {
+ label: string;
+ color: string;
+}
+
+export const BOOKING_STATUS_STYLES: Record = {
+ DRAFT: {
+ label: "Draft",
+ color: "bg-slate-100 text-slate-700 border-slate-300",
+ },
+ SUBMITTED: {
+ label: "Submitted",
+ color: "bg-amber-50 text-amber-700 border-amber-200",
+ },
+ CHANGES_REQUESTED: {
+ label: "Changes Requested",
+ color: "bg-orange-50 text-orange-700 border-orange-200",
+ },
+ PENDING_APPROVAL: {
+ label: "Pending Approval",
+ color: "bg-amber-50 text-amber-700 border-amber-200",
+ },
+ APPROVED_PENDING_SIGNATURE: {
+ label: "Pending Signature",
+ color: "bg-sky-50 text-sky-700 border-sky-200",
+ },
+ APPROVED: {
+ label: "Approved",
+ color: "bg-emerald-50 text-emerald-700 border-emerald-200",
+ },
+ CONTRACT_READY: {
+ label: "Contract Ready",
+ color: "bg-indigo-50 text-indigo-700 border-indigo-200",
+ },
+ SIGNED_CUSTOMER: {
+ label: "Customer Signed",
+ color: "bg-sky-50 text-sky-700 border-sky-200",
+ },
+ FULLY_EXECUTED: {
+ label: "Fully Executed",
+ color: "bg-indigo-50 text-indigo-700 border-indigo-200",
+ },
+ PNR_GENERATED: {
+ label: "PNR Generated",
+ color: "bg-violet-50 text-violet-700 border-violet-200",
+ },
+ PAYMENT_VERIFICATION_IN_PROGRESS: {
+ label: "Payment Verification",
+ color: "bg-amber-50 text-amber-800 border-amber-200",
+ },
+ PAID: {
+ label: "Paid",
+ color: "bg-emerald-50 text-emerald-700 border-emerald-200",
+ },
+ IN_TRANSIT: {
+ label: "In Transit",
+ color: "bg-sky-50 text-sky-700 border-sky-200",
+ },
+ COMPLETED: {
+ label: "Completed",
+ color: "bg-indigo-50 text-indigo-700 border-indigo-200",
+ },
+ REJECTED: {
+ label: "Rejected",
+ color: "bg-red-50 text-red-700 border-red-200",
+ },
+ CANCELLED: {
+ label: "Cancelled",
+ color: "bg-red-50 text-red-700 border-red-200",
+ },
+ PENDING_CONSOLIDATION: {
+ label: "Pending Consolidation",
+ color: "bg-amber-50 text-amber-700 border-amber-200",
+ },
+ CONSOLIDATED: {
+ label: "Consolidated",
+ color: "bg-indigo-50 text-indigo-700 border-indigo-200",
+ },
+};
+
+export interface StatusMeta {
+ title: string;
+ description: string;
+ color: string;
+ stage: number;
+}
+
+export const BOOKING_STATUS_META: Record = {
+ DRAFT: {
+ title: "Draft",
+ description: "Booking is being prepared by the customer.",
+ color: "text-slate-500",
+ stage: 0,
+ },
+ SUBMITTED: {
+ title: "Submitted",
+ description: "Awaiting staff review.",
+ color: "text-amber-600",
+ stage: 0,
+ },
+ CHANGES_REQUESTED: {
+ title: "Changes Requested",
+ description: "Returned to customer for updates.",
+ color: "text-orange-600",
+ stage: 0,
+ },
+ PENDING_APPROVAL: {
+ title: "Pending Approval",
+ description: "Moving through internal approval chain.",
+ color: "text-amber-600",
+ stage: 1,
+ },
+ APPROVED_PENDING_SIGNATURE: {
+ title: "Pending Signature",
+ description: "Awaiting director or CEO signature steps.",
+ color: "text-sky-600",
+ stage: 1,
+ },
+ APPROVED: {
+ title: "Approved",
+ description: "Ready to generate contract.",
+ color: "text-emerald-600",
+ stage: 2,
+ },
+ CONTRACT_READY: {
+ title: "Contract Ready",
+ description: "Contract generated; awaiting customer signature.",
+ color: "text-indigo-600",
+ stage: 2,
+ },
+ SIGNED_CUSTOMER: {
+ title: "Customer Signed",
+ description: "Awaiting contract execution.",
+ color: "text-sky-600",
+ stage: 2,
+ },
+ FULLY_EXECUTED: {
+ title: "Fully Executed",
+ description: "Contract locked; proceed to payment.",
+ color: "text-indigo-600",
+ stage: 3,
+ },
+ PNR_GENERATED: {
+ title: "PNR Generated",
+ description: "ETB payment reference issued.",
+ color: "text-violet-600",
+ stage: 3,
+ },
+ PAYMENT_VERIFICATION_IN_PROGRESS: {
+ title: "Payment Verification",
+ description: "USD payment proof under review.",
+ color: "text-amber-700",
+ stage: 3,
+ },
+ PAID: {
+ title: "Paid",
+ description: "Payment confirmed; ready for operations.",
+ color: "text-emerald-600",
+ stage: 4,
+ },
+ IN_TRANSIT: {
+ title: "In Transit",
+ description: "Shipment is on the railway network.",
+ color: "text-sky-600",
+ stage: 4,
+ },
+ COMPLETED: {
+ title: "Completed",
+ description: "Booking fulfilled.",
+ color: "text-indigo-600",
+ stage: 5,
+ },
+ REJECTED: {
+ title: "Rejected",
+ description: "Booking was rejected.",
+ color: "text-red-600",
+ stage: -1,
+ },
+ CANCELLED: {
+ title: "Cancelled",
+ description: "Booking was cancelled.",
+ color: "text-red-600",
+ stage: -1,
+ },
+ PENDING_CONSOLIDATION: {
+ title: "Pending Consolidation",
+ description: "Waiting for consolidation partner.",
+ color: "text-amber-600",
+ stage: 4,
+ },
+ CONSOLIDATED: {
+ title: "Consolidated",
+ description: "Paired with another booking.",
+ color: "text-indigo-600",
+ stage: 4,
+ },
+};
+
+export const BOOKING_LIST_TABS = [
+ { key: "all", label: "All bookings", status: null },
+ { key: "SUBMITTED", label: "Submitted", status: "SUBMITTED" },
+ { key: "PENDING_APPROVAL", label: "Pending Approval", status: "PENDING_APPROVAL" },
+ {
+ key: "APPROVED_PENDING_SIGNATURE",
+ label: "Pending Signature",
+ status: "APPROVED_PENDING_SIGNATURE",
+ },
+ { key: "SIGNED_CUSTOMER", label: "Customer Signed", status: "SIGNED_CUSTOMER" },
+ {
+ key: "PAYMENT_VERIFICATION_IN_PROGRESS",
+ label: "Payment Verification",
+ status: "PAYMENT_VERIFICATION_IN_PROGRESS",
+ },
+] as const;
+
+export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"];
+
+export const WORKFLOW_STAGES = [
+ { label: "Submission", statuses: ["DRAFT", "SUBMITTED", "CHANGES_REQUESTED"] },
+ {
+ label: "Approval",
+ statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE"],
+ },
+ {
+ label: "Contract",
+ statuses: ["APPROVED", "CONTRACT_READY", "SIGNED_CUSTOMER", "FULLY_EXECUTED"],
+ },
+ {
+ label: "Payment",
+ statuses: [
+ "PNR_GENERATED",
+ "PAYMENT_VERIFICATION_IN_PROGRESS",
+ "PAID",
+ ],
+ },
+ {
+ label: "Operations",
+ statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
+ },
+ { label: "Done", statuses: ["COMPLETED"] },
+] as const;
+
+export function getStatusMeta(status: BookingStatus | string): StatusMeta {
+ return (
+ BOOKING_STATUS_META[status] ?? {
+ title: status,
+ description: "",
+ color: "text-muted-foreground",
+ stage: 0,
+ }
+ );
+}
+
+export function getWorkflowStageIndex(status: BookingStatus | string): number {
+ const meta = getStatusMeta(status);
+ if (meta.stage < 0) return -1;
+ return meta.stage;
+}
diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts
new file mode 100644
index 000000000..6ad2a0cc7
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts
@@ -0,0 +1,35 @@
+import type { BookingDetail, BookingListRow } from "@/types/booking";
+
+function labelFromRef(
+ ref?: { name?: string; label?: string; code?: string; companyName?: string },
+ fallback = "—",
+): string {
+ if (!ref) return fallback;
+ return (
+ ref.companyName ??
+ ref.label ??
+ ref.name ??
+ ref.code ??
+ fallback
+ );
+}
+
+export function toBookingListRow(booking: BookingDetail): BookingListRow {
+ return {
+ id: booking.id,
+ reference: booking.reference,
+ customerLabel: labelFromRef(booking.company, booking.companyId),
+ // customerLabel: labelFromRef(booking.customer, booking.customerId),
+ status: booking.status,
+ scheduledDate: booking.scheduledDate,
+ totalAmount: Number(booking.totalAmount),
+ paymentCurrency: booking.paymentCurrency,
+ paymentStatus: booking.paymentStatus,
+ tradeDirection: booking.tradeDirection,
+ freightType: booking.freightType,
+ originLabel: labelFromRef(booking.originYard),
+ destinationLabel: labelFromRef(booking.destinationYard),
+ priorityScore: booking.priorityScore ?? 0,
+ createdAt: booking.createdAt,
+ };
+}
diff --git a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts
new file mode 100644
index 000000000..241fe5e43
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts
@@ -0,0 +1,178 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import toast from "react-hot-toast";
+
+import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
+import { api } from "@/services/api";
+import {
+ bookingsService,
+ type BookingListFilter,
+} from "@/services/bookings.service";
+import { invalidateBookingDetail } from "@/utils/queryInvalidation";
+
+export function useBookingList(filter?: BookingListFilter, enabled = true) {
+ return useQuery({
+ queryKey: QUERY_KEYS.BOOKINGS.list(filter),
+ queryFn: () => bookingsService.list(filter),
+ enabled,
+ });
+}
+
+export function useBookingDetail(id: string | undefined) {
+ return useQuery({
+ queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? ""),
+ queryFn: () => bookingsService.getById(id!),
+ enabled: Boolean(id),
+ });
+}
+
+export function useBookingMutations(bookingId: string) {
+ const qc = useQueryClient();
+ const onSuccess = (data: { id: string }, message: string) => {
+ toast.success(message);
+ void invalidateBookingDetail(qc, data.id);
+ };
+
+ const staffAccept = useMutation({
+ mutationFn: () => api.bookings.staffAccept.call({ id: bookingId }),
+ onSuccess: (data) => onSuccess(data, "Booking accepted for approval"),
+ onError: () => toast.error("Failed to accept booking"),
+ });
+
+ const requestChanges = useMutation({
+ mutationFn: (note: string) =>
+ api.bookings.requestChanges.call({ id: bookingId, note }),
+ onSuccess: (data) => onSuccess(data, "Changes requested from customer"),
+ onError: () => toast.error("Failed to request changes"),
+ });
+
+ const staffReject = useMutation({
+ mutationFn: (reason: string) =>
+ api.bookings.staffReject.call({ id: bookingId, reason }),
+ onSuccess: (data) => onSuccess(data, "Booking rejected"),
+ onError: () => toast.error("Failed to reject booking"),
+ });
+
+ const approveStep = useMutation({
+ mutationFn: ({
+ stepId,
+ requiredRole,
+ }: {
+ stepId: string;
+ requiredRole: string;
+ }) =>
+ api.bookings.approveStep.call({
+ id: bookingId,
+ stepId,
+ requiredRole,
+ }),
+ onSuccess: (data) => onSuccess(data, "Approval step completed"),
+ onError: () => toast.error("Failed to approve step"),
+ });
+
+ const rejectStep = useMutation({
+ mutationFn: ({
+ stepId,
+ reason,
+ }: {
+ stepId: string;
+ reason: string;
+ }) =>
+ api.bookings.rejectStep.call({
+ id: bookingId,
+ stepId,
+ reason,
+ }),
+ onSuccess: (data) => onSuccess(data, "Booking rejected at approval step"),
+ onError: () => toast.error("Failed to reject step"),
+ });
+
+ const generateContract = useMutation({
+ mutationFn: () => api.bookings.generateContract.call({ id: bookingId }),
+ onSuccess: (data) => onSuccess(data, "Contract generated"),
+ onError: () => toast.error("Failed to generate contract"),
+ });
+
+ const signContract = useMutation({
+ mutationFn: (payload: {
+ role: "CUSTOMER" | "STAFF";
+ signatureImageBase64: string;
+ signerDisplayName: string;
+ consentText?: string;
+ }) => bookingsService.signContract(bookingId, payload),
+ onSuccess: (data) => onSuccess(data, "Contract signed"),
+ onError: () => toast.error("Failed to sign contract"),
+ });
+
+ const generatePnr = useMutation({
+ mutationFn: () => api.bookings.generatePnr.call({ id: bookingId }),
+ onSuccess: (data) => onSuccess(data, "PNR generated"),
+ onError: () => toast.error("Failed to generate PNR"),
+ });
+
+ const submitPaymentProof = useMutation({
+ mutationFn: (file: File) =>
+ bookingsService.submitPaymentProof(bookingId, file),
+ onSuccess: (data) => onSuccess(data, "Payment proof uploaded"),
+ onError: () => toast.error("Failed to upload payment proof"),
+ });
+
+ const verifyPayment = useMutation({
+ mutationFn: () => api.bookings.verifyPayment.call({ id: bookingId }),
+ onSuccess: (data) => onSuccess(data, "Payment verified"),
+ onError: () => toast.error("Failed to verify payment"),
+ });
+
+ const startTransit = useMutation({
+ mutationFn: () => api.bookings.startTransit.call({ id: bookingId }),
+ onSuccess: (data) => onSuccess(data, "Marked in transit"),
+ onError: () => toast.error("Failed to start transit"),
+ });
+
+ const complete = useMutation({
+ mutationFn: () => api.bookings.complete.call({ id: bookingId }),
+ onSuccess: (data) => onSuccess(data, "Booking completed"),
+ onError: () => toast.error("Failed to complete booking"),
+ });
+
+ const cancel = useMutation({
+ mutationFn: (reason: string) =>
+ api.bookings.cancel.call({ id: bookingId, reason }),
+ onSuccess: (data) => onSuccess(data, "Booking cancelled"),
+ onError: () => toast.error("Failed to cancel booking"),
+ });
+
+ const isPending =
+ staffAccept.isPending ||
+ requestChanges.isPending ||
+ staffReject.isPending ||
+ approveStep.isPending ||
+ rejectStep.isPending ||
+ generateContract.isPending ||
+ signContract.isPending ||
+ generatePnr.isPending ||
+ submitPaymentProof.isPending ||
+ verifyPayment.isPending ||
+ startTransit.isPending ||
+ complete.isPending ||
+ cancel.isPending;
+
+ return {
+ staffAccept,
+ requestChanges,
+ staffReject,
+ approveStep,
+ rejectStep,
+ generateContract,
+ signContract,
+ generatePnr,
+ submitPaymentProof,
+ verifyPayment,
+ startTransit,
+ complete,
+ cancel,
+ isPending,
+ downloadContract: () => bookingsService.downloadContract(bookingId),
+ downloadPaymentLetter: () =>
+ bookingsService.downloadPaymentRequestLetter(bookingId),
+ };
+}
diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
index 6342d66fb..85dff0cc2 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts
@@ -1,42 +1,38 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
+import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { api } from "@/services/api";
-import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
+import { ruleEngineService, type RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources";
import type {
- ApproveRatePayload,
RuleEngineRecord,
RuleEngineResourceSlug,
} from "@/types/rule-engine";
-import { QUERY_KEYS } from "@/constants/TANSTACK_QUEY_KEY";
+import {
+ invalidateRuleEngineList,
+ patchRuleEngineListRecord,
+} from "@/utils/queryInvalidation";
const CARGO_TYPE_PARENT_PAGE_SIZE = 500;
const CONTAINER_TYPE_OPTIONS_PAGE_SIZE = 500;
-const listKey = (resource: RuleEngineResourceSlug) =>
- ["rule-engine", resource] as const;
-
export const useRuleEngineList = (
resource: RuleEngineResourceSlug,
params: RuleEngineListParams,
) =>
- useQuery(
- api.ruleEngine.list.queryOptions({
- input: { resource, params },
- }),
- );
+ useQuery({
+ queryKey: QUERY_KEYS.RULE_ENGINE.list(resource, params),
+ queryFn: () => ruleEngineService.list(resource, params),
+ });
export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
useQuery({
- queryKey: api.ruleEngine.list.queryKey({
- resource: "cargo-types",
- params: { page: 1, pageSize: CARGO_TYPE_PARENT_PAGE_SIZE },
- }),
+ queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("cargo-types"),
queryFn: () =>
- api.ruleEngine.list.call({
- resource: "cargo-types",
- params: { page: 1, pageSize: CARGO_TYPE_PARENT_PAGE_SIZE },
+ ruleEngineService.list("cargo-types", {
+ page: 1,
+ pageSize: CARGO_TYPE_PARENT_PAGE_SIZE,
}),
enabled,
select: (result) => {
@@ -54,12 +50,37 @@ export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
},
});
-export const useContainerTypeOptions = (enabled = true) =>
+export function buildContainerTypeSelectOptions(
+ rows: RuleEngineRecord[],
+ includeNone: boolean,
+): { label: string; value: string }[] {
+ const options = rows
+ .filter((row) => row.id)
+ .map((row) => {
+ const label = String(row.label ?? "").trim();
+ const code = String(row.code ?? "").trim();
+ const size = row.sizeFt ? `${String(row.sizeFt)}ft` : "";
+ const parts = [label || code || String(row.id), size].filter(Boolean);
+ return {
+ label: parts.join(" - "),
+ value: String(row.id),
+ };
+ });
+
+ if (!includeNone) return options;
+ return [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...options];
+}
+
+export const useContainerTypeOptions = (
+ includeNone = true,
+ enabled = true,
+) =>
useQuery({
- queryKey: [
- ...QUERY_KEYS.RULE_ENGINE.list("container-types"),
- "select-options",
- ],
+ queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions('container-types', {
+ page: 1,
+ pageSize: CONTAINER_TYPE_OPTIONS_PAGE_SIZE,
+ includeNone,
+ }),
queryFn: () =>
api.ruleEngine.list.call({
resource: "container-types",
@@ -69,42 +90,55 @@ export const useContainerTypeOptions = (enabled = true) =>
},
}),
enabled,
- select: (result) => {
- const noneOption = { label: "None", value: RULE_ENGINE_SELECT_NONE };
- const options = (result.data ?? []).map((row) => {
- const label = String(row.label ?? "").trim();
- const code = String(row.code ?? "").trim();
- const size = row.sizeFt ? `${String(row.sizeFt)}ft` : "";
- const parts = [label || code || String(row.id), size].filter(Boolean);
+ select: (result) =>
+ buildContainerTypeSelectOptions(result.data ?? [], includeNone),
+ });
- return {
- label: parts.join(" - "),
- value: String(row.id),
- };
- });
+const LIVE_RATE_PAGE_SIZE = 500;
- return [noneOption, ...options];
- },
+export const useLiveRateOptions = (enabled = true) =>
+ useQuery({
+ queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("rates", { status: "LIVE" }),
+ queryFn: () =>
+ ruleEngineService.list("rates", {
+ page: 1,
+ pageSize: LIVE_RATE_PAGE_SIZE,
+ status: "LIVE",
+ }),
+ enabled,
+ select: (result) =>
+ (result.data ?? [])
+ .filter((row) => row.id)
+ .map((row) => {
+ const rateType = String(row.rateType ?? "").replace(/_/g, " ");
+ const currency = String(row.currency ?? "");
+ const value = row.rateValue != null ? String(row.rateValue) : "";
+ const unit = row.rateUnit ? String(row.rateUnit).replace(/_/g, " ") : "";
+ const parts = [rateType, currency, value, unit].filter(Boolean);
+ return {
+ label: parts.join(" · "),
+ value: String(row.id),
+ };
+ }),
});
export const useApprovalChain = (enabled: boolean) =>
- useQuery(
- api.ruleEngine.getApprovalChain.queryOptions({
- enabled,
- }),
- );
+ useQuery({
+ queryKey: QUERY_KEYS.RULE_ENGINE.chain,
+ queryFn: () => ruleEngineService.getApprovalChain(),
+ enabled,
+ });
export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
const qc = useQueryClient();
- const invalidate = () =>
- qc.invalidateQueries({ queryKey: listKey(resource) });
const create = useMutation({
mutationFn: (payload: Record) =>
api.ruleEngine.create.call({ resource, payload }),
- onSuccess: () => {
+ onSuccess: async (created) => {
toast.success("Created successfully");
- invalidate();
+ patchRuleEngineListRecord(qc, resource, created);
+ await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to create record"),
});
@@ -117,18 +151,20 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
id: string;
payload: Record;
}) => api.ruleEngine.update.call({ resource, id, payload }),
- onSuccess: () => {
+ onSuccess: async (updated) => {
toast.success("Updated successfully");
- invalidate();
+ patchRuleEngineListRecord(qc, resource, updated);
+ await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to update record"),
});
const remove = useMutation({
- mutationFn: (id: string) => api.ruleEngine.remove.call({ resource, id }),
- onSuccess: () => {
+ mutationFn: (id: string) =>
+ api.ruleEngine.remove.call({ resource, id }),
+ onSuccess: async () => {
toast.success("Deleted successfully");
- invalidate();
+ await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to delete record"),
});
@@ -138,28 +174,23 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
export const useRateWorkflow = () => {
const qc = useQueryClient();
- const invalidate = () => qc.invalidateQueries({ queryKey: listKey("rates") });
const submit = useMutation({
mutationFn: (id: string) => api.ruleEngine.submitRate.call({ id }),
- onSuccess: () => {
+ onSuccess: async (updated) => {
toast.success("Rate submitted for approval");
- invalidate();
+ patchRuleEngineListRecord(qc, "rates", updated);
+ await invalidateRuleEngineList(qc, "rates");
},
onError: () => toast.error("Failed to submit rate"),
});
const approve = useMutation({
- mutationFn: ({
- id,
- payload,
- }: {
- id: string;
- payload: ApproveRatePayload;
- }) => api.ruleEngine.approveRate.call({ id, payload }),
- onSuccess: () => {
+ mutationFn: (id: string) => api.ruleEngine.approveRate.call({ id }),
+ onSuccess: async (updated) => {
toast.success("Rate approved");
- invalidate();
+ patchRuleEngineListRecord(qc, "rates", updated);
+ await invalidateRuleEngineList(qc, "rates");
},
onError: () => toast.error("Failed to approve rate"),
});
diff --git a/apps/edr-freight-web/backoffice/src/hooks/use-toast.ts b/apps/edr-freight-web/backoffice/src/hooks/use-toast.ts
new file mode 100644
index 000000000..7b15a028d
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/hooks/use-toast.ts
@@ -0,0 +1,24 @@
+import toast from 'react-hot-toast';
+
+interface ToastOptions {
+ title?: string;
+ description?: string;
+ variant?: 'default' | 'destructive';
+ duration?: number;
+}
+
+export function useToast() {
+ const showToast = (options: ToastOptions) => {
+ const { title, description, variant = 'default', duration = 3000 } = options;
+
+ const message = title ? `${title}${description ? ': ' + description : ''}` : description || '';
+
+ if (variant === 'destructive') {
+ toast.error(message, { duration });
+ } else {
+ toast.success(message, { duration });
+ }
+ };
+
+ return { toast: showToast };
+}
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/useBookings.ts
index 5ee2e5749..81e7bbc83 100644
--- a/apps/edr-freight-web/backoffice/src/hooks/useBookings.ts
+++ b/apps/edr-freight-web/backoffice/src/hooks/useBookings.ts
@@ -1,48 +1,5 @@
-import { useMutation, useQueryClient } from "@tanstack/react-query";
-
-import { api } from "@/services/api";
-import type { BookingListFilter } from "@/services/bookings.service";
-
-export const useBookingList = (filter?: BookingListFilter) => {
- const input = { filter };
- return {
- queryKey: api.bookings.list.queryKey(input),
- queryFn: () => api.bookings.list.call(input),
- };
-};
-
-export const useBooking = (id: string) => ({
- queryKey: api.bookings.getById.queryKey({ id }),
- queryFn: () => api.bookings.getById.call({ id }),
- enabled: Boolean(id),
-});
-
-export const useUpdateBookingStatus = () => {
- const qc = useQueryClient();
- return useMutation({
- mutationFn: ({
- id,
- action,
- reason,
- }: {
- id: string;
- action: string;
- reason?: string;
- }) => api.bookings.updateStatus.call({ id, action, reason }),
- onSuccess: (_data, { id }) => {
- qc.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
- qc.invalidateQueries({
- queryKey: api.bookings.getById.queryKey({ id }),
- });
- },
- });
-};
-
-export const useDeleteBooking = () => {
- const qc = useQueryClient();
- return useMutation({
- mutationFn: (id: string) => api.bookings.remove.call({ id }),
- onSuccess: () =>
- qc.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
- });
-};
+export {
+ useBookingList,
+ useBookingDetail,
+ useBookingMutations,
+} from "./bookings/useBookings";
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useCargoes.ts b/apps/edr-freight-web/backoffice/src/hooks/useCargoes.ts
new file mode 100644
index 000000000..4873e1dba
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/hooks/useCargoes.ts
@@ -0,0 +1,39 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { cargoService } from '@/services/cargoService';
+
+export const cargoKeys = {
+ all: ['cargoes'] as const,
+ byContainer: (containerId: string) => [...cargoKeys.all, 'container', containerId] as const,
+};
+
+export function useCargoes() {
+ return useQuery({ queryKey: cargoKeys.all, queryFn: () => cargoService.getAll().then(res => res.data) });
+}
+
+export function useCargoesByContainer(containerId: string) {
+ return useQuery({ queryKey: cargoKeys.byContainer(containerId), queryFn: () => cargoService.getByContainer(containerId).then(res => res.data), enabled: !!containerId });
+}
+
+export function useLoadCargo() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, quantity, weight, volume }: any) => cargoService.load(id, quantity, weight, volume),
+ onSuccess: (_, { id }) => qc.invalidateQueries({ queryKey: cargoKeys.all })
+ });
+}
+
+export function useDeliverCargo() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => cargoService.deliver(id),
+ onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all })
+ });
+}
+
+export function useUnloadCargo() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => cargoService.unload(id),
+ onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all })
+ });
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useContainers.ts b/apps/edr-freight-web/backoffice/src/hooks/useContainers.ts
new file mode 100644
index 000000000..6eac8fdcf
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/hooks/useContainers.ts
@@ -0,0 +1,31 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { containerService } from '@/services/containerService';
+
+export const containerKeys = {
+ all: ['containers'] as const,
+ byWagon: (wagonId: string) => [...containerKeys.all, 'wagon', wagonId] as const,
+};
+
+export function useContainers() {
+ return useQuery({ queryKey: containerKeys.all, queryFn: () => containerService.getAll().then(res => res.data) });
+}
+
+export function useContainersByWagon(wagonId: string) {
+ return useQuery({ queryKey: containerKeys.byWagon(wagonId), queryFn: () => containerService.getByWagon(wagonId).then(res => res.data), enabled: !!wagonId });
+}
+
+export function useAssignContainerToWagon() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ containerId, wagonId, position }: any) => containerService.assignToWagon(containerId, wagonId, position),
+ onSuccess: (_, { wagonId }) => qc.invalidateQueries({ queryKey: containerKeys.byWagon(wagonId) })
+ });
+}
+
+export function useUnassignContainer() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: containerService.unassign,
+ onSuccess: () => qc.invalidateQueries({ queryKey: containerKeys.all })
+ });
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useTrains.ts b/apps/edr-freight-web/backoffice/src/hooks/useTrains.ts
new file mode 100644
index 000000000..c763e279d
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/hooks/useTrains.ts
@@ -0,0 +1,35 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { trainService } from '@/services/trains.service';
+
+export const trainKeys = {
+ all: ['trains'] as const,
+ lists: () => [...trainKeys.all, 'list'] as const,
+ details: () => [...trainKeys.all, 'detail'] as const,
+ detail: (id: string) => [...trainKeys.details(), id] as const,
+};
+
+export function useTrains() {
+ return useQuery({ queryKey: trainKeys.lists(), queryFn: () => trainService.getAll().then(res => res.data) });
+}
+
+export function useTrain(id: string) {
+ return useQuery({ queryKey: trainKeys.detail(id), queryFn: () => trainService.getById(id).then(res => res.data), enabled: !!id });
+}
+
+export function useCreateTrain() {
+ const qc = useQueryClient();
+ return useMutation({ mutationFn: trainService.create, onSuccess: () => qc.invalidateQueries({ queryKey: trainKeys.lists() }) });
+}
+
+export function useUpdateTrain() {
+ const qc = useQueryClient();
+ return useMutation({ mutationFn: ({ id, data }: any) => trainService.update(id, data), onSuccess: (_, { id }) => {
+ qc.invalidateQueries({ queryKey: trainKeys.lists() });
+ qc.invalidateQueries({ queryKey: trainKeys.detail(id) });
+ } });
+}
+
+export function useDeleteTrain() {
+ const qc = useQueryClient();
+ return useMutation({ mutationFn: trainService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: trainKeys.lists() }) });
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWagons.ts b/apps/edr-freight-web/backoffice/src/hooks/useWagons.ts
new file mode 100644
index 000000000..0e2bd5e25
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/hooks/useWagons.ts
@@ -0,0 +1,41 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { wagonService } from '@/services/wagon.service';
+
+export const wagonKeys = {
+ all: ['wagons'] as const,
+ byTrain: (trainId: string) => [...wagonKeys.all, 'train', trainId] as const,
+ details: () => [...wagonKeys.all, 'detail'] as const,
+};
+
+export function useWagons() {
+ return useQuery({ queryKey: wagonKeys.all, queryFn: () => wagonService.getAll().then(res => res.data) });
+}
+
+export function useWagonsByTrain(trainId: string) {
+ return useQuery({ queryKey: wagonKeys.byTrain(trainId), queryFn: () => wagonService.getByTrain(trainId).then(res => res.data), enabled: !!trainId });
+}
+
+export function useAssignWagonToTrain() {
+ const qc = useQueryClient();
+ return useMutation({ mutationFn: ({ wagonId, trainId, sequenceNumber }: any) => wagonService.assignToTrain(wagonId, trainId, sequenceNumber), onSuccess: (_, { trainId }) => qc.invalidateQueries({ queryKey: wagonKeys.byTrain(trainId) }) });
+}
+
+export function useUnassignWagon() {
+ const qc = useQueryClient();
+ return useMutation({ mutationFn: wagonService.unassign, onSuccess: () => qc.invalidateQueries({ queryKey: wagonKeys.all }) });
+}
+
+export function useReorderWagons() {
+ const qc = useQueryClient();
+ return useMutation({ mutationFn: ({ trainId, wagonIds }: any) => wagonService.reorder(trainId, wagonIds), onSuccess: (_, { trainId }) => qc.invalidateQueries({ queryKey: wagonKeys.byTrain(trainId) }) });
+}
+
+export function useCreateWagon() {
+ const qc = useQueryClient();
+ return useMutation({ mutationFn: wagonService.create, onSuccess: () => qc.invalidateQueries({ queryKey: wagonKeys.all }) });
+}
+
+export function useUpdateWagon() {
+ const qc = useQueryClient();
+ return useMutation({ mutationFn: ({ id, data }: any) => wagonService.update(id, data), onSuccess: () => qc.invalidateQueries({ queryKey: wagonKeys.all }) });
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts
new file mode 100644
index 000000000..9ac8402d8
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts
@@ -0,0 +1,12 @@
+import { QueryClient } from "@tanstack/react-query";
+
+/** Single app-wide React Query client (do not nest additional providers). */
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: 1,
+ refetchOnWindowFocus: false,
+ staleTime: 30_000,
+ },
+ },
+});
diff --git a/apps/edr-freight-web/backoffice/src/main.tsx b/apps/edr-freight-web/backoffice/src/main.tsx
index f2418ba0b..7ab0fcbf8 100644
--- a/apps/edr-freight-web/backoffice/src/main.tsx
+++ b/apps/edr-freight-web/backoffice/src/main.tsx
@@ -9,7 +9,8 @@ import { Toaster } from "react-hot-toast";
import App from "./App";
import { AuthProvider } from "./auth/AuthProvider";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { queryClient } from "./lib/queryClient";
const THEME_STORAGE_KEY = "edr-theme";
@@ -38,8 +39,6 @@ if (!rootElement) {
throw new Error("Root element not found");
}
-const queryClient = new QueryClient();
-
createRoot(rootElement).render(
diff --git a/apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixApproval.tsx b/apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixApproval.tsx
new file mode 100644
index 000000000..723821936
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixApproval.tsx
@@ -0,0 +1,117 @@
+// pages/admin/rateMatrix/RateMatrixApproval.tsx
+import React from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { LoadingScreen } from '@/ui/LoadingScreen';
+import { useRateMatrixAuth } from '@/auth/hooks/useAuth';
+import { queryKeys } from '../../../constants/QUERY_KEYS';
+import { API_URLS } from '@/constants/URL_CONSTANTS';
+//import { MATRIX_STATUS } from '@/constants/rateMatrixConstants';
+import { toast } from 'sonner';
+import { Navigate } from 'react-router-dom';
+
+export default function RateMatrixApprovalPage() {
+ const { isChiefExecutive } = useRateMatrixAuth();
+ const queryClient = useQueryClient();
+ const pendingMatricesQueryKey = [...queryKeys.rateMatrix.all, 'pending-approval'];
+
+ const { data: pendingMatrices, isLoading } = useQuery({
+ queryKey: pendingMatricesQueryKey,
+ queryFn: async () => {
+ const response = await fetch(`${API_URLS.RATE_MATRIX.LIST}?status=pending_approval`);
+ return response.json();
+ },
+ });
+
+ const authorizeMutation = useMutation({
+ mutationFn: async ({ matrixId, signature }: { matrixId: string; signature: string }) => {
+ const response = await fetch(API_URLS.RATE_MATRIX.AUTHORIZE(matrixId), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ digitalSignature: signature }),
+ });
+ if (!response.ok) throw new Error('Authorization failed');
+ return response.json();
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: pendingMatricesQueryKey });
+ toast.success('Rate matrix authorized successfully!');
+ },
+ onError: () => {
+ toast.error('Failed to authorize rate matrix');
+ },
+ });
+
+ if (!isChiefExecutive) {
+ return ;
+ }
+
+ if (isLoading) return ;
+
+ return (
+
+
Pending Rate Matrix Approvals
+
+
+ {pendingMatrices?.map((matrix: any) => (
+
+
+
+ {matrix.matrixName}
+ {matrix.status}
+
+
+
+
+
+
+
Effective Date
+
{matrix.effectiveDate}
+
+
+
Submitted By
+
{matrix.createdBy}
+
+
+
+
+
Rate Types Included:
+
+ {matrix.rateEntries?.map((entry: any) => (
+
+ {entry.rateType}
+
+ ))}
+
+
+
+
+ {
+ // Implement digital signature collection
+ const signature = prompt('Enter digital signature:');
+ if (signature) {
+ authorizeMutation.mutate({
+ matrixId: matrix.id,
+ signature
+ });
+ }
+ }}
+ disabled={authorizeMutation.isPending}
+ >
+ Authorize & Release
+
+
+ Request Changes
+
+
+
+
+
+ ))}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixRegistration.tsx b/apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixRegistration.tsx
new file mode 100644
index 000000000..6f151f992
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixRegistration.tsx
@@ -0,0 +1,25 @@
+// pages/admin/rateMatrix/RateMatrixRegistration.tsx
+import React from 'react';
+import { RateMatrixForm } from '@/components/baselineRatematrix/RateMatrixForm';
+import { useRateMatrixAuth } from '@/auth/useAuth';
+import { Navigate } from 'react-router-dom';
+// Local lightweight fallback for LoadingScreen to avoid import errors
+const LoadingScreen: React.FC<{ message?: string }> = ({ message = 'Loading...' }) => (
+
+);
+
+export default function RateMatrixRegistrationPage() {
+ const { isDirector, isLoading } = useRateMatrixAuth();
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!isDirector) {
+ return ;
+ }
+
+ return ;
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx
new file mode 100644
index 000000000..32a7d9750
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx
@@ -0,0 +1,218 @@
+import { useCallback, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ ArrowLeft,
+ Download,
+ FileSignature,
+ Loader2,
+ Printer,
+} from "lucide-react";
+import toast from "react-hot-toast";
+
+import Breadcrumbs from "@/components/ui/Breadcrumbs";
+import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
+import { bookingSurface } from "@/components/bookings/booking-ui.styles";
+import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
+import { invalidateBookingDetail } from "@/utils/queryInvalidation";
+import {
+ bookingsService,
+ type ContractView,
+ type SignContractPayload,
+} from "@/services/bookings.service";
+import { cn } from "@/lib/utils";
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Input,
+ Label,
+} from "@edr/ui-common";
+
+export default function BookingContractPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const qc = useQueryClient();
+ const [signOpen, setSignOpen] = useState(false);
+ const [signerName, setSignerName] = useState("");
+ const [signatureData, setSignatureData] = useState(null);
+
+ const { data, isLoading, isError } = useQuery({
+ queryKey: [...QUERY_KEYS.BOOKINGS.byId(id ?? ""), "contract-view"],
+ queryFn: () => bookingsService.getContractView(id!),
+ enabled: Boolean(id),
+ });
+
+ const signRole: "CUSTOMER" | "STAFF" | null = data?.canSignCustomer
+ ? "CUSTOMER"
+ : data?.canSignStaff
+ ? "STAFF"
+ : null;
+
+ const signMutation = useMutation({
+ mutationFn: (payload: SignContractPayload) =>
+ bookingsService.signContract(id!, payload),
+ onSuccess: async () => {
+ toast.success("Signature recorded");
+ setSignOpen(false);
+ await invalidateBookingDetail(qc, id!);
+ qc.invalidateQueries({
+ queryKey: [...QUERY_KEYS.BOOKINGS.byId(id ?? ""), "contract-view"],
+ });
+ },
+ onError: () => toast.error("Failed to sign contract"),
+ });
+
+ const downloadPdf = useCallback(async () => {
+ if (!id) return;
+ try {
+ const blob = await bookingsService.downloadContractDocument(id);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `contract-${data?.reference ?? id}.pdf`;
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch {
+ toast.error("Contract PDF not available. Ask staff to generate it first.");
+ }
+ }, [id, data?.reference]);
+
+ const handlePrint = () => window.print();
+
+ const openSign = () => {
+ setSignerName("");
+ setSignatureData(null);
+ setSignOpen(true);
+ };
+
+ const confirmSign = () => {
+ if (!signRole || !signatureData || !signerName.trim()) return;
+ signMutation.mutate({
+ role: signRole,
+ signatureImageBase64: signatureData,
+ signerDisplayName: signerName.trim(),
+ consentText: "I agree to the terms of this contract.",
+ });
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (isError || !data) {
+ return (
+
+
Could not load contract.
+
navigate(-1)}>
+ Go back
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
navigate(-1)}>
+
+ Back
+
+
+
+
+ Print
+
+
+
+ Download PDF
+
+ {signRole && (
+
+
+ Sign as {signRole === "CUSTOMER" ? "Customer" : "Staff"}
+
+ )}
+
+
+
+
+
+
+
+
+
+ {signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"}
+
+
+ Sign to execute the contract for {data.reference}.
+
+
+
+
+ Full name
+ setSignerName(e.target.value)}
+ placeholder="As shown on the contract"
+ />
+
+
+
+
+ setSignOpen(false)}>
+ Cancel
+
+
+ {signMutation.isPending ? (
+
+ ) : (
+ "Confirm signature"
+ )}
+
+
+
+
+
+
+ );
+}
+
+/** Render server HTML body content inside our layout wrapper. */
+function extractBodyHtml(fullHtml: string): string {
+ const match = fullHtml.match(/]*>([\s\S]*)<\/body>/i);
+ return match ? match[1] : fullHtml;
+}
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
index 3bb75b6bf..e88e26e1d 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
@@ -1,642 +1,232 @@
import { useNavigate, useParams } from "react-router-dom";
-import { useQuery } from "@tanstack/react-query";
import {
- AlertCircle,
- AlertTriangle,
Anchor,
ArrowLeft,
ArrowRight,
+ Building2,
Calendar,
- Check,
- CheckCircle2,
Clock,
- FileSignature,
- FileText,
- History,
- Info,
+ Loader2,
MapPin,
Package,
- ShieldCheck,
- Ship,
- StickyNote,
+ FileSignature,
+ RefreshCw,
Train,
Truck,
Weight,
- X,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
+import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
+import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
+import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
+import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
+import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
+import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
+import { bookingSurface } from "@/components/bookings/booking-ui.styles";
+import { getStatusMeta } from "@/features/bookings/booking-status.config";
+import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
+import {
+ useBookingDetail,
+ useBookingMutations,
+} from "@/hooks/bookings/useBookings";
+import type { BookingDetail } from "@/types/booking";
import { cn } from "@/lib/utils";
-import { api } from "@/services/api";
-import { useUpdateBookingStatus } from "@/hooks/useBookings";
-import { mapBookingToRequest, BOOKING_STATUSES } from "./booking-requests.mock";
import {
Badge,
Button,
- Card,
- CardHeader,
- CardTitle,
- CardDescription,
- CardContent,
Separator,
} from "@edr/ui-common";
-const STATUS_STYLES: Record = {
- DRAFT: {
- label: "Draft",
- color: "bg-slate-100 text-slate-700 border-slate-300",
- },
- RFQ_SUBMITTED: {
- label: "RFQ Submitted",
- color: "bg-amber-50 text-amber-700 border-amber-200",
- },
- QUOTATION_SENT: {
- label: "Quotation Sent",
- color: "bg-sky-50 text-sky-700 border-sky-200",
- },
- QUOTATION_APPROVED: {
- label: "Quotation Approved",
- color: "bg-emerald-50 text-emerald-700 border-emerald-200",
- },
- QUOTATION_REJECTED: {
- label: "Quotation Rejected",
- color: "bg-red-50 text-red-700 border-red-200",
- },
- PENDING_APPROVAL: {
- label: "Pending Approval",
- color: "bg-amber-50 text-amber-700 border-amber-200",
- },
- APPROVED: {
- label: "Approved",
- color: "bg-emerald-50 text-emerald-700 border-emerald-200",
- },
- SIGNED_CUSTOMER: {
- label: "Customer Signed",
- color: "bg-sky-50 text-sky-700 border-sky-200",
- },
- FULLY_EXECUTED: {
- label: "Fully Executed",
- color: "bg-indigo-50 text-indigo-700 border-indigo-200",
- },
- PAID: {
- label: "Paid",
- color: "bg-emerald-50 text-emerald-700 border-emerald-200",
- },
- IN_TRANSIT: {
- label: "In Transit",
- color: "bg-sky-50 text-sky-700 border-sky-200",
- },
- COMPLETED: {
- label: "Completed",
- color: "bg-indigo-50 text-indigo-700 border-indigo-200",
- },
- CANCELLED: {
- label: "Cancelled",
- color: "bg-red-50 text-red-700 border-red-200",
- },
- PENDING_CONSOLIDATION: {
- label: "Pending Consolidation",
- color: "bg-amber-50 text-amber-700 border-amber-200",
- },
- CONSOLIDATED: {
- label: "Consolidated",
- color: "bg-indigo-50 text-indigo-700 border-indigo-200",
- },
-};
-
-const PROGRESS_STAGES = [
- { label: "Request", icon: FileText, statuses: ["DRAFT", "RFQ_SUBMITTED"] },
- {
- label: "Quotation",
- icon: ShieldCheck,
- statuses: ["QUOTATION_SENT", "QUOTATION_APPROVED", "QUOTATION_REJECTED"],
- },
- {
- label: "Approval",
- icon: FileSignature,
- statuses: ["PENDING_APPROVAL", "APPROVED"],
- },
- {
- label: "Execution",
- icon: CheckCircle2,
- statuses: ["SIGNED_CUSTOMER", "FULLY_EXECUTED", "PAID"],
- },
- {
- label: "In Transit",
- icon: Train,
- statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
- },
- { label: "Complete", icon: Check, statuses: ["COMPLETED"] },
-];
-
-const STATUS_CONFIG: Record<
- string,
- { title: string; description: string; color: string; stage: number }
-> = {
- DRAFT: {
- title: "Draft",
- description: "Booking is being prepared.",
- color: "text-slate-500",
- stage: 0,
- },
- RFQ_SUBMITTED: {
- title: "RFQ Submitted",
- description: "Customer has submitted a request for quotation.",
- color: "text-amber-600",
- stage: 0,
- },
- QUOTATION_SENT: {
- title: "Quotation Sent",
- description: "A formal quotation has been sent to the customer.",
- color: "text-sky-600",
- stage: 1,
- },
- QUOTATION_APPROVED: {
- title: "Quotation Approved",
- description: "Customer approved the quotation.",
- color: "text-emerald-600",
- stage: 1,
- },
- QUOTATION_REJECTED: {
- title: "Quotation Rejected",
- description: "Customer rejected the quotation.",
- color: "text-red-600",
- stage: 1,
- },
- PENDING_APPROVAL: {
- title: "Pending Approval",
- description: "Booking requires your approval to proceed.",
- color: "text-amber-600",
- stage: 2,
- },
- APPROVED: {
- title: "Approved",
- description: "Booking has been approved by all parties.",
- color: "text-emerald-600",
- stage: 2,
- },
- SIGNED_CUSTOMER: {
- title: "Customer Signed",
- description: "Customer has signed the contract.",
- color: "text-sky-600",
- stage: 3,
- },
- FULLY_EXECUTED: {
- title: "Fully Executed",
- description: "All parties have signed.",
- color: "text-indigo-600",
- stage: 3,
- },
- PAID: {
- title: "Paid",
- description: "Payment received.",
- color: "text-emerald-600",
- stage: 3,
- },
- IN_TRANSIT: {
- title: "In Transit",
- description: "Cargo is moving through the rail network.",
- color: "text-sky-600",
- stage: 4,
- },
- PENDING_CONSOLIDATION: {
- title: "Pending Consolidation",
- description: "Cargo awaiting consolidation.",
- color: "text-amber-500",
- stage: 4,
- },
- CONSOLIDATED: {
- title: "Consolidated",
- description: "Cargo merged into larger shipment.",
- color: "text-indigo-500",
- stage: 4,
- },
- COMPLETED: {
- title: "Completed",
- description: "Service completed successfully.",
- color: "text-emerald-600",
- stage: 5,
- },
- CANCELLED: {
- title: "Cancelled",
- description: "Booking terminated.",
- color: "text-red-600",
- stage: -1,
- },
-};
-
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
+ const { data: booking, isLoading, isError, refetch, isFetching } =
+ useBookingDetail(id);
+ const mutations = useBookingMutations(id ?? "");
- const { data: bookingData } = useQuery(
- api.bookings.getById.queryOptions({
- input: { id: id ?? "" },
- enabled: Boolean(id),
- }),
- );
- const updateStatus = useUpdateBookingStatus();
-
- const booking = bookingData ? mapBookingToRequest(bookingData) : undefined;
-
- if (!booking) {
+ if (isLoading) {
return (
-
-
-
-
- Booking not found
-
- navigate("/dashboard/booking-requests")}
- >
-
- Back to Booking Requests
-
-
+
+
+
+
+ Loading booking…
+
+
);
}
- const statusConfig = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT;
- const currentStage = statusConfig.stage;
-
- const canApprove = ["PENDING_APPROVAL", "RFQ_SUBMITTED"].includes(
- booking.status,
- );
- const canReject = !["COMPLETED", "CANCELLED", "QUOTATION_REJECTED"].includes(
- booking.status,
- );
-
- const isPending = updateStatus.isPending;
-
- function handleApprove() {
- if (!booking) return;
- const action =
- booking.status === "RFQ_SUBMITTED"
- ? "SEND_QUOTATION"
- : "APPROVE";
- updateStatus.mutate({ id: booking.id, action });
+ if (isError || !booking) {
+ return (
+
+
+
+
+
+ Booking not found
+
+
+ This request may have been removed or the link is invalid.
+
+
navigate("/dashboard/booking-requests")}
+ >
+
+ Back to booking requests
+
+
+
+
+ );
}
- function handleReject() {
- if (!booking) return;
- updateStatus.mutate({ id: booking.id, action: "CANCEL", reason: "Cancelled by backoffice" });
- }
+ const row = toBookingListRow(booking);
+ const statusMeta = getStatusMeta(booking.status);
+ const amount = Number(booking.totalAmount);
return (
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
{booking.reference}
-
-
+
+
-
-
{booking.customer}
-
-
-
- Requested {booking.scheduledDate}
+
+
+
+ {row.customerLabel}
-
-
-
- {new Date(booking.createdAt).toLocaleDateString()}
+
+
+ Scheduled {booking.scheduledDate}
+
+
+
+ Created{" "}
+ {new Date(booking.createdAt).toLocaleDateString(undefined, {
+ dateStyle: "medium",
+ })}
-
-
-
- {canReject && (
-
-
- Reject
-
- )}
- {canApprove && (
-
-
- {booking.status === "RFQ_SUBMITTED"
- ? "Send Quotation"
- : "Approve"}
-
- )}
+
+
+
+ Total value
+
+
+ {booking.paymentCurrency}{" "}
+ {amount.toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ })}
+
+
+ {booking.paymentStatus}
+
+
+
refetch()}
+ >
+
+ Refresh
+
+
+
-
-
-
-
- Status Lifecycle
-
-
- Track the booking from request to completion
-
-
-
-
-
-
= 0
- ? `${(currentStage / (PROGRESS_STAGES.length - 1)) * 100}%`
- : "0%",
- }}
- />
-
- {PROGRESS_STAGES.map((stage, idx) => {
- const isCompleted = idx < currentStage;
- const isActive = idx === currentStage;
- return (
-
-
- {isCompleted ? (
-
- ) : (
-
- )}
-
-
- {stage.label}
-
-
- );
- })}
-
+
-
-
- {booking.status === "CANCELLED" ? (
-
- ) : (
-
- )}
-
-
-
- {statusConfig.title}
-
-
- {statusConfig.description}
-
-
-
-
-
-
-
-
-
-
-
-
- Route & Service
-
-
-
-
-
}
- />
-
-
-
- {booking.serviceType.replace(/_/g, " ")}
-
-
-
}
- />
-
-
-
- }
- label="Trade Direction"
- value={booking.tradeDirection}
- />
- }
- label="Return"
- value={
- booking.serviceType === "RAIL_AND_FORWARDING"
- ? "With Return"
- : "Without Return"
- }
- />
- {booking.shippingLine && (
- }
- label="Shipping Line"
- value={booking.shippingLine}
- />
- )}
-
-
-
-
- {(booking.firstMilePickupAddress ||
- booking.lastMileDeliveryAddress) && (
-
-
-
-
- Mile Services
-
-
-
- {booking.firstMilePickupAddress && (
-
-
- First Mile
-
-
-
- )}
- {booking.lastMileDeliveryAddress && (
-
-
- Last Mile
-
-
-
- )}
-
-
- )}
-
-
-
-
-
- Cargo Specifications
-
-
-
-
- }
- label="Type"
- value={booking.cargoType}
- />
- }
- label="Total Weight"
- value={`${booking.cargoTotalWeightVgm} Tons`}
- />
- {booking.shippingLine && (
- }
- label="Shipping Line"
- value={booking.shippingLine}
- />
- )}
-
-
-
-
- Hazardous: {booking.isHazardous ? "Yes" : "No"}
-
- {booking.pnrCode && (
-
- PNR: {booking.pnrCode}
-
- )}
-
-
-
+
+
+
+
+
+ {booking.contractSummary && (
+
}
+ title="Contract summary"
+ subtitle="Generated terms"
+ >
+
+ {booking.contractSummary}
+
+
+ )}
-
-
-
-
-
- Contract Info
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {canApprove && (
-
-
-
-
- Approval Required
-
-
- This booking is waiting for your review.
-
-
-
-
-
- {booking.status === "RFQ_SUBMITTED"
- ? "Send Quotation"
- : "Approve Booking"}
-
+
+
+
+ {["CONTRACT_READY", "SIGNED_CUSTOMER", "FULLY_EXECUTED"].includes(
+ booking.status,
+ ) && (
+
+
+ navigate(`/dashboard/booking-requests/${booking.id}/contract`)
+ }
>
-
- Reject
+
+ View & sign contract
-
-
+
+
+ )}
+ {(booking.status === "PENDING_APPROVAL" ||
+ booking.status === "APPROVED_PENDING_SIGNATURE") && (
+
)}
@@ -645,92 +235,221 @@ export default function BookingRequestDetailPage() {
);
}
-function StatusBadge({ status }: { status: string }) {
- const style = STATUS_STYLES[status] ?? {
- label: status,
- color: "bg-muted text-muted-foreground border-border",
- };
+function SectionShell({
+ icon,
+ title,
+ subtitle,
+ children,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ subtitle?: string;
+ children: React.ReactNode;
+}) {
return (
-
- {style.label}
-
+
+
+
+ {icon}
+
+
+
{title}
+ {subtitle && (
+
{subtitle}
+ )}
+
+
+
{children}
+
);
}
-function PriorityBadge({ score }: { score: number }) {
- if (score >= 3) {
- return (
-
- Urgent
-
- );
- }
- if (score === 2) {
- return (
-
- High
-
- );
+function RouteCard({
+ booking,
+ row,
+}: {
+ booking: BookingDetail;
+ row: ReturnType
;
+}) {
+ return (
+ }
+ title="Route & service"
+ subtitle="Corridor and service level"
+ >
+
+
+
+
+
+
+
+
+ {booking.serviceType?.label ??
+ booking.serviceType?.code ??
+ "Rail service"}
+
+
+
+
+
+
+
+
+ {booking.shippingLine && (
+
+ )}
+
+
+ );
+}
+
+function MileCard({ booking }: { booking: BookingDetail }) {
+ if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) {
+ return null;
}
return (
-
- Normal
-
+ }
+ title="Mile services"
+ subtitle="First and last mile"
+ >
+
+ {booking.firstMilePickupAddress && (
+
+ )}
+ {booking.lastMileDeliveryAddress && (
+
+ )}
+
+
+ );
+}
+
+function CargoCard({ booking }: { booking: BookingDetail }) {
+ const containers = booking.bookingContainers ?? [];
+ return (
+ }
+ title="Cargo specifications"
+ subtitle="Freight and containers"
+ >
+
+
+
+
+
+ {containers.length > 0 && (
+ <>
+
+
+
+
+
+ Container type
+ Qty
+ VGM / unit
+
+
+
+ {containers.map((c) => (
+
+
+ {c.containerType?.label ??
+ c.containerType?.code ??
+ c.containerTypeId}
+
+
+ {c.quantity}
+
+
+ {c.vgmPerUnitTons} t
+
+
+ ))}
+
+
+
+ >
+ )}
+
);
}
function RouteEndpoint({
label,
station,
- icon,
}: {
label: string;
station: string;
- icon: React.ReactNode;
}) {
return (
-
-
-
{icon}
+
+
+
-
-
+
+
{label}
-
{station}
+
{station}
);
}
-function InfoItem({
- icon,
+function MetricTile({
label,
value,
+ highlight,
}: {
- icon?: React.ReactNode;
label: string;
- value?: string | number | null;
+ value: string;
+ highlight?: boolean;
}) {
return (
-
- {icon && (
-
- {icon}
-
+
-
- {label}
-
-
{value ?? "—"}
-
+ >
+
+ {label}
+
+
+ {value}
+
);
}
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx
index 643257bbd..5300dc3ca 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx
@@ -1,30 +1,37 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
-import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
ArrowRight,
Calendar,
Clock,
- Eye,
FileText,
- Filter,
- MoreHorizontal,
+ Inbox,
+ LayoutList,
Package,
+ RefreshCw,
Search,
- ShieldCheck,
- Train,
User,
+ X,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
-import { cn } from "@/lib/utils";
-import { api } from "@/services/api";
+import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import {
- BOOKING_STATUSES,
- type BookingRequest,
- mapBookingToRequest,
-} from "./booking-requests.mock";
+ BookingStatusTabs,
+ type BookingStatusTabKey,
+} from "@/components/bookings/BookingStatusTabs";
+import { BookingStatGrid } from "@/components/bookings/BookingStatGrid";
+import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
+import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
+import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
+import { bookingInput, bookingSurface } from "@/components/bookings/booking-ui.styles";
+import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
+import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
+import { useBookingList } from "@/hooks/bookings/useBookings";
+import type { BookingListFilter } from "@/services/bookings.service";
+import type { BookingListRow } from "@/types/booking";
+import { cn } from "@/lib/utils";
import {
DataTable,
DataTableFooter,
@@ -32,190 +39,70 @@ import {
usePagination,
Badge,
Button,
- Card,
- CardHeader,
- CardTitle,
- CardDescription,
- CardContent,
Input,
- DropdownMenu,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- Separator,
} from "@edr/ui-common";
-const STATUS_STYLES: Record
= {
- DRAFT: {
- label: "Draft",
- color: "bg-slate-100 text-slate-700 border-slate-300",
- },
- RFQ_SUBMITTED: {
- label: "RFQ Submitted",
- color: "bg-amber-50 text-amber-700 border-amber-200",
- },
- QUOTATION_SENT: {
- label: "Quotation Sent",
- color: "bg-sky-50 text-sky-700 border-sky-200",
- },
- QUOTATION_APPROVED: {
- label: "Quotation Approved",
- color: "bg-emerald-50 text-emerald-700 border-emerald-200",
- },
- QUOTATION_REJECTED: {
- label: "Quotation Rejected",
- color: "bg-red-50 text-red-700 border-red-200",
- },
- PENDING_APPROVAL: {
- label: "Pending Approval",
- color: "bg-amber-50 text-amber-700 border-amber-200",
- },
- APPROVED: {
- label: "Approved",
- color: "bg-emerald-50 text-emerald-700 border-emerald-200",
- },
- SIGNED_CUSTOMER: {
- label: "Customer Signed",
- color: "bg-sky-50 text-sky-700 border-sky-200",
- },
- FULLY_EXECUTED: {
- label: "Fully Executed",
- color: "bg-indigo-50 text-indigo-700 border-indigo-200",
- },
- PAID: {
- label: "Paid",
- color: "bg-emerald-50 text-emerald-700 border-emerald-200",
- },
- IN_TRANSIT: {
- label: "In Transit",
- color: "bg-sky-50 text-sky-700 border-sky-200",
- },
- COMPLETED: {
- label: "Completed",
- color: "bg-indigo-50 text-indigo-700 border-indigo-200",
- },
- CANCELLED: {
- label: "Cancelled",
- color: "bg-red-50 text-red-700 border-red-200",
- },
- PENDING_CONSOLIDATION: {
- label: "Pending Consolidation",
- color: "bg-amber-50 text-amber-700 border-amber-200",
- },
- CONSOLIDATED: {
- label: "Consolidated",
- color: "bg-indigo-50 text-indigo-700 border-indigo-200",
- },
-};
-
-function StatusBadge({ status }: { status: string }) {
- const style = STATUS_STYLES[status] ?? {
- label: status,
- color: "bg-muted text-muted-foreground border-border",
- };
- return (
-
- {style.label}
-
- );
-}
-
-function PriorityBadge({ score }: { score: number }) {
- if (score >= 3) {
- return (
-
- Urgent
-
- );
- }
- if (score === 2) {
- return (
-
- High
-
- );
- }
- return (
-
- Normal
-
- );
+function getStatusForTab(tab: BookingStatusTabKey): string | undefined {
+ const match = BOOKING_LIST_TABS.find((t) => t.key === tab);
+ return match?.status ?? undefined;
}
export default function BookingRequestsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
- const [statusFilter, setStatusFilter] = useState(null);
+ const [activeTab, setActiveTab] = useState("SUBMITTED");
- const { data: bookingData } = useQuery(
- api.bookings.list.queryOptions({ input: { filter: { page: pagination.pageIndex + 1, pageSize: pagination.pageSize } } }),
- );
- const bookingRequests = useMemo(
- () => (bookingData?.items ?? []).map(mapBookingToRequest),
- [bookingData],
+ const filter: BookingListFilter = useMemo(
+ () => ({
+ page: pagination.pageIndex + 1,
+ pageSize: pagination.pageSize,
+ sortBy: "createdAt",
+ sortOrder: "DESC",
+ ...(getStatusForTab(activeTab) ? { status: getStatusForTab(activeTab) } : {}),
+ }),
+ [pagination.pageIndex, pagination.pageSize, activeTab],
);
- const filtered = useMemo(() => {
+ const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
+
+ const rows = useMemo(() => {
+ const items = (data?.items ?? []).map(toBookingListRow);
const q = query.trim().toLowerCase();
- return bookingRequests.filter((b) => {
- if (
- q &&
- !b.reference.toLowerCase().includes(q) &&
- !b.customer.toLowerCase().includes(q)
- ) {
- return false;
- }
- if (statusFilter && b.status !== statusFilter) {
- return false;
- }
- return true;
- });
- }, [bookingRequests, query, statusFilter]);
+ if (!q) return items;
+ return items.filter(
+ (b) =>
+ b.reference.toLowerCase().includes(q) ||
+ b.customerLabel.toLowerCase().includes(q),
+ );
+ }, [data?.items, query]);
- const total = filtered.length;
+ const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
- const start = pagination.pageIndex * pagination.pageSize;
- const end = Math.min(start + pagination.pageSize, total);
+ const hasSearch = query.trim().length > 0;
+ const showEmpty = !isLoading && !isError && rows.length === 0;
- const paginatedData = useMemo(
- () => filtered.slice(start, end),
- [start, end, filtered],
- );
+ const pendingCount = rows.filter(
+ (b) => b.status === "SUBMITTED" || b.status === "PENDING_APPROVAL",
+ ).length;
+ const urgentCount = rows.filter((b) => b.priorityScore >= 1000).length;
- const pendingCount = bookingRequests.filter(
- (b) => b.status === "PENDING_APPROVAL" || b.status === "RFQ_SUBMITTED",
- ).length;
- const activeCount = bookingRequests.filter(
- (b) => !["COMPLETED", "CANCELLED"].includes(b.status),
- ).length;
- const urgentCount = bookingRequests.filter(
- (b) => b.priorityScore >= 3,
- ).length;
-
- const columns: ColumnDef[] = [
+ const columns: ColumnDef[] = [
{
id: "booking",
- header: "Booking",
+ header: () => Booking ,
cell: ({ row }) => {
const b = row.original;
return (
-
-
-
+
+
-
-
{b.reference}
-
-
- {b.customer}
+
+
{b.reference}
+
+
+ {b.customerLabel}
@@ -224,264 +111,225 @@ export default function BookingRequestsPage() {
},
{
id: "route",
- header: "Route",
+ header: () =>
Route ,
cell: ({ row }) => {
const b = row.original;
return (
-
-
-
{b.originYard}
-
-
{b.destinationYard}
+
+
+
{b.originLabel}
+
+
{b.destinationLabel}
+
+
+
+ {b.tradeDirection}
+
+
+ {b.freightType}
+
-
- {b.tradeDirection}
-
);
},
},
{
id: "status",
- header: "Status",
- cell: ({ row }) =>
,
+ header: () =>
Status ,
+ cell: ({ row }) =>
,
},
{
- id: "service",
- header: "Service",
- cell: ({ row }) => {
- const b = row.original;
- return (
-
-
- {b.serviceType.replace(/_/g, " ")}
-
-
-
- {b.scheduledDate}
-
-
- );
- },
- },
- {
- id: "cargo",
- header: "Cargo",
- cell: ({ row }) => {
- const b = row.original;
- return (
-
-
- {b.cargoType}
-
-
- {b.cargoTotalWeightVgm}T
-
-
- );
- },
+ id: "scheduled",
+ header: () =>
Scheduled ,
+ cell: ({ row }) => (
+
+
+ {row.original.scheduledDate}
+
+ ),
},
{
id: "priority",
- header: "Priority",
- cell: ({ row }) =>
,
+ header: () =>
Priority ,
+ cell: ({ row }) => (
+
+ ),
},
{
id: "amount",
- header: "Amount",
+ header: () => (
+
Amount
+ ),
cell: ({ row }) => {
const b = row.original;
return (
-
- {b.paymentCurrency} {b.totalAmount.toLocaleString()}
+
+ {b.paymentCurrency}{" "}
+ {b.totalAmount.toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ })}
);
},
},
{
id: "actions",
- size: 40,
- cell: ({ row }) => {
- const b = row.original;
- return (
- e.stopPropagation()}
- >
-
-
-
-
-
-
-
-
- navigate(`/dashboard/booking-requests/${b.id}`)
- }
- >
-
- View Details
-
-
-
- navigate(`/dashboard/booking-requests/${b.id}`)
- }
- >
-
- Review
-
-
-
-
- );
- },
+ size: 140,
+ header: () => (
+
+ Actions
+
+ ),
+ cell: ({ row }) => (
+
+ ),
},
];
return (
-
-
-
+
+
+
-
-
-
- Booking Requests
-
-
- Review, approve, or reject customer booking requests across the
- freight network.
-
+
+
+
+
+
+
+
+
+
+ Booking requests
+
+
+ Track bookings from submission through payment and operations.
+
+
+
+
+ refetch()}
+ >
+
+ Refresh
+
+
+
-
-
-
+
0 ? "rose" : "default",
+ },
+ ]}
+ />
+
+ {
+ setActiveTab(tab);
+ setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
+ }}
+ counts={{
+ [activeTab]: total,
+ }}
+ />
+
+
+
+
+
{
- setQuery(e.target.value);
- setPagination({
- pageIndex: 0,
- pageSize: pagination.pageSize,
- });
- }}
- placeholder="Search reference or customer..."
- className="pl-8!"
+ onChange={(e) => setQuery(e.target.value)}
+ placeholder="Search reference or customer…"
+ className={bookingInput.search}
/>
+ {query && (
+ setQuery("")}
+ aria-label="Clear search"
+ >
+
+
+ )}
+
+
+
+
+ {total} record{total !== 1 ? "s" : ""}
+
-
-
- }
- />
- }
- />
- } />
- } />
-
-
-
-
-
- All Booking Requests
-
- {total} request{total !== 1 ? "s" : ""} found
-
-
-
-
- {statusFilter && (
- setStatusFilter(null)}
- >
- Clear filter
-
- )}
-
-
-
-
- {statusFilter
- ? (STATUS_STYLES[statusFilter]?.label ?? "Filter")
- : "Filter"}
-
-
-
- {BOOKING_STATUSES.map((s) => (
- setStatusFilter(s)}
- >
- {STATUS_STYLES[s]?.label ?? s}
-
- ))}
-
-
-
-
-
-
-
- navigate(`/dashboard/booking-requests/${row.id}`)
- }
- pagination={{
- pageIndex: pagination.pageIndex,
- pageSize: pagination.pageSize,
- pageCount,
- totalCount: total,
- }}
- tableOptions={{
- state: { pagination },
- onPaginationChange: setPagination,
- }}
- containerClassName="border-b shadow-none"
- footer={DataTableFooter}
+ {showEmpty ? (
+ refetch()}
/>
-
-
+ ) : (
+
+
+ navigate(`/dashboard/booking-requests/${row.id}`)
+ }
+ pagination={{
+ pageIndex: pagination.pageIndex,
+ pageSize: pagination.pageSize,
+ pageCount,
+ totalCount: total,
+ }}
+ tableOptions={{
+ state: { pagination },
+ onPaginationChange: setPagination,
+ manualPagination: true,
+ pageCount,
+ }}
+ containerClassName="border-0 shadow-none [&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/40"
+ footer={DataTableFooter}
+ />
+
+ )}
+
);
}
-
-function StatCard({
- label,
- value,
- icon,
-}: {
- label: string;
- value: number;
- icon: React.ReactNode;
-}) {
- return (
-
-
-
-
- {icon}
-
-
-
- );
-}
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts b/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts
index 9adb8eb33..8b738bc5b 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts
@@ -1,180 +1,2 @@
-import type { Freight } from "@edr/types";
-
-export interface BookingRequest {
- id: string;
- reference: string;
- customer: string;
- status: (typeof BOOKING_STATUSES)[number];
- scheduledDate: string;
- totalAmount: number;
- paymentStatus: string;
- contractType: string;
- serviceType: string;
- tradeDirection: string;
- originYard: string;
- destinationYard: string;
- cargoType: string;
- cargoTotalWeightVgm: number;
- isHazardous: boolean;
- paymentCurrency: string;
- priorityScore: number;
- firstMilePickupAddress: string | null;
- lastMileDeliveryAddress: string | null;
- shippingLine: string | null;
- pnrCode: string | null;
- createdBy: string;
- createdAt: string;
- updatedAt: string;
-}
-
-export const BOOKING_STATUSES = [
- "DRAFT",
- "RFQ_SUBMITTED",
- "QUOTATION_SENT",
- "QUOTATION_APPROVED",
- "QUOTATION_REJECTED",
- "PENDING_APPROVAL",
- "APPROVED",
- "SIGNED_CUSTOMER",
- "FULLY_EXECUTED",
- "PAID",
- "IN_TRANSIT",
- "COMPLETED",
- "CANCELLED",
- "PENDING_CONSOLIDATION",
- "CONSOLIDATED",
-] as const;
-
-const customers = [
- "Ethio Cargo Logistics",
- "Djibouti Shipping PLC",
- "Horn of Africa Traders",
- "Addis Freight Forwarders",
- "Red Sea Maritime Services",
- "Dire Dawa Imports Ltd",
- "Awash Agro Industry",
- "Mieso Mineral Exports",
-];
-
-const yards = [
- "Addis Ababa Dry Port",
- "Mojo Inland Container Depot",
- "Dire Dawa Freight Station",
- "Djibouti Port Terminal",
- "Adama Logistics Hub",
- "Awash Cargo Center",
-];
-
-const serviceTypes = ["RAIL", "RAIL_AND_FORWARDING"];
-const tradeDirections = ["EXPORT", "IMPORT", "DOMESTIC"];
-const cargoTypes = ["Containerized", "Bulk", "Liquid", "Refrigerated", "Hazardous"];
-const shippingLines = ["MSC", "CMA CGM", "Maersk", "COSCO", "Hapag-Lloyd", null];
-
-function pick
(arr: T[], index: number): T {
- return arr[index % arr.length];
-}
-
-function randDate(daysAgo: number): string {
- const d = new Date(2026, 4, 28 - daysAgo);
- return d.toISOString();
-}
-
-const now = Date.now();
-
-const INITIAL_REQUESTS: BookingRequest[] = Array.from({ length: 25 }, (_, i) => {
- const statusIndex = i % BOOKING_STATUSES.length;
- const status = BOOKING_STATUSES[statusIndex];
- const customer = pick(customers, i);
-
- return {
- id: String(i + 1),
- reference: `EDR-BK-${String(2026001 + i).slice(-6)}`,
- customer,
- status,
- scheduledDate: new Date(2026, 5, 1 + (i % 28)).toISOString().slice(0, 10),
- totalAmount: 1500 + i * 320 + (i % 7) * 100,
- paymentStatus: status === "PAID" || status === "COMPLETED" ? "PAID" : status === "CANCELLED" ? "REFUNDED" : "PENDING",
- contractType: i % 5 === 0 ? "RENEWAL" : "NEW",
- serviceType: pick(serviceTypes, i),
- tradeDirection: pick(tradeDirections, i),
- originYard: pick(yards, i),
- destinationYard: pick(yards, i + 3),
- cargoType: pick(cargoTypes, i),
- cargoTotalWeightVgm: 10 + ((i * 7) % 90),
- isHazardous: i % 7 === 0,
- paymentCurrency: "USD",
- priorityScore: i % 4 === 0 ? 3 : i % 3 === 0 ? 2 : 1,
- firstMilePickupAddress: i % 3 === 0 ? "Bole Industrial Zone, Addis Ababa" : null,
- lastMileDeliveryAddress: i % 4 === 0 ? "Port Boulevard, Djibouti City" : null,
- shippingLine: pick(shippingLines, i),
- pnrCode: i % 6 === 0 ? `PNR-${202600 + i}` : null,
- createdBy: customer,
- createdAt: randDate(30 - i),
- updatedAt: randDate(2),
- };
-});
-
-export function saveBookingRequestsToStorage(data: BookingRequest[]) {
- if (typeof window !== "undefined" && window.localStorage) {
- localStorage.setItem("edr_backoffice_booking_requests", JSON.stringify(data));
- }
-}
-
-export function getBookingRequestById(id: string): BookingRequest | undefined {
- const requests = getBookingRequests();
- return requests.find((r) => r.id === id);
-}
-
-export function updateBookingRequestStatus(id: string, newStatus: (typeof BOOKING_STATUSES)[number]) {
- const requests = getBookingRequests();
- const idx = requests.findIndex((r) => r.id === id);
- if (idx === -1) return;
- requests[idx] = { ...requests[idx], status: newStatus, updatedAt: new Date().toISOString() };
- saveBookingRequestsToStorage(requests);
-}
-
-export function mapBookingToRequest(booking: Freight.IBooking): BookingRequest {
- return {
- id: booking.id,
- reference: booking.reference,
- customer: booking.customerId,
- status: booking.status as BookingRequest["status"],
- scheduledDate: booking.scheduledDate,
- totalAmount: booking.totalAmount,
- paymentStatus: booking.paymentStatus,
- contractType: booking.contractType,
- serviceType:
- booking.serviceType === "RAIL_ONLY" ? "RAIL" : (booking.serviceType as string),
- tradeDirection: booking.tradeDirection as string,
- originYard: booking.originStation,
- destinationYard: booking.destinationStation,
- cargoType: booking.freightType ?? booking.freightSubtype ?? "",
- cargoTotalWeightVgm: booking.cargoTotalWeightVgm,
- isHazardous: booking.isHazardous,
- paymentCurrency: booking.paymentCurrency,
- priorityScore: booking.priorityScore,
- firstMilePickupAddress: booking.firstMilePickupAddress ?? null,
- lastMileDeliveryAddress: booking.lastMileDeliveryAddress ?? null,
- shippingLine: null,
- pnrCode: null,
- createdBy: booking.customerId,
- createdAt: booking.createdAt,
- updatedAt: booking.updatedAt,
- };
-}
-
-export function getBookingRequests(): BookingRequest[] {
- if (typeof window === "undefined" || !window.localStorage) {
- return INITIAL_REQUESTS;
- }
- const data = localStorage.getItem("edr_backoffice_booking_requests");
- if (!data) {
- localStorage.setItem("edr_backoffice_booking_requests", JSON.stringify(INITIAL_REQUESTS));
- return INITIAL_REQUESTS;
- }
- try {
- return JSON.parse(data);
- } catch {
- return INITIAL_REQUESTS;
- }
-}
+/** @deprecated Use BookingDetail from @/types/booking — kept for gradual migration */
+export type { BookingListRow as BookingRequest } from "@/types/booking";
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/bookings.mock.ts b/apps/edr-freight-web/backoffice/src/pages/bookings/bookings.mock.ts
new file mode 100644
index 000000000..633c7cb32
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/bookings.mock.ts
@@ -0,0 +1,9 @@
+/** Demo portal mock data — booking requests use the live API instead. */
+export interface Booking {
+ id: number | string;
+ customerId: number | string;
+ reference?: string;
+ status?: string;
+}
+
+export const bookings: Booking[] = [];
diff --git a/apps/edr-freight-web/backoffice/src/pages/cargoes/CargoesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/cargoes/CargoesPage.tsx
new file mode 100644
index 000000000..4733d23cf
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/cargoes/CargoesPage.tsx
@@ -0,0 +1,34 @@
+import { useCargoes } from '@/hooks/useCargoes';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Badge } from '@/components/ui/badge';
+import { LoadCargoDialog } from '@/components/cargoes/LoadCargoDialog';
+
+export default function CargoesPage() {
+ const { data: cargoes, refetch, isLoading } = useCargoes();
+ if (isLoading) return Loading cargoes...
;
+ return (
+
+ All Cargoes
+
+
+ Reference Description Quantity Weight Status Actions
+
+ {cargoes?.map(c => (
+
+ {c.cargoReference}
+ {c.description || '-'}
+ {c.quantity}
+ {c.weight} kg
+ {c.status}
+
+ {c.status === 'PENDING' && refetch()} />}
+
+
+ ))}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/pages/containers_management/ContainersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/containers_management/ContainersPage.tsx
new file mode 100644
index 000000000..5c531a434
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/containers_management/ContainersPage.tsx
@@ -0,0 +1,29 @@
+import { useContainers } from '@/hooks/useContainers';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Badge } from '@/components/ui/badge';
+
+export default function ContainersPage() {
+ const { data: containers, isLoading } = useContainers();
+ if (isLoading) return Loading containers...
;
+ return (
+
+ All Containers
+
+
+ Number Type Wagon Status
+
+ {containers?.map(c => (
+
+ {c.containerNumber}
+ {c.containerTypeId}
+ {c.wagonId || 'Unassigned'}
+ {c.status}
+
+ ))}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
index 7f8f6cfa4..c81f1d6e7 100644
--- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from "react";
+import { useCallback, useMemo, useState } from "react";
import { Navigate, useLocation, useParams } from "react-router-dom";
import type { ColumnDef } from "@tanstack/react-table";
import { Loader2 } from "lucide-react";
@@ -9,7 +9,6 @@ import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordAct
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
import {
- ruleEngineField,
ruleEngineSurface,
ruleEngineTable,
} from "@/components/ruleEngine/ruleEngineStyles";
@@ -26,6 +25,7 @@ import {
useApprovalChain,
useCargoTypeParentOptions,
useContainerTypeOptions,
+ useLiveRateOptions,
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
@@ -41,8 +41,6 @@ import {
DialogDescription,
DialogHeader,
DialogTitle,
- Input,
- Label,
getCoreRowModel,
usePagination,
useReactTable,
@@ -73,9 +71,6 @@ const RuleEngineResourcePage = () => {
const [editing, setEditing] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [chainOpen, setChainOpen] = useState(false);
- const [approveTarget, setApproveTarget] = useState(null);
- const [ceoId, setCeoId] = useState("");
-
const { viewMode, setViewMode } = useRuleEngineViewMode(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
);
@@ -103,33 +98,52 @@ const RuleEngineResourcePage = () => {
);
const editingId = editing?.id ? String(editing.id) : undefined;
+ const usesContainerTypeField = Boolean(
+ config?.formFields.some((f) => f.name === "containerTypeId"),
+ );
+ const usesLiveRateField = Boolean(
+ config?.formFields.some((f) => f.name === "rateId"),
+ );
+
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
- useContainerTypeOptions(config?.slug === "rates");
+ useContainerTypeOptions(
+ config?.slug === "rates",
+ usesContainerTypeField,
+ );
+ const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
+ useLiveRateOptions(usesLiveRateField);
const formFields = useMemo(() => {
if (!config) return [];
- return config.formFields.map((field) =>
- config.slug === "cargo-types" && field.name === "parentGroupId"
- ? {
- ...field,
- options:
- cargoParentOptions ?? [
- { label: "None", value: RULE_ENGINE_SELECT_NONE },
- ],
- }
- : config.slug === "rates" && field.name === "containerTypeId"
- ? {
- ...field,
- options:
- containerTypeOptions ?? [
- { label: "None", value: RULE_ENGINE_SELECT_NONE },
- ],
- }
- : field,
- );
- }, [config, cargoParentOptions, containerTypeOptions]);
+ return config.formFields.map((field) => {
+ if (config.slug === "cargo-types" && field.name === "parentGroupId") {
+ return {
+ ...field,
+ options:
+ cargoParentOptions ?? [
+ { label: "None", value: RULE_ENGINE_SELECT_NONE },
+ ],
+ };
+ }
+ if (field.name === "containerTypeId") {
+ return {
+ ...field,
+ type: "select" as const,
+ options: containerTypeOptions ?? [],
+ };
+ }
+ if (field.name === "rateId") {
+ return {
+ ...field,
+ type: "select" as const,
+ options: liveRateOptions ?? [],
+ };
+ }
+ return field;
+ });
+ }, [config, cargoParentOptions, containerTypeOptions, liveRateOptions]);
const rows = data?.data ?? [];
const meta = data?.meta;
@@ -163,6 +177,13 @@ const RuleEngineResourcePage = () => {
onPaginationChange: setPagination,
});
+ const handleApproveRate = useCallback(
+ (record: RuleEngineRecord) => {
+ approve.mutate(String(record.id));
+ },
+ [approve],
+ );
+
const columns = useMemo((): ColumnDef[] => {
if (!config) return [];
@@ -195,14 +216,14 @@ const RuleEngineResourcePage = () => {
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
}
onSubmitRate={(id) => submit.mutate(id)}
- onApproveRate={setApproveTarget}
+ onApproveRate={handleApproveRate}
/>
),
});
return base;
- }, [config, submit]);
+ }, [config, submit, handleApproveRate]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
@@ -318,7 +339,7 @@ const RuleEngineResourcePage = () => {
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
}
onSubmitRate={(id) => submit.mutate(id)}
- onApproveRate={setApproveTarget}
+ onApproveRate={handleApproveRate}
/>
)}
@@ -337,7 +358,8 @@ const RuleEngineResourcePage = () => {
isSubmitting={create.isPending || update.isPending}
selectOptionsLoading={
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
- (config.slug === "rates" && containerTypeOptionsLoading)
+ (usesContainerTypeField && containerTypeOptionsLoading) ||
+ (usesLiveRateField && liveRateOptionsLoading)
}
onSubmit={handleFormSubmit}
/>
@@ -370,51 +392,6 @@ const RuleEngineResourcePage = () => {
-
!o && setApproveTarget(null)}>
-
-
- Approve rate
- Enter the CEO staff ID to approve this rate.
-
-
-
-
- CEO staff ID
-
- setCeoId(e.target.value)}
- placeholder="UUID"
- className={ruleEngineField.input}
- />
-
-
- setApproveTarget(null)}>
- Cancel
-
- {
- if (!approveTarget) return;
- approve.mutate(
- { id: approveTarget.id, payload: { approvedByCeoId: ceoId.trim() } },
- {
- onSuccess: () => {
- setApproveTarget(null);
- setCeoId("");
- },
- },
- );
- }}
- >
- {approve.isPending ? : "Approve"}
-
-
-
-
-
-
diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
index 3fe1b59cb..3ac0286d3 100644
--- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
+++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts
@@ -3,7 +3,16 @@ import type { RuleEngineResourceSlug } from "@/types/rule-engine";
export type RuleEngineNavCategory = "configuration" | "rules";
-export type ColumnFormat = "text" | "code" | "boolean" | "activeBadge" | "rateStatus" | "date" | "number";
+export type ColumnFormat =
+ | "text"
+ | "code"
+ | "boolean"
+ | "activeBadge"
+ | "rateStatus"
+ | "date"
+ | "number"
+ | "entityLabel"
+ | "rateLabel";
export type FormFieldType = "text" | "number" | "boolean" | "date" | "select" | "textarea";
@@ -187,7 +196,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "score", label: "Score", type: "number", required: true },
- { name: "conditionCurrency", label: "Condition currency", type: "text", placeholder: "USD (optional)" },
+ {
+ name: "conditionCurrency",
+ label: "Condition currency",
+ type: "select",
+ optional: true,
+ options: [{ label: "Any", value: RULE_ENGINE_SELECT_NONE }, ...CURRENCIES],
+ placeholder: "Any currency (optional)",
+ },
{ name: "isActive", label: "Active", type: "boolean" },
],
},
@@ -226,7 +242,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" },
{ id: "triggerCondition", header: "Trigger", accessorKey: "triggerCondition" },
- { id: "rateId", header: "Rate ID", accessorKey: "rateId" },
+ { id: "rateId", header: "Rate", accessorKey: "rate", format: "rateLabel" },
activeColumn,
],
formFields: [
@@ -238,7 +254,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
required: true,
options: SURCHARGE_TRIGGERS,
},
- { name: "rateId", label: "Rate ID", type: "text", required: true, placeholder: "UUID of LIVE rate" },
+ {
+ name: "rateId",
+ label: "Live rate",
+ type: "select",
+ required: true,
+ placeholder: "Select a LIVE rate",
+ },
{ name: "isActive", label: "Active", type: "boolean" },
],
},
@@ -249,14 +271,25 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
subtitle: "VGM limits by container and trade direction",
searchPlaceholder: "Search weight limit rules...",
columns: [
- { id: "containerTypeId", header: "Container", accessorKey: "containerTypeId" },
+ {
+ id: "containerType",
+ header: "Container",
+ accessorKey: "containerType",
+ format: "entityLabel",
+ },
{ id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" },
{ id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" },
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
{ id: "effectiveTo", header: "To", accessorKey: "effectiveTo", format: "date" },
],
formFields: [
- { name: "containerTypeId", label: "Container type ID", type: "text", required: true },
+ {
+ name: "containerTypeId",
+ label: "Container type",
+ type: "select",
+ required: true,
+ placeholder: "Select container type",
+ },
{
name: "tradeDirection",
label: "Trade direction",
@@ -349,7 +382,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "currency", label: "Currency", type: "select", required: true, options: CURRENCIES },
{ name: "rateValue", label: "Rate value", type: "number", required: true },
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
- { name: "proposedByStaffId", label: "Proposed by (staff ID)", type: "text", required: true },
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
{ name: "effectiveTo", label: "Effective to", type: "date" },
],
diff --git a/apps/edr-freight-web/backoffice/src/pages/trains/TrainDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trains/TrainDetailPage.tsx
new file mode 100644
index 000000000..500fc3c91
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/trains/TrainDetailPage.tsx
@@ -0,0 +1,33 @@
+import { useParams } from 'react-router-dom';
+import { useTrain } from '@/hooks/useTrains';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Skeleton } from '@/components/ui/skeleton';
+import { AssignWagonDialog } from '@/components/AssignWagonDialog';
+import { WagonsTable } from '@/components/WagonsTable';
+
+export default function TrainDetailPage() {
+ const { id } = useParams<{ id: string }>();
+ const { data: train, isLoading } = useTrain(id!);
+
+ if (isLoading) return ;
+ if (!train) return Train not found
;
+
+ return (
+
+
+ {train.trainNumber || train.code} - {train.trainName || 'Unnamed'}
+
+ Status: {train.status}
+ Capacity: {train.capacityTons} tons
+ Origin Station: {train.originStationId || '-'}
+ Destination: {train.destinationStationId || '-'}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx
index a9d12eb6a..ef6dba506 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx
@@ -1,12 +1,838 @@
-import FeaturePlaceholder from "@/components/FeaturePlaceholder";
+<<<<<<< HEAD
+import { useState } from 'react';
+import { useTrains, useDeleteTrain, useCreateTrain } from '@/hooks/useTrains';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useToast } from '@/hooks/use-toast';
+import { Plus, Eye, Trash2 } from 'lucide-react';
+import { Link } from 'react-router-dom';
+
+const CreateTrainForm = ({ onSuccess }: { onSuccess: () => void }) => {
+ const [form, setForm] = useState({ code: '', capacityTons: 0, trainNumber: '', trainName: '' });
+ const createTrain = useCreateTrain();
+ const { toast } = useToast();
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ try {
+ await createTrain.mutateAsync(form);
+ toast({ title: 'Train created', description: `${form.code} added.` });
+ onSuccess();
+ } catch {
+ toast({ title: 'Error', description: 'Failed to create train.', variant: 'destructive' });
+ }
+ };
+
+ return (
+
+=======
+import { useMemo, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { isAxiosError } from 'axios';
+import toast from 'react-hot-toast';
+import { Calendar, RefreshCw, TrainTrack } from 'lucide-react';
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@edr/ui-common';
+
+import Breadcrumbs from '@/components/ui/Breadcrumbs';
+import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
+import { trainSchedulingService } from '@/services/trainScheduling.service';
+import type {
+ EligibleContainerBooking,
+ TrainScheduleFilters,
+ TrainSchedulePreviewResponse,
+ YardOption,
+} from '@/types/trainScheduling';
+
+const inputClassName =
+ 'w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-950';
+
+const formatDate = (value?: string | null) => {
+ if (!value) return '-';
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return '-';
+ return new Intl.DateTimeFormat('en', {
+ year: 'numeric',
+ month: 'short',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(date);
+};
+
+const formatDayInput = (value?: string | null) => {
+ if (!value) return '';
+ return value.slice(0, 10);
+};
+
+const parseError = (error: unknown, fallback: string) => {
+ if (isAxiosError(error)) {
+ const message = error.response?.data?.message;
+ if (Array.isArray(message)) return message.join(', ');
+ if (typeof message === 'string') return message;
+ const violations = error.response?.data?.violations;
+ if (Array.isArray(violations)) return violations.join(', ');
+ }
+ return fallback;
+};
+
+const deriveFromBooking = (
+ booking: EligibleContainerBooking | undefined,
+ stations: YardOption[],
+) => {
+ if (!booking) {
+ return { originStationId: '', destinationStationId: '', scheduleDate: '' };
+ }
+
+ const originStationId = stations.find((station) => station.name === booking.origin)?.id ?? '';
+ const destinationStationId =
+ stations.find((station) => station.name === booking.destination)?.id ?? '';
+
+ return {
+ originStationId,
+ destinationStationId,
+ scheduleDate: formatDayInput(booking.preferredDepartureDate),
+ };
+};
const TrainsPage = () => {
+ const qc = useQueryClient();
+ const [filters, setFilters] = useState({});
+ const [selectedBookingIds, setSelectedBookingIds] = useState([]);
+ const [preview, setPreview] = useState(null);
+ const [selectedLocomotiveId, setSelectedLocomotiveId] = useState('');
+ const [detailId, setDetailId] = useState(null);
+ const [scheduleSearch, setScheduleSearch] = useState('');
+ const [scheduleStatusFilter, setScheduleStatusFilter] = useState('ALL');
+
+ const stationsQuery = useQuery({
+ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.stations(),
+ queryFn: () => trainSchedulingService.getStations(),
+ });
+
+ const eligibleQuery = useQuery({
+ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.eligible(filters),
+ queryFn: () => trainSchedulingService.getEligibleBookings(filters),
+ });
+
+ const locomotivesQuery = useQuery({
+ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(),
+ queryFn: () => trainSchedulingService.getAvailableLocomotives(),
+ });
+
+ const schedulesQuery = useQuery({
+ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules(),
+ queryFn: () => trainSchedulingService.listSchedules(),
+ });
+
+ const detailQuery = useQuery({
+ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(detailId ?? ''),
+ queryFn: () => trainSchedulingService.getScheduleById(detailId!),
+ enabled: Boolean(detailId),
+ });
+
+ const eligibleItems = eligibleQuery.data?.items ?? [];
+ const filteredSchedules = useMemo(() => {
+ const query = scheduleSearch.trim().toLowerCase();
+
+ return (schedulesQuery.data ?? []).filter((schedule) => {
+ const matchesStatus =
+ scheduleStatusFilter === 'ALL' || schedule.status === scheduleStatusFilter;
+
+ if (!matchesStatus) {
+ return false;
+ }
+
+ if (!query) {
+ return true;
+ }
+
+ const haystack = [
+ schedule.id,
+ schedule.origin ?? '',
+ schedule.destination ?? '',
+ schedule.locomotive?.code ?? '',
+ schedule.status,
+ ]
+ .join(' ')
+ .toLowerCase();
+
+ return haystack.includes(query);
+ });
+ }, [scheduleSearch, scheduleStatusFilter, schedulesQuery.data]);
+ const selectedBookings = useMemo(
+ () => eligibleItems.filter((booking) => selectedBookingIds.includes(booking.id)),
+ [eligibleItems, selectedBookingIds],
+ );
+
+ const summary = useMemo(() => {
+ const totalWeightTons = selectedBookings.reduce((sum, booking) => sum + booking.weightTons, 0);
+ const wagonsNeeded = Math.ceil(totalWeightTons / 70);
+ const totalLengthMeters = wagonsNeeded * 14;
+ const routeSet = new Set(selectedBookings.map((booking) => `${booking.origin} -> ${booking.destination}`));
+ const dateSet = new Set(selectedBookings.map((booking) => formatDayInput(booking.preferredDepartureDate)));
+
+ return {
+ count: selectedBookings.length,
+ totalWeightTons,
+ wagonsNeeded: Number.isFinite(wagonsNeeded) ? wagonsNeeded : 0,
+ totalLengthMeters: Number.isFinite(totalLengthMeters) ? totalLengthMeters : 0,
+ route: routeSet.size === 1 ? [...routeSet][0] : selectedBookings.length ? 'Mixed route' : '-',
+ scheduleDate: dateSet.size === 1 ? [...dateSet][0] : selectedBookings.length ? 'Mixed date' : '-',
+ };
+ }, [selectedBookings]);
+
+ const previewMutation = useMutation({
+ mutationFn: () => {
+ if (!filters.originStationId || !filters.destinationStationId || !filters.scheduleDate) {
+ throw new Error('Please select origin, destination, and schedule date');
+ }
+ return trainSchedulingService.preview({
+ bookingIds: selectedBookingIds,
+ scheduleDate: new Date(`${filters.scheduleDate}T08:00:00.000Z`).toISOString(),
+ originStationId: filters.originStationId,
+ destinationStationId: filters.destinationStationId,
+ });
+ },
+ onSuccess: (data) => {
+ setPreview(data);
+ toast.success(data.valid ? 'Preview generated' : 'Preview has validation issues');
+ },
+ onError: (error) => {
+ toast.error(parseError(error, 'Failed to preview train schedule'));
+ },
+ });
+
+ const createMutation = useMutation({
+ mutationFn: () => {
+ if (!selectedLocomotiveId) {
+ throw new Error('Please select a locomotive');
+ }
+ if (!filters.originStationId || !filters.destinationStationId || !filters.scheduleDate) {
+ throw new Error('Please select origin, destination, and schedule date');
+ }
+ return trainSchedulingService.createSchedule({
+ bookingIds: selectedBookingIds,
+ scheduleDate: new Date(`${filters.scheduleDate}T08:00:00.000Z`).toISOString(),
+ originStationId: filters.originStationId,
+ destinationStationId: filters.destinationStationId,
+ locomotiveId: selectedLocomotiveId,
+ });
+ },
+ onSuccess: (data) => {
+ toast.success('Train schedule created');
+ setSelectedBookingIds([]);
+ setSelectedLocomotiveId('');
+ setPreview(null);
+ void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
+ void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() });
+ void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() });
+ setDetailId(data.id);
+ },
+ onError: (error) => {
+ toast.error(parseError(error, 'Failed to create train schedule'));
+ },
+ });
+
+ const cancelMutation = useMutation({
+ mutationFn: (id: string) => trainSchedulingService.cancelSchedule(id),
+ onSuccess: (data) => {
+ toast.success('Train schedule cancelled');
+ void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() });
+ void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() });
+ void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT });
+ void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(data.id) });
+ setDetailId(data.id);
+ },
+ onError: (error) => {
+ toast.error(parseError(error, 'Failed to cancel train schedule'));
+ },
+ });
+
+ const toggleBooking = (booking: EligibleContainerBooking, checked: boolean) => {
+ setSelectedBookingIds((current) => {
+ if (checked) {
+ const next = [...new Set([...current, booking.id])];
+ if (next.length === 1) {
+ const defaults = deriveFromBooking(booking, stationsQuery.data ?? []);
+ setFilters((prev) => ({
+ ...prev,
+ originStationId: prev.originStationId || defaults.originStationId,
+ destinationStationId: prev.destinationStationId || defaults.destinationStationId,
+ scheduleDate: prev.scheduleDate || defaults.scheduleDate,
+ }));
+ }
+ return next;
+ }
+ return current.filter((id) => id !== booking.id);
+ });
+ setPreview(null);
+ };
+
+ const detail = detailQuery.data;
+ const isBusy = previewMutation.isPending || createMutation.isPending;
+
return (
-
+
+
+
+
+
+
+
+
+
+
+
Train Scheduling
+
+ Build container train schedules from compatible bookings, preview wagon plans, and assign locomotives.
+
+
+
+
{
+ void eligibleQuery.refetch();
+ void schedulesQuery.refetch();
+ void locomotivesQuery.refetch();
+ }}
+ >
+
+ Refresh
+
+
+
+
+
+
+
+
+
+ Filters
+
+
+
+
+ Origin station
+
+ setFilters((current) => ({
+ ...current,
+ originStationId: value === '__all__' ? undefined : value,
+ }))
+ }
+ >
+
+
+
+
+ All origins
+ {(stationsQuery.data ?? []).map((station) => (
+
+ {station.name}
+
+ ))}
+
+
+
+
+ Destination station
+
+ setFilters((current) => ({
+ ...current,
+ destinationStationId: value === '__all__' ? undefined : value,
+ }))
+ }
+ >
+
+
+
+
+ All destinations
+ {(stationsQuery.data ?? []).map((station) => (
+
+ {station.name}
+
+ ))}
+
+
+
+
+ Schedule date
+
+ setFilters((current) => ({
+ ...current,
+ scheduleDate: event.target.value || undefined,
+ }))
+ }
+ />
+
+
+ Booking status
+
+ setFilters((current) => ({
+ ...current,
+ status: event.target.value || undefined,
+ }))
+ }
+ />
+
+
+
+
+
+
+
+
Eligible container bookings
+
+ Only container bookings not already assigned to a schedule appear here.
+
+
+
+ {eligibleQuery.data?.count ?? 0} bookings
+
+
+
+
+
+
+
+
+
+ Schedule builder
+
+
+
Selected bookings
+
{summary.count}
+
+
+
Total weight
+
{summary.totalWeightTons.toLocaleString()} T
+
+
+
Route
+
{summary.route}
+
+
+
Schedule date
+
{summary.scheduleDate}
+
+
+
Estimated wagon type
+
NW5
+
+
+
Estimated wagons / length
+
+ {summary.wagonsNeeded} wagons / {summary.totalLengthMeters} m
+
+
+
+
+
+
previewMutation.mutate()}
+ >
+ Preview schedule
+
+
+
+ Locomotive
+
+
+
+
+
+ {(locomotivesQuery.data ?? []).map((locomotive) => (
+
+ {locomotive.code} - {locomotive.maxPullWeightTons}T
+
+ ))}
+
+
+
+
+
createMutation.mutate()}
+ >
+ Create schedule
+
+
+
+ {preview ? (
+
+
+
Preview result
+
+ {preview.valid ? 'Valid' : 'Invalid'}
+
+
+
+
+
+
Wagons
+
{preview.summary.wagonsNeeded}
+
+
+
Weight
+
{preview.summary.totalWeightTons} T
+
+
+
Length
+
{preview.summary.totalLengthMeters} m
+
+
+
+ {preview.violations.length > 0 ? (
+
+
+ {preview.violations.map((violation) => (
+ {violation}
+ ))}
+
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+
+
Created schedules
+
Open a schedule to inspect wagons and allocations.
+
+
+ {filteredSchedules.length} schedules
+
+
+
+
+ setScheduleSearch(event.target.value)}
+ />
+
+
+
+
+
+ All statuses
+ DRAFT
+ SCHEDULED
+ DISPATCHED
+ ARRIVED
+ CANCELLED
+
+
+
+
+
+
+
+
+ Schedule
+ Departure
+ Route
+ Locomotive
+ Bookings
+ Wagons
+ Weight
+ Length
+ Status
+ Actions
+
+
+
+ {filteredSchedules.map((schedule) => (
+
+ {schedule.id}
+ {formatDate(schedule.scheduleDate)}
+
+ {schedule.origin} to {schedule.destination}
+
+ {schedule.locomotive?.code ?? '-'}
+ {schedule.bookingsCount}
+ {schedule.wagonCount}
+ {schedule.totalWeightTons} T
+ {schedule.totalLengthMeters} m
+ {schedule.status}
+
+
+ setDetailId(schedule.id)}>
+ View
+
+ {schedule.status !== 'CANCELLED' ? (
+ cancelMutation.mutate(schedule.id)}
+ >
+ Cancel
+
+ ) : null}
+
+
+
+ ))}
+ {!schedulesQuery.isLoading && filteredSchedules.length === 0 ? (
+
+
+ No train schedules matched the current filters.
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
(!open ? setDetailId(null) : null)}>
+
+
+ Train schedule detail
+
+ Inspect the selected schedule, locomotive, wagons, and booking allocations.
+
+
+
+ {detail ? (
+
+
+
+
Schedule
+
{detail.id}
+
+
+
Departure
+
{formatDate(detail.scheduledDepartureDate)}
+
+
+
Route
+
+ {detail.originStation?.label ?? detail.originStation?.code ?? '-'} to{' '}
+ {detail.destinationStation?.label ?? detail.destinationStation?.code ?? '-'}
+
+
+
+
Status
+
{detail.status}
+
+
+
+
+
Locomotive
+
+ {detail.trainSet?.locomotive
+ ? `${detail.trainSet.locomotive.code} (${detail.trainSet.locomotive.maxPullWeightTons}T pull capacity)`
+ : 'No locomotive attached'}
+
+
+
+
+
Wagons and allocations
+
+ {(detail.trainSet?.wagons ?? []).map((wagon) => (
+
+
+
+
+ Wagon {wagon.sequenceNo} - {wagon.wagonType?.code ?? 'NW5'}
+
+
+ {wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m
+
+
+
+
+
+
+
+ Booking
+ Allocated weight
+
+
+
+ {wagon.allocations.map((allocation) => (
+
+ {allocation.bookingReference ?? allocation.bookingId}
+ {allocation.allocatedWeightTons} T
+
+ ))}
+
+
+
+
+ ))}
+
+
+
+
+
Bookings in schedule
+
+
+
+
+ Reference
+ Customer
+ Weight
+ Status
+
+
+
+ {detail.bookings.map((booking) => (
+
+ {booking.reference ?? booking.id}
+ {booking.customer ?? '-'}
+ {booking.weightTons} T
+ {booking.status ?? '-'}
+
+ ))}
+
+
+
+
+
+ ) : (
+ Loading schedule detail...
+ )}
+
+
+
+>>>>>>> 523d7e58422f1bde8024c2dd237092a7cf6aa190
);
};
-export default TrainsPage;
+export default function TrainsPage() {
+ const { data: trains, isLoading } = useTrains();
+ const deleteTrain = useDeleteTrain();
+ const [open, setOpen] = useState(false);
+
+ if (isLoading) return Loading trains...
;
+
+ return (
+
+
+ Trains
+
+ New Train
+ Create Train setOpen(false)} />
+
+
+
+
+ Number Name Status Capacity Actions
+
+ {trains?.map(train => (
+
+ {train.trainNumber || train.code}
+ {train.trainName || '-'}
+ {train.status}
+ {train.capacityTons} t
+
+
+ deleteTrain.mutate(train.id)}>
+
+
+ ))}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/pages/wagons/WagonsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/wagons/WagonsPage.tsx
new file mode 100644
index 000000000..b5407f022
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/pages/wagons/WagonsPage.tsx
@@ -0,0 +1,29 @@
+import { useWagons } from '@/hooks/useWagons';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Badge } from '@/components/ui/badge';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+
+export default function WagonsPage() {
+ const { data: wagons, isLoading } = useWagons();
+ if (isLoading) return Loading wagons...
;
+ return (
+
+ All Wagons
+
+
+ Number Type Train Status
+
+ {wagons?.map(w => (
+
+ {w.wagonNumber}
+ {w.wagonTypeId}
+ {w.trainId || 'Unassigned'}
+ {w.status}
+
+ ))}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts
index f0d585859..06595e5ca 100644
--- a/apps/edr-freight-web/backoffice/src/services/api.ts
+++ b/apps/edr-freight-web/backoffice/src/services/api.ts
@@ -1,3 +1,4 @@
+import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { endpoint } from "@/utils/endpoint";
import type {
CreateFileUploadFieldDto,
@@ -16,7 +17,6 @@ import {
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
import {
- ApproveRatePayload,
RuleEngineListResult,
RuleEngineRecord,
RuleEngineResourceSlug,
@@ -27,8 +27,14 @@ import {
ruleEngineService,
RuleEngineListParams,
} from "./ruleEngine/ruleEngine.service";
-import { bookingsService, BookingListFilter } from "./bookings.service";
-import type { Freight, PaginatedResponse } from "@edr/types";
+import {
+ bookingsService,
+ BookingListFilter,
+ type ApproveStepPayload,
+ type PaginatedBookings,
+ type RejectStepPayload,
+} from "./bookings.service";
+import type { BookingDetail } from "@/types/booking";
export const api = {
fileUploadSettings: {
@@ -167,15 +173,21 @@ export const api = {
list: endpoint<
{ resource: RuleEngineResourceSlug; params?: RuleEngineListParams },
RuleEngineListResult
- >("rule-engine", "list", ({ resource, params }) =>
- ruleEngineService.list(resource, params),
+ >(
+ "rule-engine",
+ "list",
+ ({ resource, params }) => ruleEngineService.list(resource, params),
+ ({ resource, params }) => QUERY_KEYS.RULE_ENGINE.list(resource, params),
),
getById: endpoint<
{ resource: RuleEngineResourceSlug; id: string },
RuleEngineRecord
- >("rule-engine", "getById", ({ resource, id }) =>
- ruleEngineService.getById(resource, id),
+ >(
+ "rule-engine",
+ "getById",
+ ({ resource, id }) => ruleEngineService.getById(resource, id),
+ ({ resource, id }) => QUERY_KEYS.RULE_ENGINE.detail(resource, id),
),
create: endpoint<
@@ -209,37 +221,33 @@ export const api = {
({ id }) => ruleEngineService.submitRate(id),
),
- approveRate: endpoint<
- { id: string; payload: ApproveRatePayload },
- RuleEngineRecord
- >("rule-engine", "approveRate", ({ id, payload }) =>
- ruleEngineService.approveRate(id, payload),
+ approveRate: endpoint<{ id: string }, RuleEngineRecord>(
+ "rule-engine",
+ "approveRate",
+ ({ id }) => ruleEngineService.approveRate(id),
),
getApprovalChain: endpoint(
"rule-engine",
"getApprovalChain",
() => ruleEngineService.getApprovalChain(),
+ () => QUERY_KEYS.RULE_ENGINE.chain,
),
},
bookings: {
- list: endpoint<
- { filter?: BookingListFilter },
- PaginatedResponse
- >("bookings", "list", ({ filter }) => bookingsService.list(filter)),
+ list: endpoint<{ filter?: BookingListFilter }, PaginatedBookings>(
+ "bookings",
+ "list",
+ ({ filter }) => bookingsService.list(filter),
+ ({ filter }) => QUERY_KEYS.BOOKINGS.list(filter),
+ ),
- getById: endpoint<{ id: string }, Freight.IBooking>(
+ getById: endpoint<{ id: string }, BookingDetail>(
"bookings",
"getById",
({ id }) => bookingsService.getById(id),
- ),
-
- updateStatus: endpoint<
- { id: string; action: string; reason?: string },
- Freight.IBooking
- >("bookings", "updateStatus", ({ id, action, reason }) =>
- bookingsService.updateStatus(id, { action, reason }),
+ ({ id }) => QUERY_KEYS.BOOKINGS.byId(id),
),
remove: endpoint<{ id: string }, void>(
@@ -247,5 +255,84 @@ export const api = {
"remove",
({ id }) => bookingsService.remove(id),
),
+
+ staffAccept: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "staffAccept",
+ ({ id }) => bookingsService.staffAccept(id),
+ ),
+
+ requestChanges: endpoint<{ id: string; note: string }, BookingDetail>(
+ "bookings",
+ "requestChanges",
+ ({ id, note }) => bookingsService.requestChanges(id, note),
+ ),
+
+ staffReject: endpoint<{ id: string; reason: string }, BookingDetail>(
+ "bookings",
+ "staffReject",
+ ({ id, reason }) => bookingsService.staffReject(id, reason),
+ ),
+
+ approveStep: endpoint(
+ "bookings",
+ "approveStep",
+ (payload) => bookingsService.approveStep(payload),
+ ),
+
+ rejectStep: endpoint(
+ "bookings",
+ "rejectStep",
+ (payload) => bookingsService.rejectStep(payload),
+ ),
+
+ generateContract: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "generateContract",
+ ({ id }) => bookingsService.generateContract(id),
+ ),
+
+ getContractView: endpoint<{ id: string }, import("./bookings.service").ContractView>(
+ "bookings",
+ "getContractView",
+ ({ id }) => bookingsService.getContractView(id),
+ ),
+
+ signContract: endpoint<
+ { id: string } & import("./bookings.service").SignContractPayload,
+ BookingDetail
+ >("bookings", "signContract", ({ id, ...payload }) =>
+ bookingsService.signContract(id, payload),
+ ),
+
+ generatePnr: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "generatePnr",
+ ({ id }) => bookingsService.generatePnr(id),
+ ),
+
+ verifyPayment: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "verifyPayment",
+ ({ id }) => bookingsService.verifyPayment(id),
+ ),
+
+ startTransit: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "startTransit",
+ ({ id }) => bookingsService.startTransit(id),
+ ),
+
+ complete: endpoint<{ id: string }, BookingDetail>(
+ "bookings",
+ "complete",
+ ({ id }) => bookingsService.complete(id),
+ ),
+
+ cancel: endpoint<{ id: string; reason: string }, BookingDetail>(
+ "bookings",
+ "cancel",
+ ({ id, reason }) => bookingsService.cancel(id, reason),
+ ),
},
};
diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts
index e823b2146..4dd64823d 100644
--- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts
@@ -1,51 +1,173 @@
-import type { Freight, PaginatedResponse } from "@edr/types";
-
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
+import type { BookingDetail } from "@/types/booking";
-const BASE = URL_CONSTANTS.BOOKINGS.BASE;
+const B = URL_CONSTANTS.BOOKINGS;
export interface BookingListFilter {
status?: string;
- customerId?: string;
- search?: string;
+ // customerId?: string;
+ companyId?: string;
+ freightType?: string;
+ tradeDirection?: string;
+ paymentCurrency?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
+export interface PaginatedBookings {
+ items: BookingDetail[];
+ total: number;
+}
+
+export interface ApproveStepPayload {
+ id: string;
+ stepId: string;
+ requiredRole: string;
+}
+
+export interface RejectStepPayload {
+ id: string;
+ stepId: string;
+ reason: string;
+}
+
+export interface ContractView {
+ bookingId: string;
+ reference: string;
+ status: string;
+ templateKey: string;
+ title: string;
+ html: string;
+ canSignCustomer: boolean;
+ canSignStaff: boolean;
+ hasContractDocument: boolean;
+ signatures: Array<{
+ role: string;
+ signerDisplayName: string;
+ signedAt: string;
+ signatureImageUrl?: string | null;
+ }>;
+}
+
+export interface SignContractPayload {
+ role: "CUSTOMER" | "STAFF";
+ signatureImageBase64: string;
+ signerDisplayName: string;
+ consentText?: string;
+}
+
+async function postBooking(url: string, body?: unknown): Promise {
+ const response = await client.post(url, body ?? {});
+ return unwrap(response.data);
+}
+
export const bookingsService = {
- list: async (
- filter?: BookingListFilter,
- ): Promise> => {
- const response = await client.get>(
- BASE,
- { params: filter },
- );
- return unwrap(response.data);
+ list: async (filter?: BookingListFilter): Promise => {
+ const response = await client.get(B.BASE, {
+ params: filter,
+ });
+ const data = unwrap(response.data);
+ return {
+ items: (data.items ?? []) as BookingDetail[],
+ total: data.total ?? 0,
+ };
},
- getById: async (id: string): Promise => {
- const response = await client.get(
- URL_CONSTANTS.BOOKINGS.BY_ID(id),
- );
- return unwrap(response.data);
- },
-
- updateStatus: async (
- id: string,
- payload: { action: string; reason?: string },
- ): Promise => {
- const response = await client.patch(
- `${URL_CONSTANTS.BOOKINGS.BY_ID(id)}/status`,
- payload,
- );
- return unwrap(response.data);
+ getById: async (id: string): Promise => {
+ const response = await client.get(B.BY_ID(id));
+ return unwrap(response.data) as BookingDetail;
},
remove: async (id: string): Promise => {
- await client.delete(URL_CONSTANTS.BOOKINGS.BY_ID(id));
+ await client.delete(B.BY_ID(id));
},
+
+ staffAccept: (id: string) => postBooking(B.STAFF_ACCEPT(id)),
+
+ requestChanges: (id: string, note: string) =>
+ postBooking(B.STAFF_REQUEST_CHANGES(id), { note }),
+
+ staffReject: (id: string, reason: string) =>
+ postBooking(B.STAFF_REJECT(id), { reason }),
+
+ approveStep: ({ id, stepId, requiredRole }: ApproveStepPayload) =>
+ postBooking(B.APPROVE_STEP(id, stepId), { requiredRole }),
+
+ rejectStep: ({ id, stepId, reason }: RejectStepPayload) =>
+ postBooking(B.REJECT_STEP(id, stepId), { reason }),
+
+ generateContract: (id: string) =>
+ postBooking(B.CONTRACT_GENERATE(id)),
+
+ getContractView: async (id: string): Promise => {
+ const response = await client.get(B.CONTRACT_VIEW(id));
+ return unwrap(response.data) as ContractView;
+ },
+
+ downloadContract: async (id: string): Promise => {
+ const response = await client.get(B.CONTRACT_DOWNLOAD(id), {
+ responseType: "blob",
+ });
+ return response.data as Blob;
+ },
+
+ downloadContractDocument: async (id: string): Promise => {
+ const response = await client.get(B.CONTRACT_DOCUMENT(id), {
+ responseType: "blob",
+ });
+ return response.data as Blob;
+ },
+
+ signContract: (id: string, payload: SignContractPayload) =>
+ postBooking(B.CONTRACT_SIGN(id), payload),
+
+ getSummary: async (id: string): Promise<{ summary: string }> => {
+ const response = await client.get<{ summary: string }>(B.SUMMARY(id));
+ return unwrap(response.data);
+ },
+
+ customerSign: (id: string, payload: SignContractPayload) =>
+ postBooking(B.CUSTOMER_SIGN(id), {
+ ...payload,
+ role: "CUSTOMER",
+ }),
+
+ marketingApprove: (id: string, payload: SignContractPayload) =>
+ postBooking(B.MARKETING_APPROVE(id), {
+ ...payload,
+ role: "STAFF",
+ }),
+
+ generatePnr: (id: string) => postBooking(B.PAYMENT_PNR(id)),
+
+ submitPaymentProof: async (id: string, file: File): Promise => {
+ const form = new FormData();
+ form.append("file", file);
+ const response = await client.post(B.PAYMENT_PROOF(id), form, {
+ headers: { "Content-Type": "multipart/form-data" },
+ });
+ return unwrap(response.data) as BookingDetail;
+ },
+
+ verifyPayment: (id: string) =>
+ postBooking(B.PAYMENT_VERIFY(id)),
+
+ downloadPaymentRequestLetter: async (id: string): Promise => {
+ const response = await client.get(B.PAYMENT_REQUEST_LETTER(id), {
+ responseType: "blob",
+ });
+ return response.data as Blob;
+ },
+
+ startTransit: (id: string) =>
+ postBooking(B.START_TRANSIT(id)),
+
+ complete: (id: string) => postBooking(B.COMPLETE(id)),
+
+ cancel: (id: string, reason: string) =>
+ postBooking(B.CANCEL(id), { reason }),
};
diff --git a/apps/edr-freight-web/backoffice/src/services/cargoService.ts b/apps/edr-freight-web/backoffice/src/services/cargoService.ts
new file mode 100644
index 000000000..79facab6e
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/services/cargoService.ts
@@ -0,0 +1,26 @@
+import { apiClient } from '@/lib/axios';
+
+export interface Cargo {
+ id: string;
+ cargoReference: string;
+ shipmentId: string;
+ containerId: string;
+ cargoTypeId?: string;
+ description?: string;
+ quantity: number;
+ weight: number;
+ volume?: number;
+ status: 'PENDING' | 'LOADED' | 'IN_TRANSIT' | 'DELIVERED' | 'UNLOADED';
+ loadedAt?: string;
+ unloadedAt?: string;
+}
+
+export const cargoService = {
+ getAll: () => apiClient.get('/cargoes'),
+ getByContainer: (containerId: string) => apiClient.get(`/cargoes?containerId=${containerId}`),
+ create: (data: any) => apiClient.post('/cargoes', data),
+ load: (cargoId: string, quantity: number, weight: number, volume?: number) =>
+ apiClient.post(`/cargoes/${cargoId}/load`, { quantity, weight, volume }),
+ deliver: (cargoId: string) => apiClient.post(`/cargoes/${cargoId}/deliver`),
+ unload: (cargoId: string) => apiClient.post(`/cargoes/${cargoId}/unload`),
+};
diff --git a/apps/edr-freight-web/backoffice/src/services/containerService.ts b/apps/edr-freight-web/backoffice/src/services/containerService.ts
new file mode 100644
index 000000000..3d0e84bb8
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/services/containerService.ts
@@ -0,0 +1,21 @@
+import { apiClient } from '@/lib/axios';
+
+export interface Container {
+ id: string;
+ containerNumber: string;
+ containerTypeId: string;
+ wagonId: string | null;
+ position: number | null;
+ tareWeight: number;
+ maxGrossWeight: number;
+ sealNumber?: string;
+ status: string;
+}
+
+export const containerService = {
+ getAll: () => apiClient.get('/containers'),
+ getByWagon: (wagonId: string) => apiClient.get(`/containers?wagonId=${wagonId}`),
+ assignToWagon: (containerId: string, wagonId: string, position?: number) =>
+ apiClient.post(`/containers/${containerId}/assign-wagon`, { wagonId, position }),
+ unassign: (containerId: string) => apiClient.post(`/containers/${containerId}/unassign-wagon`),
+};
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts
index 65d9476e8..6c5c7f47d 100644
--- a/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/ruleEngine/ruleEngine.service.ts
@@ -1,7 +1,6 @@
import { api as client } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
- ApproveRatePayload,
RuleEngineListMeta,
RuleEngineListResult,
RuleEngineRecord,
@@ -143,11 +142,8 @@ export const ruleEngineService = {
return normalizeEntity(response.data);
},
- approveRate: async (
- id: string,
- payload: ApproveRatePayload,
- ): Promise => {
- const response = await client.post(URL_CONSTANTS.RULE_ENGINE.RATE_APPROVE(id), payload);
+ approveRate: async (id: string): Promise => {
+ const response = await client.post(URL_CONSTANTS.RULE_ENGINE.RATE_APPROVE(id));
return normalizeEntity(response.data);
},
diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts
new file mode 100644
index 000000000..e696b7e8b
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts
@@ -0,0 +1,87 @@
+import { api as client } from '../auth/http';
+import { unwrap } from '@/utils/endpoint';
+import { URL_CONSTANTS } from '@/constants/URLS';
+import type {
+ CreateTrainSchedulePayload,
+ EligibleContainerBookingsResponse,
+ LocomotiveRecord,
+ TrainScheduleDetail,
+ TrainScheduleFilters,
+ TrainScheduleListItem,
+ TrainSchedulePreviewPayload,
+ TrainSchedulePreviewResponse,
+ YardOption,
+} from '@/types/trainScheduling';
+
+interface BookingReferenceDataResponse {
+ yard?: YardOption[];
+}
+
+export const trainSchedulingService = {
+ getEligibleBookings: async (
+ filters?: TrainScheduleFilters,
+ ): Promise => {
+ const response = await client.get(
+ URL_CONSTANTS.TRAIN_SCHEDULING.ELIGIBLE_BOOKINGS,
+ { params: filters },
+ );
+ return unwrap(response.data);
+ },
+
+ preview: async (
+ payload: TrainSchedulePreviewPayload,
+ ): Promise => {
+ const response = await client.post(
+ URL_CONSTANTS.TRAIN_SCHEDULING.PREVIEW,
+ payload,
+ );
+ return unwrap(response.data);
+ },
+
+ createSchedule: async (
+ payload: CreateTrainSchedulePayload,
+ ): Promise => {
+ const response = await client.post(
+ URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULES,
+ payload,
+ );
+ return unwrap(response.data);
+ },
+
+ listSchedules: async (): Promise => {
+ const response = await client.get(
+ URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULES,
+ );
+ return unwrap(response.data);
+ },
+
+ getScheduleById: async (id: string): Promise => {
+ const response = await client.get(
+ URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULE_BY_ID(id),
+ );
+ return unwrap(response.data);
+ },
+
+ cancelSchedule: async (id: string): Promise => {
+ const response = await client.post(
+ URL_CONSTANTS.TRAIN_SCHEDULING.CANCEL_SCHEDULE(id),
+ {},
+ );
+ return unwrap(response.data);
+ },
+
+ getAvailableLocomotives: async (): Promise => {
+ const response = await client.get(URL_CONSTANTS.LOCOMOTIVES.BASE, {
+ params: { status: 'AVAILABLE' },
+ });
+ return unwrap(response.data);
+ },
+
+ getStations: async (): Promise => {
+ const response = await client.get(
+ URL_CONSTANTS.BOOKINGS.REFERENCE_DATA,
+ );
+ const data = unwrap(response.data);
+ return data.yard ?? [];
+ },
+};
diff --git a/apps/edr-freight-web/backoffice/src/services/trains.service.ts b/apps/edr-freight-web/backoffice/src/services/trains.service.ts
new file mode 100644
index 000000000..57f0c5630
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/services/trains.service.ts
@@ -0,0 +1,26 @@
+import { apiClient } from '@/lib/axios';
+
+export interface Train {
+ id: string;
+ code: string;
+ capacityTons: number;
+ trainNumber?: string;
+ trainName?: string;
+ routeId?: string;
+ originStationId?: string;
+ destinationStationId?: string;
+ departureTime?: string;
+ arrivalTime?: string;
+ locomotiveNumber?: string;
+ status: string;
+ remarks?: string;
+}
+
+export const trainService = {
+ getAll: () => apiClient.get('/trains'),
+ getById: (id: string) => apiClient.get(`/trains/${id}`),
+ create: (data: Partial) => apiClient.post('/trains', data),
+ update: (id: string, data: Partial) => apiClient.patch(`/trains/${id}`, data),
+ delete: (id: string) => apiClient.delete(`/trains/${id}`),
+ getDetails: (id: string) => apiClient.get(`/trains/${id}/details`),
+};
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts
new file mode 100644
index 000000000..27ee99bbd
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts
@@ -0,0 +1,26 @@
+import { apiClient } from '@/lib/axios';
+
+export interface Wagon {
+ id: string;
+ wagonNumber: string;
+ wagonTypeId: string;
+ trainId: string | null;
+ sequenceNumber: number | null;
+ tareWeight: number;
+ maxPayloadWeight: number;
+ status: string;
+ notes?: string;
+}
+
+export const wagonService = {
+ getAll: () => apiClient.get('/wagons'),
+ getByTrain: (trainId: string) => apiClient.get(`/wagons?trainId=${trainId}`),
+ assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
+ apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }),
+ unassign: (wagonId: string) => apiClient.post(`/wagons/${wagonId}/unassign-train`),
+ reorder: (trainId: string, wagonIds: string[]) =>
+ apiClient.post(`/trains/${trainId}/reorder-wagons`, { wagonIds }),
+ create: (data: Partial) => apiClient.post('/wagons', data),
+ update: (id: string, data: Partial) => apiClient.patch(`/wagons/${id}`, data),
+
+};
\ No newline at end of file
diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts
new file mode 100644
index 000000000..9cdd4a42e
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/types/booking.ts
@@ -0,0 +1,128 @@
+/** Mirrors API BOOKING_STATUSES from edr-freight-api booking.entity */
+export const BOOKING_STATUSES = [
+ "DRAFT",
+ "SUBMITTED",
+ "CHANGES_REQUESTED",
+ "PENDING_APPROVAL",
+ "APPROVED_PENDING_SIGNATURE",
+ "APPROVED",
+ "CONTRACT_READY",
+ "SIGNED_CUSTOMER",
+ "FULLY_EXECUTED",
+ "PNR_GENERATED",
+ "PAYMENT_VERIFICATION_IN_PROGRESS",
+ "PAID",
+ "IN_TRANSIT",
+ "COMPLETED",
+ "REJECTED",
+ "CANCELLED",
+ "PENDING_CONSOLIDATION",
+ "CONSOLIDATED",
+] as const;
+
+export type BookingStatus = (typeof BOOKING_STATUSES)[number];
+
+export interface BookingNamedRef {
+ id: string;
+ name?: string;
+ code?: string;
+ label?: string;
+ companyName?: string;
+}
+
+export interface BookingContainerLine {
+ id: string;
+ containerTypeId: string;
+ quantity: number;
+ vgmPerUnitTons: number;
+ containerType?: {
+ id: string;
+ code?: string;
+ label?: string;
+ sizeFt?: number;
+ isReefer?: boolean;
+ };
+}
+
+export interface BookingApprovalStep {
+ id: string;
+ stepOrder: number;
+ requiredRole: string;
+ status: "PENDING" | "APPROVED" | "REJECTED" | "SKIPPED";
+ actionedAt?: string | null;
+ remarks?: string | null;
+}
+
+export interface BookingReviewNote {
+ id: string;
+ note: string;
+ type: string;
+ createdAt: string;
+}
+
+export interface BookingFile {
+ id: string;
+ name: string;
+ mimeType?: string;
+ code?: string;
+}
+
+export interface BookingDetail {
+ id: string;
+ reference: string;
+ // customerId: string;
+ companyId: string;
+ status: BookingStatus;
+ scheduledDate: string;
+ totalAmount: number;
+ paymentStatus: string;
+ paymentCurrency: string;
+ contractType: string;
+ freightType: "CONTAINER" | "BULK";
+ tradeDirection: string;
+ cargoTotalWeightVgm: number;
+ isHazardous: boolean;
+ allowConsolidation: boolean;
+ priorityScore: number;
+ pnrCode?: string | null;
+ firstMilePickupAddress?: string | null;
+ lastMileDeliveryAddress?: string | null;
+ equipmentReturn?: string;
+ contractSummary?: string | null;
+ latestChangeRequestNote?: string | null;
+ createdAt: string;
+ updatedAt: string;
+ // customer?: BookingNamedRef & { companyName?: string };
+ company?: BookingNamedRef;
+ originYard?: BookingNamedRef;
+ destinationYard?: BookingNamedRef;
+ serviceType?: BookingNamedRef & { code?: string };
+ cargoType?: BookingNamedRef;
+ shippingLine?: BookingNamedRef;
+ bookingContainers?: BookingContainerLine[];
+ approvalSteps?: BookingApprovalStep[];
+ reviewNotes?: BookingReviewNote[];
+ files?: BookingFile[];
+ cargoModifiers?: Array<{
+ id: string;
+ calculatedAmount: number;
+ triggerValue?: number | null;
+ }>;
+}
+
+export interface BookingListRow {
+ id: string;
+ reference: string;
+ customerLabel: string;
+ status: BookingStatus;
+ scheduledDate: string;
+ totalAmount: number;
+ paymentCurrency: string;
+ paymentStatus: string;
+ tradeDirection: string;
+ freightType: string;
+ originLabel: string;
+ destinationLabel: string;
+ priorityScore: number;
+ createdAt: string;
+}
diff --git a/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts b/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts
index 362b21a63..ceede6873 100644
--- a/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts
+++ b/apps/edr-freight-web/backoffice/src/types/rule-engine/index.ts
@@ -23,7 +23,3 @@ export interface RuleEngineListResult {
}
export type RuleEngineRecord = Record & { id: string };
-
-export interface ApproveRatePayload {
- approvedByCeoId: string;
-}
diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
new file mode 100644
index 000000000..171500a93
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
@@ -0,0 +1,154 @@
+export interface YardOption {
+ id: string;
+ name: string;
+ code: string;
+ country?: string;
+}
+
+export interface EligibleContainerBooking {
+ id: string;
+ reference: string;
+ customer: string;
+ containerType: string;
+ quantity: number;
+ weightTons: number;
+ origin: string;
+ destination: string;
+ preferredDepartureDate: string;
+ status: string;
+}
+
+export interface EligibleContainerBookingsResponse {
+ count: number;
+ items: EligibleContainerBooking[];
+}
+
+export interface WagonPlanAllocation {
+ bookingId: string;
+ bookingReference: string;
+ allocatedWeightTons: number;
+}
+
+export interface WagonPlanRow {
+ sequenceNo: number;
+ capacityTons: number;
+ lengthMeters: number;
+ assignedWeightTons: number;
+ allocations: WagonPlanAllocation[];
+}
+
+export interface TrainSchedulePreviewResponse {
+ valid: boolean;
+ violations: string[];
+ summary: {
+ totalBookings: number;
+ totalWeightTons: number;
+ wagonType: string;
+ wagonsNeeded: number;
+ totalLengthMeters: number;
+ };
+ bookingIds: string[];
+ wagonPlan: WagonPlanRow[];
+}
+
+export interface LocomotiveRecord {
+ id: string;
+ code: string;
+ name?: string | null;
+ maxPullWeightTons: number;
+ status: 'AVAILABLE' | 'ASSIGNED' | 'MAINTENANCE' | 'INACTIVE';
+ availableFrom?: string | null;
+}
+
+export interface TrainScheduleListItem {
+ id: string;
+ scheduleDate: string;
+ origin: string | null;
+ destination: string | null;
+ locomotive:
+ | {
+ id: string;
+ code: string;
+ name?: string | null;
+ }
+ | null;
+ wagonCount: number;
+ totalWeightTons: number;
+ totalLengthMeters: number;
+ bookingsCount: number;
+ status: string;
+}
+
+export interface TrainScheduleDetail {
+ id: string;
+ status: string;
+ scheduledDepartureDate: string;
+ scheduledArrivalDate?: string | null;
+ originStation?: {
+ id: string;
+ label?: string;
+ code?: string;
+ } | null;
+ destinationStation?: {
+ id: string;
+ label?: string;
+ code?: string;
+ } | null;
+ trainSet?: {
+ id: string;
+ status: string;
+ wagonCount: number;
+ totalWeightTons: number;
+ totalLengthMeters: number;
+ locomotive?: {
+ id: string;
+ code: string;
+ name?: string | null;
+ status: string;
+ maxPullWeightTons: number;
+ } | null;
+ wagons: Array<{
+ id: string;
+ sequenceNo: number;
+ capacityTons: number;
+ lengthMeters: number;
+ assignedWeightTons: number;
+ wagonType?: {
+ id: string;
+ code: string;
+ name: string;
+ } | null;
+ allocations: Array<{
+ id: string;
+ bookingId: string;
+ bookingReference: string | null;
+ allocatedWeightTons: number;
+ }>;
+ }>;
+ } | null;
+ bookings: Array<{
+ id: string;
+ reference: string | null;
+ customer: string | null;
+ weightTons: number;
+ status: string | null;
+ }>;
+}
+
+export interface TrainScheduleFilters {
+ originStationId?: string;
+ destinationStationId?: string;
+ scheduleDate?: string;
+ status?: string;
+}
+
+export interface TrainSchedulePreviewPayload {
+ bookingIds: string[];
+ scheduleDate: string;
+ originStationId: string;
+ destinationStationId: string;
+}
+
+export interface CreateTrainSchedulePayload extends TrainSchedulePreviewPayload {
+ locomotiveId: string;
+}
diff --git a/apps/edr-freight-web/backoffice/src/utils/endpoint.ts b/apps/edr-freight-web/backoffice/src/utils/endpoint.ts
index 0ed1848d7..4a4af69d6 100644
--- a/apps/edr-freight-web/backoffice/src/utils/endpoint.ts
+++ b/apps/edr-freight-web/backoffice/src/utils/endpoint.ts
@@ -44,11 +44,16 @@ export function endpoint(
service: string,
action: string,
execute: (input: TInput) => Promise,
+ queryKeyBuilder?: (input: TInput) => readonly unknown[],
) {
- const buildKey = (input?: TInput): readonly unknown[] =>
- input === undefined
+ const buildKey = (input?: TInput): readonly unknown[] => {
+ if (queryKeyBuilder && input !== undefined) {
+ return queryKeyBuilder(input as TInput);
+ }
+ return input === undefined
? [service, action]
: [service, action, input];
+ };
const call = (input: TInput) => execute(input);
@@ -116,4 +121,4 @@ export function unwrap(response: { data: T } | T): T {
}
return response as T;
-}
\ No newline at end of file
+}
diff --git a/apps/edr-freight-web/backoffice/src/utils/queryInvalidation.ts b/apps/edr-freight-web/backoffice/src/utils/queryInvalidation.ts
new file mode 100644
index 000000000..bead8a8a0
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/utils/queryInvalidation.ts
@@ -0,0 +1,56 @@
+import type { QueryClient } from "@tanstack/react-query";
+
+import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
+import type {
+ RuleEngineListResult,
+ RuleEngineRecord,
+ RuleEngineResourceSlug,
+} from "@/types/rule-engine";
+
+export function invalidateBookings(qc: QueryClient): Promise {
+ return qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT });
+}
+
+export function invalidateBookingDetail(
+ qc: QueryClient,
+ id: string,
+): Promise {
+ return Promise.all([
+ qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.byId(id) }),
+ invalidateBookings(qc),
+ ]).then(() => undefined);
+}
+
+/** Update a single row in all cached list queries for a rule-engine resource. */
+export function patchRuleEngineListRecord(
+ qc: QueryClient,
+ resource: RuleEngineResourceSlug | string,
+ updated: RuleEngineRecord,
+): void {
+ const updatedId = String(updated.id);
+ qc.setQueriesData>(
+ { queryKey: ["rule-engine", "list", resource] },
+ (old) => {
+ if (!old?.data?.length) return old;
+ const index = old.data.findIndex((row) => String(row.id) === updatedId);
+ if (index === -1) return old;
+ const data = old.data.slice();
+ data[index] = { ...data[index], ...updated };
+ return { ...old, data };
+ },
+ );
+}
+
+/** Invalidate and refetch active rule-engine list queries for a resource. */
+export async function invalidateRuleEngineList(
+ qc: QueryClient,
+ resource: RuleEngineResourceSlug | string,
+): Promise {
+ const queryKey = ["rule-engine", "list", resource] as const;
+ await qc.invalidateQueries({ queryKey });
+ await qc.refetchQueries({ queryKey, type: "active" });
+}
+
+export function invalidateRuleEngineRoot(qc: QueryClient): Promise {
+ return qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.ROOT });
+}
diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json
index 9afc896f7..3cd617e0a 100644
--- a/apps/edr-freight-web/portal/package.json
+++ b/apps/edr-freight-web/portal/package.json
@@ -25,6 +25,7 @@
"react": "19.2.6",
"react-dom": "19.2.6",
"react-hook-form": "^7.76.0",
+ "react-hot-toast": "^2.6.0",
"react-router-dom": "^6.27.0",
"recharts": "^3.8.1",
"tailwind-merge": "^3.6.0",
diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx
index 1187422f8..147668d91 100644
--- a/apps/edr-freight-web/portal/src/App.tsx
+++ b/apps/edr-freight-web/portal/src/App.tsx
@@ -14,11 +14,13 @@ import {
Home,
Loader2,
User,
+ Settings,
} from "lucide-react";
import useAuth from "./hooks/useAuth";
import ProfilePage from "./pages/ProfilePage";
+import SettingsPage from "./pages/SettingsPage";
import MyPortalPage from "./pages/MyPortalPage";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import SignupPage from "./pages/accounts/SignupPage";
@@ -27,6 +29,7 @@ import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
import LoginPage from "./pages/accounts/LoginPage";
import MyBookings from "./pages/bookings/MyBookings";
+import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import TrackingPage from "./pages/tracking/TrackingPage";
@@ -39,12 +42,14 @@ const sidebarItems: SidebarItem[] = [
{ label: "Tracking", href: "/tracking", icon: },
{ label: "Billing", href: "/billing", icon: },
{ label: "Profile", href: "/profile", icon: },
+ { label: "Settings", href: "/settings", icon: },
];
const App = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, isPending, logout, customer, customerQuery } = useAuth();
+
useEffect(() => {
if (isPending) return;
const isInProtectedRoutes = sidebarItems.find((item) =>
@@ -102,9 +107,11 @@ const App = () => {
} />
} />
} />
+ } />
} />
} />
} />
+ } />
} />
diff --git a/apps/edr-freight-web/portal/src/components/bookings/ContractSignaturePad.tsx b/apps/edr-freight-web/portal/src/components/bookings/ContractSignaturePad.tsx
new file mode 100644
index 000000000..aacf47858
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/components/bookings/ContractSignaturePad.tsx
@@ -0,0 +1,105 @@
+import { useEffect, useRef, useState } from "react";
+import { Eraser } from "lucide-react";
+
+import { Button } from "@edr/ui-common";
+import { cn } from "@/lib/utils";
+
+interface ContractSignaturePadProps {
+ onChange: (dataUrl: string | null) => void;
+ className?: string;
+}
+
+export function ContractSignaturePad({
+ onChange,
+ className,
+}: ContractSignaturePadProps) {
+ const canvasRef = useRef(null);
+ const drawing = useRef(false);
+ const [empty, setEmpty] = useState(true);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ const dpr = window.devicePixelRatio || 1;
+ const w = canvas.offsetWidth;
+ const h = canvas.offsetHeight;
+ canvas.width = w * dpr;
+ canvas.height = h * dpr;
+ ctx.scale(dpr, dpr);
+ ctx.strokeStyle = "#111";
+ ctx.lineWidth = 2;
+ ctx.lineCap = "round";
+ }, []);
+
+ const getPos = (e: React.MouseEvent | React.TouchEvent) => {
+ const canvas = canvasRef.current!;
+ const rect = canvas.getBoundingClientRect();
+ if ("touches" in e) {
+ const t = e.touches[0];
+ return { x: t.clientX - rect.left, y: t.clientY - rect.top };
+ }
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
+ };
+
+ const start = (e: React.MouseEvent | React.TouchEvent) => {
+ drawing.current = true;
+ const ctx = canvasRef.current?.getContext("2d");
+ const { x, y } = getPos(e);
+ ctx?.beginPath();
+ ctx?.moveTo(x, y);
+ };
+
+ const move = (e: React.MouseEvent | React.TouchEvent) => {
+ if (!drawing.current) return;
+ const ctx = canvasRef.current?.getContext("2d");
+ const { x, y } = getPos(e);
+ ctx?.lineTo(x, y);
+ ctx?.stroke();
+ setEmpty(false);
+ onChange(canvasRef.current?.toDataURL("image/png") ?? null);
+ };
+
+ const end = () => {
+ drawing.current = false;
+ };
+
+ const clear = () => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ setEmpty(true);
+ onChange(null);
+ };
+
+ return (
+
+
+
+
+
+
Draw your signature above
+
+
+ Clear
+
+
+ {empty && (
+
Signature is required before confirming.
+ )}
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/components/ui/badge.tsx b/apps/edr-freight-web/portal/src/components/ui/badge.tsx
new file mode 100644
index 000000000..0512e9936
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/components/ui/badge.tsx
@@ -0,0 +1,47 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Slot } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
+ outline:
+ "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 [a&]:hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Badge({
+ className,
+ variant = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "span";
+
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/apps/edr-freight-web/portal/src/components/ui/index.ts b/apps/edr-freight-web/portal/src/components/ui/index.ts
new file mode 100644
index 000000000..21dcac6e4
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/components/ui/index.ts
@@ -0,0 +1,8 @@
+export * from './table';
+export * from './badge';
+export * from './button';
+export * from './dialog';
+export * from './input';
+export * from './label';
+export * from './textarea';
+export * from './Breadcrumbs';
diff --git a/apps/edr-freight-web/portal/src/components/ui/table.tsx b/apps/edr-freight-web/portal/src/components/ui/table.tsx
new file mode 100644
index 000000000..128912911
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/components/ui/table.tsx
@@ -0,0 +1,114 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return (
+
+ );
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return (
+
+ );
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return (
+
+ );
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+ return (
+ tr]:last:border-b-0",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ );
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ );
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+};
diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts
index a9ce8fc00..b2dd0f254 100644
--- a/apps/edr-freight-web/portal/src/constants/URLS.ts
+++ b/apps/edr-freight-web/portal/src/constants/URLS.ts
@@ -82,11 +82,17 @@ export const URL_CONSTANTS = {
COMPANIES_API: {
GET_INFO: "/api/companies/getInfo",
CREATE: "/api/companies/create",
+ PROFILE: "/api/companies/profile",
+ DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
},
BOOKINGS: {
BASE: "/bookings",
BY_ID: (id: string | number) => `/bookings/${id}`,
+ CONTRACT_VIEW: (id: string) => `/bookings/${id}/contract/view`,
+ CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`,
+ CONTRACT_SIGN: (id: string) => `/bookings/${id}/contract/sign`,
+ CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
CANCEL: (id: string | number) => `/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/bookings/${id}/confirm`,
},
diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts
index 8d58d3ada..7f4c02585 100644
--- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts
+++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts
@@ -29,7 +29,6 @@ const useAuth = () => {
const authQuery = useQuery(
api.auth.getMyInfo.queryOptions({
- enabled: !!getCookie("auth-token"),
retry: false,
staleTime: 10 * 60 * 1000,
}),
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx
index 440617f3e..6cb111bb7 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx
@@ -1,4 +1,4 @@
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import {
ArrowRight,
@@ -14,6 +14,8 @@ import {
Plus,
Receipt,
Truck,
+ UploadCloud,
+ X,
} from "lucide-react";
import {
@@ -48,6 +50,7 @@ export default function MyPortalPage() {
const outstandingInvoices = myInvoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
);
+ const [dismissed, setDismissed] = useState(false);
const totalOutstanding = outstandingInvoices
.filter((inv) => inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0);
@@ -61,6 +64,34 @@ export default function MyPortalPage() {
return (
+ {/* Documents banner */}
+ {!me.documentsComplete && !dismissed && (
+
+
+
+
Upload your documents
+
+ To enable all account features, please upload your Business
+ License, TIN Certificate, and National ID / Passport.
+
+
+ Upload now
+
+
+
setDismissed(true)}
+ className="shrink-0 rounded-lg p-1 text-amber-400 transition hover:bg-amber-100 hover:text-amber-600"
+ aria-label="Dismiss"
+ >
+
+
+
+ )}
+
{/* Welcome banner */}
diff --git a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx
index e0d364bc1..9c4354723 100644
--- a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx
@@ -1,135 +1,46 @@
-import { useMemo } from "react";
-import {
- User,
- Building2,
- Phone,
- Mail,
- MapPin,
- ShieldCheck,
- Briefcase,
- UserCheck,
- Building,
- Globe,
- Fingerprint,
- FileCheck,
- Settings2,
- ExternalLink,
-} from "lucide-react";
-import useAuth from "@/hooks/useAuth";
-import {
- Card,
- CardHeader,
- CardTitle,
- CardDescription,
- CardContent,
- CardAction,
- Badge,
- Separator,
- SmartFileInput,
- Button,
-} from "@edr/ui-common";
-import type { IFileUploadSetting } from "@edr/types/freight";
-import { cn } from "@/lib/utils";
+import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react";
+import { useQuery } from "@tanstack/react-query";
+import { api } from "@/services/api";
+import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common";
+
+function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) {
+ return (
+
+ {icon &&
{icon}
}
+
+
{label}
+
{value || "—"}
+
+
+ );
+}
export default function ProfilePage() {
- const { user, customer, isPending } = useAuth();
-
- const documentSettings = useMemo
(() => ({
- id: "profile-docs",
- code: "customer_documents",
- label: "Customer Documents",
- entity: "customer",
- createdAt: new Date(),
- updatedAt: new Date(),
- fields: [
- {
- id: "doc-tin",
- settingId: "profile-docs",
- fileKey: "tin_certificate",
- fileLabel: "TIN Certificate",
- isRequired: true,
- isMultiple: false,
- maxFiles: 1,
- allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
- maxSizeMb: 5,
- order: 1,
- createdAt: new Date(),
- updatedAt: new Date(),
- },
- {
- id: "doc-license",
- settingId: "profile-docs",
- fileKey: "business_license",
- fileLabel: "Business/Investment License",
- isRequired: true,
- isMultiple: false,
- maxFiles: 1,
- allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
- maxSizeMb: 5,
- order: 2,
- createdAt: new Date(),
- updatedAt: new Date(),
- },
- {
- id: "doc-reg",
- settingId: "profile-docs",
- fileKey: "registration_certificate",
- fileLabel: "Business Registration Certificate",
- isRequired: true,
- isMultiple: false,
- maxFiles: 1,
- allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
- maxSizeMb: 5,
- order: 3,
- createdAt: new Date(),
- updatedAt: new Date(),
- },
- {
- id: "doc-id",
- settingId: "profile-docs",
- fileKey: "national_id",
- fileLabel: "National ID",
- isRequired: true,
- isMultiple: false,
- maxFiles: 1,
- allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
- maxSizeMb: 5,
- order: 4,
- createdAt: new Date(),
- updatedAt: new Date(),
- },
- {
- id: "doc-poa",
- settingId: "profile-docs",
- fileKey: "power_of_attorney",
- fileLabel: "Power of Attorney",
- isRequired: false,
- isMultiple: false,
- maxFiles: 1,
- allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
- maxSizeMb: 5,
- order: 5,
- createdAt: new Date(),
- updatedAt: new Date(),
- },
- ],
- }), []);
+ const { data: profile, isPending } = useQuery(
+ api.companies.getProfile.queryOptions(),
+ );
if (isPending) {
return (
);
}
- const displayName = user?.name?.en || user?.username || user?.email || "User";
+ if (!profile) {
+ return (
+
+
No company profile found.
+
+ );
+ }
return (
-
-
- {/* Header Section */}
-
+
+
+
+ {/* Header */}
@@ -137,7 +48,7 @@ export default function ProfilePage() {
- {displayName}
+ {profile.companyName}
Verified
@@ -145,182 +56,129 @@ export default function ProfilePage() {
- {customer?.companyName || "No Company Linked"}
+ {profile.companyName}
-
-
-
- Account Settings
-
-
-
-
+
-
- {/* Left Column - Personal & Company Info */}
-
-
- {/* Personal Details Card */}
+
+ {/* Left Column */}
+
+
+ {/* Company Details */}
+
+
+
+
+ Company Details
+
+ Business registration information
+
+
+ } label="Location" value={profile.companyLocation} />
+ } label="Address" value={profile.companyAddress} />
+ } label="TIN Number" value={profile.tinNumber} />
+ } label="FAN Number" value={profile.fanNumber} />
+ } label="Email" value={profile.companyEmail} />
+ } label="Phone" value={profile.companyPhone} />
+
+
+
+ {/* Personal Details (from ExternalProfile) */}
+
+
+
+
+ Profile Details
+
+ Your linked user profile
+
+
+ } label="Profile" value="Primary Contact" />
+
+
+
+
+ {/* Personnel Card */}
-
- Personal Details
+
+ Key Personnel
- Your account contact information
-
-
-
-
-
+ Management and contact persons
-
- } label="Email Address" value={user?.email} />
- } label="Phone Number" value={user?.phoneNumber} />
- } label="Username" value={user?.username} />
+
+
+
+ Contact Person
+
+
+
+
+
+
+
+
+ General Manager
+
+
+
+
+
+
+
- {/* Company Details Card */}
-
-
-
-
- Company Details
-
- Business registration information
-
-
- } label="Location" value={customer?.companyLocation} />
- } label="Address" value={customer?.companyAddress} />
- } label="TIN Number" value={customer?.tinNumber} />
- } label="FAN Number" value={customer?.fanNumber} />
+ {/* Power of Attorney */}
+ {profile.poaName && (
+
+
+
+
+ Power of Attorney
+
+ Authorized representative details
+
+
+
+
+
+
+
+
+ )}
+
+
+ {/* Right Column */}
+
+
+
+
+
+
+ Secure Account
+
+ Your information is protected by enterprise-grade security.
+ Contact support for verified information updates.
+
+
-
- {/* Personnel Card */}
-
-
-
-
- Key Personnel
-
- Management and contact persons
-
-
-
-
- Contact Person
-
-
-
-
-
-
-
-
- General Manager
-
-
-
-
-
-
-
-
-
-
- {/* Power of Attorney Section (Conditional) */}
- {customer?.poaName && (
-
-
-
-
- Power of Attorney
-
- Authorized representative details
-
-
-
-
-
-
-
-
- )}
-
-
- {/* Right Column - Documents */}
-
-
-
-
-
- Documents
-
- Manage required business documents
-
-
-
-
-
-
-
-
-
-
-
- Secure Account
-
- Your information is protected by enterprise-grade security.
- Contact support for verified information updates.
-
-
-
- Contact Support
-
-
-
-
);
}
-
-function InfoItem({
- icon,
- label,
- value,
-}: {
- icon?: React.ReactNode;
- label: string;
- value?: string | null;
-}) {
- return (
-
- {icon && (
-
- {icon}
-
- )}
-
-
- {label}
-
-
- {value || "—"}
-
-
-
- );
-}
diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
new file mode 100644
index 000000000..faec1b5b2
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx
@@ -0,0 +1,617 @@
+import { useState, useMemo } from "react";
+import { useSearchParams } from "react-router-dom";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import {
+ Building2,
+ User,
+ Briefcase,
+ UserCheck,
+ FileCheck,
+ Loader2,
+ Save,
+ UploadCloud,
+ CheckCircle2,
+ XCircle,
+} from "lucide-react";
+import { api } from "@/services/api";
+import { companiesService } from "@/services/companies.service";
+import PhoneInput from "@/components/auth/PhoneInput";
+import {
+ Card,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ CardFooter,
+ Button,
+ Input,
+ Field,
+ FieldLabel,
+ FieldError,
+ FieldGroup,
+ SmartFileInput,
+ Badge,
+} from "@edr/ui-common";
+import { cn } from "@/lib/utils";
+
+type SettingsTab =
+ | "company"
+ | "contact"
+ | "gm"
+ | "poa"
+ | "documents";
+
+const settingsSchema = z.object({
+ companyName: z.string().min(1, "Company name is required"),
+ companyEmail: z.string().email("Invalid email address"),
+ companyPhone: z.string().min(1, "Company phone is required"),
+ companyPhoneCountryCode: z.string().min(1, "Country code is required"),
+ companyLocation: z.string().min(1, "Location is required"),
+ companyAddress: z.string().min(1, "Address is required"),
+ tinNumber: z.string().length(10, "TIN must be exactly 10 digits"),
+ fanNumber: z.string().length(16, "FAN must be exactly 16 digits"),
+ contactPersonName: z.string().min(1, "Contact person name is required"),
+ contactPersonPhone: z.string().min(1, "Contact person phone is required"),
+ contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"),
+ generalManagerName: z.string().min(1, "GM name is required"),
+ generalManagerEmail: z.string().email("Invalid GM email"),
+ generalManagerPhone: z.string().min(1, "GM phone is required"),
+ generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"),
+ poaName: z.string().optional(),
+ poaEmail: z.string().optional(),
+ poaPhone: z.string().optional(),
+ poaPhoneCountryCode: z.string().optional(),
+ poaLocation: z.string().optional(),
+ poaAddress: z.string().optional(),
+});
+
+type FormData = z.infer
;
+
+const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
+ { id: "company", label: "Company Profile", icon: },
+ { id: "contact", label: "Contact Person", icon: },
+ { id: "gm", label: "General Manager", icon: },
+ { id: "poa", label: "Power of Attorney", icon: },
+ { id: "documents", label: "Documents", icon: },
+];
+
+function splitPhone(fullPhone?: string | null): { code: string; number: string } {
+ if (!fullPhone) return { code: "+251", number: "" };
+ const match = fullPhone.match(/^(\+\d{1,3})(.*)$/);
+ if (match) return { code: match[1], number: match[2] };
+ return { code: "+251", number: fullPhone };
+}
+
+export default function SettingsPage() {
+ const queryClient = useQueryClient();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const tab = (searchParams.get("tab") as SettingsTab) || "company";
+ const setTab = (t: SettingsTab) => {
+ setSearchParams((prev) => {
+ const next = new URLSearchParams(prev);
+ next.set("tab", t);
+ return next;
+ }, { replace: true });
+ };
+ const [documentFiles, setDocumentFiles] = useState<
+ Record
+ >({});
+
+ const profileQuery = useQuery(
+ api.companies.getProfile.queryOptions(),
+ );
+
+ const docSettingQuery = useQuery(
+ api.fileUploadSettings.getByCode.queryOptions({
+ input: { code: "customer_documents" },
+ enabled: tab === "documents",
+ }),
+ );
+
+ const profile = profileQuery.data;
+
+ const defaultValues = useMemo((): FormData => {
+ if (!profile) {
+ return {
+ companyName: "",
+ companyEmail: "",
+ companyPhone: "",
+ companyPhoneCountryCode: "+251",
+ companyLocation: "",
+ companyAddress: "",
+ tinNumber: "",
+ fanNumber: "",
+ contactPersonName: "",
+ contactPersonPhone: "",
+ contactPersonPhoneCountryCode: "+251",
+ generalManagerName: "",
+ generalManagerEmail: "",
+ generalManagerPhone: "",
+ generalManagerPhoneCountryCode: "+251",
+ poaName: "",
+ poaEmail: "",
+ poaPhone: "",
+ poaPhoneCountryCode: "+251",
+ poaLocation: "",
+ poaAddress: "",
+ };
+ }
+ const contactPhone = splitPhone(profile.contactPersonPhone);
+ const gmPhone = splitPhone(profile.generalManagerPhone);
+ const poaPhone = splitPhone(profile.poaPhone);
+ return {
+ companyName: profile.companyName,
+ companyEmail: profile.companyEmail ?? "",
+ companyPhone: profile.companyPhone ?? "",
+ companyPhoneCountryCode: splitPhone(profile.companyPhone).code,
+ companyLocation: profile.companyLocation,
+ companyAddress: profile.companyAddress ?? "",
+ tinNumber: profile.tinNumber,
+ fanNumber: profile.fanNumber ?? "",
+ contactPersonName: profile.contactPersonName ?? "",
+ contactPersonPhone: contactPhone.number,
+ contactPersonPhoneCountryCode: contactPhone.code,
+ generalManagerName: profile.generalManagerName ?? "",
+ generalManagerEmail: profile.generalManagerEmail ?? "",
+ generalManagerPhone: gmPhone.number,
+ generalManagerPhoneCountryCode: gmPhone.code,
+ poaName: profile.poaName ?? "",
+ poaEmail: profile.poaEmail ?? "",
+ poaPhone: poaPhone.number,
+ poaPhoneCountryCode: poaPhone.code,
+ poaLocation: profile.poaLocation ?? "",
+ poaAddress: profile.poaAddress ?? "",
+ };
+ }, [profile]);
+
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors, isDirty },
+ } = useForm({
+ resolver: zodResolver(settingsSchema),
+ values: defaultValues,
+ });
+
+ const updateMutation = useMutation({
+ mutationFn: (data: FormData) =>
+ api.companies.updateProfile.call({
+ companyName: data.companyName,
+ companyEmail: data.companyEmail,
+ companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
+ companyLocation: data.companyLocation,
+ companyAddress: data.companyAddress,
+ tin: data.tinNumber,
+ fanNumber: data.fanNumber,
+ contactPersonName: data.contactPersonName,
+ contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
+ generalManagerName: data.generalManagerName,
+ generalManagerEmail: data.generalManagerEmail,
+ generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
+ poaName: data.poaName || undefined,
+ poaPhone:
+ data.poaPhone && data.poaPhoneCountryCode
+ ? `${data.poaPhoneCountryCode}${data.poaPhone}`
+ : undefined,
+ poaEmail: data.poaEmail || undefined,
+ poaLocation: data.poaLocation || undefined,
+ poaAddress: data.poaAddress || undefined,
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: api.companies.getProfile.queryKey(),
+ });
+ },
+ });
+
+ const docUploadMutation = useMutation({
+ mutationFn: (files: Record) =>
+ companiesService.uploadDocuments(profile!.companyId, files),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: api.companies.getProfile.queryKey(),
+ });
+ },
+ });
+
+ const isPending = profileQuery.isPending || updateMutation.isPending || docUploadMutation.isPending;
+
+ if (profileQuery.isPending) {
+ return (
+
+ );
+ }
+
+ if (!profile) {
+ return (
+
+
No company profile found.
+
+ );
+ }
+
+ const onSubmit = (data: FormData) => {
+ updateMutation.mutate(data);
+ };
+
+ return (
+
+
+
+
+ Account Settings
+
+
+ Manage your company profile, personnel, and documents
+
+
+
+ Verified
+
+
+
+ {/* Tab Bar */}
+
+ {TABS.map((t) => (
+ setTab(t.id)}
+ className={cn(
+ "flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-semibold transition-colors",
+ tab === t.id
+ ? "border-primary text-primary"
+ : "border-transparent text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {t.icon}
+ {t.label}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
index eb8d5a343..e86c30132 100644
--- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx
@@ -14,10 +14,8 @@ import {
ChevronLeft,
UploadCloud,
} from "lucide-react";
-import type { OnboardingUserType } from "./types";
import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
-import type { FileUploadSetting } from "@/types/fileUploadSettings";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
@@ -30,7 +28,7 @@ import {
} from "@edr/ui-common";
import { api } from "@/services/api";
-type CompanyStep = "company" | "personnel" | "poa" | "documents";
+type CompanyStep = "company" | "personnel" | "poa" | "documents" | "confirm";
const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
@@ -85,6 +83,7 @@ const stepFields: Record = {
],
poa: [],
documents: [],
+ confirm: [],
};
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
@@ -116,26 +115,34 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
}
export default function CompanyProfileForm({
- userType,
+ documentSettingCode,
+ documentFiles: controlledFiles,
+ onDocumentFilesChange,
user,
onSubmit,
isPending,
onBack,
}: {
- userType: OnboardingUserType;
+ documentSettingCode: string;
+ documentFiles?: Record;
+ onDocumentFilesChange?: (
+ files: Record,
+ ) => void;
user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {
const [step, setStep] = useState("company");
- const [documentFiles, setDocumentFiles] = useState<
+ const [internalFiles, setInternalFiles] = useState<
Record
>({});
+ const documentFiles = controlledFiles ?? internalFiles;
+ const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
- const { data: uploadSettings = [], isLoading: loadingDocuments } = useQuery(
- api.fileUploadSettings.getByEntity.queryOptions({
- input: { entity: "customer" },
+ const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
+ api.fileUploadSettings.getByCode.queryOptions({
+ input: { code: documentSettingCode },
refetchOnMount: false,
}),
);
@@ -144,6 +151,7 @@ export default function CompanyProfileForm({
register,
handleSubmit,
trigger,
+ watch,
formState: { errors },
} = useForm({
resolver: zodResolver(onboardingSchema),
@@ -173,18 +181,20 @@ export default function CompanyProfileForm({
},
});
- const hasDocuments = uploadSettings.length > 0;
+ const formValues = watch();
+ const hasDocuments = Boolean(uploadSetting?.fields?.length);
+ const totalSteps = 5;
const nextStep = async () => {
if (step === "poa") {
- if (hasDocuments) {
- setStep("documents");
- } else {
- handleSubmit((data) => onSubmit(buildPayload(data, user)))();
- }
+ setStep("documents");
return;
}
if (step === "documents") {
+ setStep("confirm");
+ return;
+ }
+ if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
@@ -201,8 +211,10 @@ export default function CompanyProfileForm({
setStep("company");
} else if (step === "poa") {
setStep("personnel");
- } else {
+ } else if (step === "documents") {
setStep("poa");
+ } else {
+ setStep("documents");
}
};
@@ -228,31 +240,41 @@ export default function CompanyProfileForm({
}
active={step === "personnel"}
- completed={step === "poa"}
+ completed={
+ step === "poa" || step === "documents" || step === "confirm"
+ }
/>
}
active={step === "poa"}
- completed={hasDocuments ? step === "documents" : step === "personnel"}
+ completed={step === "documents" || step === "confirm"}
+ />
+ }
+ active={step === "documents"}
+ completed={step === "confirm"}
+ />
+ }
+ active={step === "confirm"}
+ completed={false}
/>
- {hasDocuments && (
- }
- active={step === "documents"}
- completed={false}
- />
- )}
- {step === "company" && `Step 1 of ${hasDocuments ? 4 : 3} — Company Information`}
- {step === "personnel" && `Step 2 of ${hasDocuments ? 4 : 3} — Personnel Details`}
- {step === "poa" && `Step 3 of ${hasDocuments ? 4 : 3} — Power of Attorney (Optional)`}
- {step === "documents" && "Step 4 of 4 — Upload Documents (Optional)"}
+ {step === "company" &&
+ `Step 1 of ${totalSteps} — Company Information`}
+ {step === "personnel" &&
+ `Step 2 of ${totalSteps} — Personnel Details`}
+ {step === "poa" &&
+ `Step 3 of ${totalSteps} — Power of Attorney (Optional)`}
+ {step === "documents" &&
+ `Step 4 of ${totalSteps} — Upload Documents (Optional)`}
+ {step === "confirm" && `Step 5 of ${totalSteps} — Review & Confirm`}
>
)}
+
+ {step === "documents" && (
+ <>
+ {loadingDocuments ? (
+
+
+
+ ) : !uploadSetting ? (
+
+ No document requirements found for your account type.
+
+ ) : (
+
+
+
+ )}
+ >
+ )}
+
+ {step === "confirm" && (
+
+
+
+ Review your registration
+
+
+ Confirm the company details below before saving.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
- {step === "company" ? "Change Type" : "Back"}
+ {step === "company"
+ ? "Change Type"
+ : step === "confirm"
+ ? "Back to Documents"
+ : "Back"}
-
- {isPending ? (
- <>
-
- Submitting...
- >
- ) : step === "representative" ? (
- "Complete Registration"
- ) : (
- <>
- Next Step
-
- >
+
+ {step === "documents" && (
+
+ Skip for now
+
)}
-
+
+
onSubmit(buildPayload(data, user))) : nextStep}
+ disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
+ >
+ {isPending ? (
+ <>
+
+ Submitting...
+ >
+ ) : step === "documents" ? (
+ "Continue"
+ ) : step === "confirm" ? (
+ "Submit Registration"
+ ) : (
+ <>
+ Next Step
+
+ >
+ )}
+
+
>
);
}
+function ReviewRow({ label, value }: { label: string; value?: string | null }) {
+ return (
+
+
+ {label}
+
+
+ {value?.trim() ? value : "Not provided"}
+
+
+ );
+}
+
function StepIcon({
icon,
active,
diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/CustomerOnboardingPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx
similarity index 57%
rename from apps/edr-freight-web/portal/src/pages/customers/on_boarding/CustomerOnboardingPage.tsx
rename to apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx
index 994dd751e..418b8bcc4 100644
--- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/CustomerOnboardingPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx
@@ -1,6 +1,6 @@
import { useState } from "react";
-import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
+import { useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
@@ -11,11 +11,11 @@ import {
FileText,
CheckCircle2,
Loader2,
+ ChevronLeft,
+ UploadCloud,
} from "lucide-react";
-import useAuth from "@/hooks/useAuth";
-import { api } from "@/services/api";
-import type { CreateCustomerDto } from "@/types/customers";
-import AuthLayout from "@/components/auth/AuthLayout";
+import type { AuthUser } from "@/types/auth";
+import type { CreateCompanyPayload } from "@/services/companies.service";
import PhoneInput from "@/components/auth/PhoneInput";
import {
Button,
@@ -24,14 +24,13 @@ import {
FieldLabel,
FieldError,
FieldGroup,
+ SmartFileInput,
} from "@edr/ui-common";
-import TransporterOnboarding from "./TransportrOnBoarding";
-import DjiboutiForwardingAgentForm from "./DjiboutiFreightForwardingAgent";
-import ImportExportOnBoarding from "./ImportExportOnBoarding";
+import { api } from "@/services/api";
-type OnboardingStep = "company" | "personnel" | "poa";
+type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "confirm";
-const onboardingSchema = z.object({
+const forwarderSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z.string().min(1, "Company phone is required"),
@@ -59,9 +58,9 @@ const onboardingSchema = z.object({
poaLocation: z.string().optional(),
});
-type FormData = z.infer
;
+type FormData = z.infer;
-const stepFields: Record = {
+const stepFields: Record = {
company: [
"companyName",
"companyEmail",
@@ -83,20 +82,79 @@ const stepFields: Record = {
"generalManagerPhoneCountryCode",
],
poa: [],
+ documents: [],
+ confirm: [],
};
-export default function CustomerOnboardingPage() {
- const queryClient = useQueryClient();
- const { user } = useAuth();
- const [step, setStep] = useState("company");
+function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
+ return {
+ companyName: data.companyName,
+ companyEmail: data.companyEmail,
+ companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
+ companyLocation: data.companyLocation,
+ companyAddress: data.companyAddress,
+ tin: data.tinNumber,
+ vatNumber: data.vatNumber,
+ fanNumber: data.fanNumber,
+ attributes: {
+ contactPersonName: data.contactPersonName,
+ contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
+ generalManagerName: data.generalManagerName,
+ generalManagerEmail: data.generalManagerEmail,
+ generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
+ poaName: data.poaName || undefined,
+ poaPhone:
+ data.poaPhone && data.poaPhoneCountryCode
+ ? `${data.poaPhoneCountryCode}${data.poaPhone}`
+ : undefined,
+ poaAddress: data.poaAddress || undefined,
+ poaEmail: data.poaEmail || undefined,
+ poaLocation: data.poaLocation || undefined,
+ },
+ };
+}
+
+export default function ForwarderForm({
+ documentSettingCode,
+ documentFiles: controlledFiles,
+ onDocumentFilesChange,
+ user,
+ onSubmit,
+ isPending,
+ onBack,
+}: {
+ documentSettingCode: string;
+ documentFiles?: Record;
+ onDocumentFilesChange?: (
+ files: Record,
+ ) => void;
+ user: AuthUser;
+ onSubmit: (data: CreateCompanyPayload) => void;
+ isPending: boolean;
+ onBack: () => void;
+}) {
+ const [step, setStep] = useState("company");
+ const [internalFiles, setInternalFiles] = useState<
+ Record
+ >({});
+ const documentFiles = controlledFiles ?? internalFiles;
+ const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
+
+ const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
+ api.fileUploadSettings.getByCode.queryOptions({
+ input: { code: documentSettingCode },
+ refetchOnMount: false,
+ }),
+ );
const {
register,
handleSubmit,
trigger,
+ watch,
formState: { errors },
} = useForm({
- resolver: zodResolver(onboardingSchema),
+ resolver: zodResolver(forwarderSchema),
defaultValues: {
companyName: "",
companyEmail: "",
@@ -123,20 +181,21 @@ export default function CustomerOnboardingPage() {
},
});
- const createCustomerMutation = useMutation({
- mutationFn: (payload: CreateCustomerDto) =>
- api.customers.create.call(payload),
- onSuccess: () => {
- if (user)
- queryClient.invalidateQueries({
- queryKey: api.customers.getByUserId.queryKey({ id: user.id }),
- });
- },
- });
+ const formValues = watch();
+ const hasDocuments = Boolean(uploadSetting?.fields?.length);
+ const totalSteps = 5;
const nextStep = async () => {
if (step === "poa") {
- handleSubmit(onSubmit)();
+ setStep("documents");
+ return;
+ }
+ if (step === "documents") {
+ setStep("confirm");
+ return;
+ }
+ if (step === "confirm") {
+ handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
const fields = stepFields[step];
@@ -145,69 +204,36 @@ export default function CustomerOnboardingPage() {
setStep(step === "company" ? "personnel" : "poa");
};
- const prevStep = () => {
- if (step === "personnel") setStep("company");
- else if (step === "poa") setStep("personnel");
+ const skipDocuments = () => {
+ setStep("confirm");
};
- const onSubmit = async (data: FormData) => {
- const nameParts = (user?.name?.en ?? "").split(" ");
- const payload: CreateCustomerDto = {
- userId: user!.id,
- firstName: nameParts[0] || "",
- lastName: nameParts.slice(-1)[0] || "",
- email: user!.email,
- phone: user!.phoneNumber,
- companyName: data.companyName,
- companyEmail: data.companyEmail,
- companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
- companyLocation: data.companyLocation,
- companyAddress: data.companyAddress,
- contactPersonName: data.contactPersonName,
- contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`,
- tinNumber: data.tinNumber,
- vatNumber: data.vatNumber,
- fanNumber: data.fanNumber,
- generalManagerName: data.generalManagerName,
- generalManagerEmail: data.generalManagerEmail,
- generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`,
- poaName: data.poaName || undefined,
- poaPhone:
- data.poaPhone && data.poaPhoneCountryCode
- ? `${data.poaPhoneCountryCode}${data.poaPhone}`
- : undefined,
- poaAddress: data.poaAddress || undefined,
- poaEmail: data.poaEmail || undefined,
- poaLocation: data.poaLocation || undefined,
- };
- createCustomerMutation.mutate(payload);
+ const prevStep = () => {
+ if (step === "company") {
+ onBack();
+ } else if (step === "personnel") {
+ setStep("company");
+ } else if (step === "poa") {
+ setStep("personnel");
+ } else if (step === "documents") {
+ setStep("poa");
+ } else {
+ setStep("documents");
+ }
};
return (
-
+ <>
+
+
+
+ Change account type
+
-
- {/*
*/}
- {/*
*/}
- {/*
}
active={step === "personnel"}
- completed={step === "poa"}
+ completed={step === "poa" || step === "documents" || step === "confirm"}
/>
}
active={step === "poa"}
+ completed={step === "documents" || step === "confirm"}
+ />
+
}
+ active={step === "documents"}
+ completed={step === "confirm"}
+ />
+
}
+ active={step === "confirm"}
completed={false}
/>
- {step === "company" && "Step 1 of 3 — Company Information"}
- {step === "personnel" && "Step 2 of 3 — Personnel Details"}
- {step === "poa" && "Step 3 of 3 — Power of Attorney (Optional)"}
+ {step === "company" &&
+ `Step 1 of ${totalSteps} — Company Information`}
+ {step === "personnel" &&
+ `Step 2 of ${totalSteps} — Personnel Details`}
+ {step === "poa" &&
+ `Step 3 of ${totalSteps} — Power of Attorney (Optional)`}
+ {step === "documents" &&
+ `Step 4 of ${totalSteps} — Upload Documents (Optional)`}
+ {step === "confirm" && `Step 5 of ${totalSteps} — Review & Confirm`}
-
*/}
+
- {/*