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 5114e20da..7e85ffe21 100644
--- a/apps/edr-freight-api/package.json
+++ b/apps/edr-freight-api/package.json
@@ -31,7 +31,9 @@
"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",
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-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.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.ts b/apps/edr-freight-api/src/contracts/contract-template.registry.ts
new file mode 100644
index 000000000..aacf6a0a3
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-template.registry.ts
@@ -0,0 +1,98 @@
+export interface ContractTemplateMeta {
+ key: string;
+ title: string;
+ directionLabel: string;
+ freightLabel: string;
+ currency: string;
+ serviceScope: 'TRANSPORT_ONLY' | 'FORWARDING';
+ /** Optional dedicated .hbs file; otherwise uses generic.hbs */
+ templateFile?: string;
+ whereas: string;
+ article1Objective: string;
+}
+
+const DIRECTION_LABELS: Record = {
+ IMP: 'Import',
+ EXP: 'Export',
+ DOM: 'Domestic',
+};
+
+const FREIGHT_LABELS: Record = {
+ CON: 'Container',
+ BULK: 'Bulk',
+};
+
+function buildMeta(
+ dir: string,
+ freight: string,
+ currency: string,
+ service: 'TRANSPORT_ONLY' | 'FORWARDING',
+ templateFile?: string,
+): ContractTemplateMeta {
+ const key = `${dir}_${freight}_${currency}_${service}`;
+ const dirLabel = DIRECTION_LABELS[dir] ?? dir;
+ const freightLabel = FREIGHT_LABELS[freight] ?? 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';
+
+ return {
+ key,
+ title: `${dirLabel} ${freightLabel} Transport Service by Railway (${serviceLabel})`,
+ directionLabel: dirLabel,
+ freightLabel,
+ currency,
+ serviceScope: service,
+ templateFile,
+ 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: `To provide railway transportation services for ${freightLabel.toLowerCase()} cargo on the agreed corridor (${serviceLabel}).`,
+ };
+}
+
+const DIRECTIONS = ['IMP', 'EXP', 'DOM'] as const;
+const FREIGHTS = ['CON', 'BULK'] as const;
+const CURRENCIES = ['ETB', 'USD'] as const;
+const SERVICES = ['TRANSPORT_ONLY', 'FORWARDING'] as const;
+
+/** 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 dedicated =
+ dir === 'IMP' &&
+ freight === 'CON' &&
+ currency === 'ETB' &&
+ service === 'TRANSPORT_ONLY'
+ ? 'IMP_CON_ETB_TRANSPORT_ONLY.hbs'
+ : undefined;
+ const meta = buildMeta(dir, freight, currency, service, dedicated);
+ CONTRACT_TEMPLATE_REGISTRY[meta.key] = meta;
+ }
+ }
+ }
+}
+
+export function getTemplateMeta(key: string): ContractTemplateMeta {
+ return (
+ CONTRACT_TEMPLATE_REGISTRY[key] ?? {
+ key,
+ title: 'Freight Contract Agreement',
+ directionLabel: 'Freight',
+ freightLabel: 'Cargo',
+ currency: 'USD',
+ serviceScope: 'TRANSPORT_ONLY',
+ whereas: 'The parties agree to railway freight services as described in the schedule below.',
+ article1Objective: 'To provide railway transportation services per the agreed schedule.',
+ }
+ );
+}
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-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
new file mode 100644
index 000000000..36c0328c8
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
@@ -0,0 +1,118 @@
+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 ?? '—',
+ },
+ 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/IMP_CON_ETB_TRANSPORT_ONLY.hbs b/apps/edr-freight-api/src/contracts/templates/IMP_CON_ETB_TRANSPORT_ONLY.hbs
new file mode 100644
index 000000000..90b6dba93
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/templates/IMP_CON_ETB_TRANSPORT_ONLY.hbs
@@ -0,0 +1,68 @@
+
+
+
+
+ Import Container Transport — {{reference}}
+ {{> styles}}
+
+
+
+
Contract Agreement
+
Import Container Transport Service by Railway
+
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:
+
+
+
Article 1: Objective and Scope of Services
+
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: (1) Railway transport service; (2) Cargo handling at Galaan Multipurpose port (GMP) where applicable.
+
+
+
+
Article 2: Obligations of the Client (summary)
+
+ 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.
+ Pay 100% transportation fees in advance per train set in {{paymentArticle}}.
+ Notify EDR 48 hours in advance for hazardous or valuable cargo.
+
+
+
+
+
Article 3: Obligations of the Service Provider (summary)
+
+ 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.
+
+
+
+ {{> article5_pricing}}
+
+
+
Article 4: Force Majeure
+
Neither party is liable for delays due to force majeure interpreted under the Ethiopian Civil Code.
+
+
+
+
Article 6: Contract Documents
+
+ Amendments (if any)
+ This Contract Agreement
+ Final Minutes of Negotiation (if any)
+
+
+
+ {{> signatures_block}}
+
+
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/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..23a35cb88
--- /dev/null
+++ b/apps/edr-freight-api/src/contracts/templates/generic.hbs
@@ -0,0 +1,42 @@
+
+
+
+
+ {{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) (“Service Provider”) and {{client.companyName}} (“Client”) at {{client.companyAddress}}, {{client.companyLocation}}. Phone: {{client.phone}}. Email: {{client.email}}. TIN: {{client.tinNumber}}.
+
+ Whereas
+ {{template.whereas}}
+ Now therefore, the parties agree as follows:
+
+
+
Article 1: Objective and Scope
+
{{template.article1Objective}}
+
+
+ {{> article5_pricing}}
+
+
+
Article 4: Force Majeure
+
Neither party shall be liable for delays caused by force majeure beyond reasonable control, interpreted per the Ethiopian Civil Code.
+
+
+
+
Article 6: Contract Documents
+
This agreement, amendments (if any), and negotiated minutes constitute the contract.
+
+
+ {{> signatures_block}}
+
+
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/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/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts
index 65c317eaf..4eadff0fc 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts
@@ -1,16 +1,34 @@
-import { Injectable, NotFoundException } from '@nestjs/common';
+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 {
@@ -22,7 +40,7 @@ export class BookingContractService {
: booking.tradeDirection;
const cargo = booking.cargoType;
- const isBulk = cargo?.requiresDirectorApproval;
+ const isBulk = booking.freightType === 'BULK';
let cargoLabel: string;
if (isBulk) {
@@ -36,7 +54,7 @@ export class BookingContractService {
cargoLabel =
lines.length > 0
? `Container (${lines.join(', ')})`
- : `Container (${cargo?.cargoTypeName ?? 'Standard'})`;
+ : 'Container (Standard)';
}
return `Operation: ${direction} | Cargo Type: ${cargoLabel}`;
@@ -48,25 +66,117 @@ export class BookingContractService {
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 summary = this.buildContractSummary(booking);
- const body = [
- 'FREIGHT CONTRACT (STUB)',
- `Reference: ${booking.reference}`,
- summary,
- `Total: ${booking.totalAmount} ${booking.paymentCurrency}`,
- `Trade: ${booking.tradeDirection}`,
- ].join('\n');
+ 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 buffer = Buffer.from(body, 'utf-8');
const file: Express.Multer.File = {
fieldname: 'contract',
- originalname: `contract-${booking.reference}.txt`,
+ originalname: `contract-${booking.reference}.pdf`,
encoding: '7bit',
- mimetype: 'text/plain',
+ 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),
@@ -75,27 +185,71 @@ export class BookingContractService {
path: '',
};
- await this.filesService.upload({
+ const fileRecord = await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
- code: 'contract',
- file,
+ code: role === 'CUSTOMER' ? 'signature_customer' : 'signature_staff',
+ file: sigFile,
});
- const updated = await this.bookingsRepository.update(bookingId, {
- status: 'CONTRACT_READY',
- contractSummary: summary,
- } as never);
+ 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 streamContract(bookingId: string) {
- const record = await this.filesService.findByCode(
- bookingId,
- 'bookings',
- 'contract',
- );
- return this.filesService.streamById(record.id);
+ 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 {
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..cf4561c7b
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts
@@ -0,0 +1,43 @@
+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-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
index 621fb58e9..4e47456e0 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts
@@ -1,14 +1,8 @@
-import { Inject, Injectable, NotFoundException } from '@nestjs/common';
+import { Injectable, NotFoundException } from '@nestjs/common';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
-import {
- IRatesRepository,
- RATES_REPOSITORY,
-} from '../rule-engine/interfaces/rates.repository.interface';
-import {
- IServiceTypesRepository,
- SERVICE_TYPES_REPOSITORY,
-} from '../rule-engine/interfaces/service-types.repository.interface';
+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,
@@ -26,10 +20,8 @@ export class BookingPricingService {
private readonly bookingsRepository: BookingsRepository,
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
- @Inject(RATES_REPOSITORY)
- private readonly ratesRepo: IRatesRepository,
- @Inject(SERVICE_TYPES_REPOSITORY)
- private readonly serviceTypesRepo: IServiceTypesRepository,
+ private readonly ratesService: RatesService,
+ private readonly serviceTypesService: ServiceTypesService,
) {}
async generatePrice(bookingId: string): Promise {
@@ -37,6 +29,7 @@ export class BookingPricingService {
assertBookingStatus(booking, ['DRAFT']);
const evalInput = await this.buildEvalInputForBooking(booking);
+ console.log('evalInput----', evalInput);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
this.ruleEngineService.assertNoHardBlocks(ruleResult);
@@ -65,6 +58,12 @@ export class BookingPricingService {
await this.bookingsRepository.update(bookingId, {
totalAmount: total,
priorityScore: ruleResult.priorityScore,
+ pricingBreakdown: {
+ lineItems,
+ totalAmount: total,
+ currency: booking.paymentCurrency,
+ generatedAt: new Date().toISOString(),
+ },
} as never);
return {
@@ -92,7 +91,8 @@ export class BookingPricingService {
}),
);
return {
- cargoTypeId: booking.cargoTypeId,
+ freightType: booking.freightType as 'CONTAINER' | 'BULK',
+ cargoTypeId: booking.cargoTypeId ?? null,
serviceTypeId: booking.serviceTypeId,
paymentCurrency: booking.paymentCurrency,
tradeDirection: booking.tradeDirection,
@@ -109,13 +109,71 @@ export class BookingPricingService {
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.serviceTypesRepo.findById(booking.serviceTypeId);
+ const serviceType = await this.serviceTypesService.findById(booking.serviceTypeId);
if (booking.paymentCurrency === 'USD' && serviceType) {
const code = (serviceType.code ?? '').toUpperCase();
const hasForwarding =
@@ -136,10 +194,10 @@ export class BookingPricingService {
booking: Booking,
evalInput: BookingEvaluationInput,
): Promise {
- const liveRates = await this.ratesRepo.findLiveRates();
+ const liveRates = await this.ratesService.findLiveRates();
const currency = booking.paymentCurrency;
- const isBulk = booking.cargoType?.requiresDirectorApproval ?? false;
-
+ const isBulk = booking.freightType === 'BULK';
+console.log('liveRates----', liveRates);
const rateType =
booking.tradeDirection === 'IMPORT'
? isBulk
@@ -151,11 +209,16 @@ export class BookingPricingService {
: '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);
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
index d40d6590b..ad76fa7a3 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts
@@ -42,7 +42,7 @@ export class BookingTransitionService {
async requestChanges(
bookingId: string,
note: string,
- actorId?: string,
+ actorId: string,
): Promise {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']);
@@ -60,19 +60,19 @@ export class BookingTransitionService {
return this.bookingsService.findById(updated!.id);
}
- async acceptIntake(bookingId: string, actorId?: string): Promise {
+ async acceptIntake(bookingId: string, actorId: string): Promise {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']);
- await this.ruleEngineService.instantiateApprovalSteps(
- bookingId,
- booking.cargoTypeId,
- );
+ 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 ?? booking.approvedByStaffId,
- approvedByStaffAt: actorId ? new Date() : booking.approvedByStaffAt,
+ approvedByStaffId: actorId,
+ approvedByStaffAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
}
@@ -80,7 +80,7 @@ export class BookingTransitionService {
async staffReject(
bookingId: string,
reason: string,
- actorId?: string,
+ actorId: string,
): Promise {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']);
@@ -211,17 +211,14 @@ export class BookingTransitionService {
return this.bookingsService.findById(updated!.id);
}
- async marketingApprove(
- bookingId: string,
- actorId?: string,
- ): Promise {
+ 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 ?? null,
+ marketingApprovedById: actorId,
marketingApprovedAt: new Date(),
lockedAt: new Date(),
} as never);
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 c8572dd76..03c5cfde4 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts
@@ -14,8 +14,11 @@ import {
Res,
StreamableFile,
UploadedFiles,
+ UseGuards,
UseInterceptors,
} 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,
@@ -41,12 +44,16 @@ import {
ApproveStepDto,
CancelBookingDto,
RejectStepDto,
- MarketingApproveDto,
RequestChangesDto,
- StaffAcceptDto,
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')
@@ -155,96 +162,146 @@ export class BookingsController {
}
@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,
- dto.actorId,
+ 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,
- @Body() dto: StaffAcceptDto,
+ @CurrentUser() user: AuthUserPayload,
) {
- const booking = await this.transitionService.acceptIntake(id, dto.actorId);
+ 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,
- dto.actorId,
+ 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,
- dto.actorId,
+ 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,
- dto.actorId,
+ resolveAuthUserId(user),
dto.reason,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/contract/generate')
- @ApiOperation({ summary: 'Generate contract document' })
+ @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')
- @ApiOperation({ summary: 'Download contract file' })
- async downloadContract(
+ @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/octet-stream',
+ '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) {
@@ -252,22 +309,41 @@ export class BookingsController {
}
@Post(':id/customer/sign')
- @ApiOperation({ summary: 'Customer digital signature' })
- async customerSign(@Param('id', ParseUUIDPipe) id: string) {
- const booking = await this.transitionService.customerSign(id);
+ @ApiOperation({
+ summary: 'Customer digital signature (deprecated — use POST contract/sign)',
+ })
+ 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')
- @ApiOperation({ summary: 'Marketing verify and fully execute' })
+ @UseGuards(JwtGuard)
+ @ApiOperation({
+ summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
+ })
async marketingApprove(
@Param('id', ParseUUIDPipe) id: string,
- @Body() dto: MarketingApproveDto,
+ @Body() dto: SignContractDto,
+ @CurrentUser() user: AuthUserPayload,
+ @Request() req: { ip?: string },
) {
- const booking = await this.transitionService.marketingApprove(
- id,
- dto.actorId,
- );
+ 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);
}
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 f3b3a9360..1f96176ba 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts
@@ -19,8 +19,14 @@ 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: [
@@ -31,6 +37,7 @@ import { Booking } from './entities/booking.entity';
BookingApprovalStep,
BookingRateSnapshot,
BookingReviewNote,
+ BookingContractSignature,
]),
FilesModule,
MinioModule,
@@ -47,6 +54,11 @@ import { Booking } from './entities/booking.entity';
BookingTransitionService,
BookingContractService,
BookingPaymentService,
+ ContractTemplateResolver,
+ ContractViewModelBuilder,
+ ContractPricingScheduleBuilder,
+ ContractRendererService,
+ ContractPdfService,
],
exports: [BookingsService],
})
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 3a6155f3b..04c8a2fb7 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts
@@ -10,6 +10,10 @@ 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';
@@ -353,7 +357,7 @@ export class BookingsRepository extends BaseRepository {
.where('booking.status IN (:...statuses)', { statuses });
if (options.excludeBulk) {
- qb.andWhere('cargo.requires_director_approval = false');
+ qb.andWhere("booking.freight_type = 'CONTAINER'");
}
const sortField =
@@ -380,4 +384,39 @@ export class BookingsRepository extends BaseRepository {
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 1039e60c1..f4351a1e6 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -16,10 +16,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 { CUSTOMER_EDITABLE_STATUSES } from './entities/booking.entity';
+import { CUSTOMER_EDITABLE_STATUSES, FreightType } from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
@@ -42,22 +43,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 +71,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,
};
@@ -161,12 +166,29 @@ export class BookingsService {
}
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);
@@ -185,7 +207,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 +226,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 {
@@ -249,19 +273,44 @@ export class BookingsService {
}
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,
@@ -277,6 +326,8 @@ export class BookingsService {
const updates: Record = {
...dto,
+ freightType,
+ cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
allowConsolidation,
priorityScore: ruleResult.priorityScore,
};
@@ -287,7 +338,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,
@@ -328,6 +379,7 @@ export class BookingsService {
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) {
@@ -347,6 +399,7 @@ export class BookingsService {
skip: (page - 1) * pageSize,
take: pageSize,
order: { [sortField]: sortDir },
+ relations: ['customer', 'originYard', 'destinationYard', 'serviceType'],
});
return { items, total };
}
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..30cfd9fab 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,6 +52,9 @@ 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()
@@ -107,9 +115,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 +173,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..cdf4a6b18 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 })
@@ -28,6 +33,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/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts
index 8e77b51fc..6f716388c 100644
--- 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
@@ -1,30 +1,11 @@
-import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
-import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
+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;
-
- @ApiPropertyOptional({ format: 'uuid' })
- @IsOptional()
- @IsUUID()
- actorId?: string;
-}
-
-export class StaffAcceptDto {
- @ApiPropertyOptional({ format: 'uuid' })
- @IsOptional()
- @IsUUID()
- actorId?: string;
-}
-
-export class MarketingApproveDto {
- @ApiPropertyOptional({ format: 'uuid' })
- @IsOptional()
- @IsUUID()
- actorId?: string;
}
export class StaffRejectDto {
@@ -32,28 +13,15 @@ export class StaffRejectDto {
@IsString()
@MinLength(1)
reason!: string;
-
- @ApiPropertyOptional({ format: 'uuid' })
- @IsOptional()
- @IsUUID()
- actorId?: string;
}
export class ApproveStepDto {
- @ApiProperty({ format: 'uuid' })
- @IsUUID()
- actorId!: string;
-
@ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' })
@IsString()
requiredRole!: string;
}
export class RejectStepDto {
- @ApiProperty({ format: 'uuid' })
- @IsUUID()
- actorId!: string;
-
@ApiProperty()
@IsString()
@MinLength(1)
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/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.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts
index 6a4140006..eaec84e82 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
@@ -46,6 +46,9 @@ export const PAYMENT_STATUSES = [
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',
@@ -126,8 +129,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' })
@@ -200,6 +206,15 @@ export class Booking extends BaseEntity {
@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;
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/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-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx
index 7cc6a9935..884fde00b 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,
@@ -14,6 +13,7 @@ import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@
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";
@@ -31,16 +31,6 @@ import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirec
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- retry: 1,
- refetchOnWindowFocus: false,
- staleTime: 5 * 60 * 1000,
- },
- },
-});
-
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Main menu",
@@ -188,18 +178,15 @@ const App = () => {
if (!user) {
return (
-
-
- } />
- } />
-
-
+
+ } />
+ } />
+
);
}
return (
-
-
+
} />
} />
@@ -208,6 +195,10 @@ const App = () => {
} />
} />
+ }
+ />
} />
} />
@@ -251,8 +242,7 @@ const App = () => {
} />
-
-
+
);
};
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/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/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
new file mode 100644
index 000000000..02fe21ffc
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts
@@ -0,0 +1,50 @@
+import type { BookingListFilter } from "@/services/bookings.service";
+import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
+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,
+ },
+
+ 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..174416857 100644
--- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts
+++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts
@@ -77,9 +77,33 @@ 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`,
+ 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: {
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..eaf7421f4
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts
@@ -0,0 +1,34 @@
+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.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 8430c8f1c..c98738102 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,41 +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 {
+ 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) => {
@@ -55,54 +52,90 @@ 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", {
+ includeNone,
+ }),
queryFn: () =>
ruleEngineService.list("container-types", {
page: 1,
pageSize: CONTAINER_TYPE_OPTIONS_PAGE_SIZE,
}),
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"),
});
@@ -115,9 +148,10 @@ 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"),
});
@@ -125,9 +159,9 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
const remove = useMutation({
mutationFn: (id: string) =>
api.ruleEngine.remove.call({ resource, id }),
- onSuccess: () => {
+ onSuccess: async () => {
toast.success("Deleted successfully");
- invalidate();
+ await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to delete record"),
});
@@ -137,29 +171,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/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/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/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/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/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..fffcdea44 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,172 @@
-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;
+ 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/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/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts
new file mode 100644
index 000000000..caf98f689
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/types/booking.ts
@@ -0,0 +1,126 @@
+/** 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;
+ 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 };
+ 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/utils/endpoint.ts b/apps/edr-freight-web/backoffice/src/utils/endpoint.ts
index 0ed1848d7..31748c77d 100644
--- a/apps/edr-freight-web/backoffice/src/utils/endpoint.ts
+++ b/apps/edr-freight-web/backoffice/src/utils/endpoint.ts
@@ -44,11 +44,19 @@ 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);
+ }
+ if (queryKeyBuilder && input === undefined) {
+ return queryKeyBuilder(undefined as TInput);
+ }
+ return input === undefined
? [service, action]
: [service, action, input];
+ };
const call = (input: TInput) => execute(input);
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/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx
index 1187422f8..cab3bfd1f 100644
--- a/apps/edr-freight-web/portal/src/App.tsx
+++ b/apps/edr-freight-web/portal/src/App.tsx
@@ -27,6 +27,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";
@@ -102,6 +103,7 @@ 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/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts
index 84db5e2c2..dcfd7e421 100644
--- a/apps/edr-freight-web/portal/src/constants/URLS.ts
+++ b/apps/edr-freight-web/portal/src/constants/URLS.ts
@@ -82,6 +82,10 @@ export const URL_CONSTANTS = {
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/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx
new file mode 100644
index 000000000..a8742c9b1
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx
@@ -0,0 +1,165 @@
+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 { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
+import {
+ bookingsService,
+ type SignContractPayload,
+} from "@/services/bookings.service";
+import { Button } 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, refetch } = useQuery({
+ queryKey: ["booking-contract-view", id],
+ queryFn: () => bookingsService.getContractView(id!),
+ enabled: Boolean(id),
+ });
+
+ const signMutation = useMutation({
+ mutationFn: (payload: SignContractPayload) =>
+ bookingsService.signContract(id!, payload),
+ onSuccess: () => {
+ toast.success("Contract signed successfully");
+ setSignOpen(false);
+ void refetch();
+ qc.invalidateQueries({ queryKey: ["booking", id] });
+ },
+ 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("PDF not ready yet. Contact EDR if this persists.");
+ }
+ }, [id, data?.reference]);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (isError || !data) {
+ return (
+
+
Could not load contract.
+
navigate(-1)}>
+ Go back
+
+
+ );
+ }
+
+ const bodyHtml = extractBodyHtml(data.html);
+
+ return (
+
+
+
+
navigate(`/bookings/${id}`)}>
+
+ Back to booking
+
+
+
window.print()}>
+
+ Print
+
+
+
+ PDF
+
+ {data.canSignCustomer && (
+
setSignOpen(true)}>
+
+ Sign contract
+
+ )}
+
+
+
+
+
+
+ {signOpen && (
+
+
+
Sign contract
+
+ {data.reference} — your signature will be stored securely.
+
+
+
+ Full name
+
+ setSignerName(e.target.value)}
+ />
+
+
+
+ setSignOpen(false)}>
+ Cancel
+
+
+ signMutation.mutate({
+ role: "CUSTOMER",
+ signatureImageBase64: signatureData!,
+ signerDisplayName: signerName.trim(),
+ consentText: "I agree to the terms of this contract.",
+ })
+ }
+ >
+ Confirm signature
+
+
+
+
+ )}
+
+ );
+}
+
+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/portal/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx
index 6a4f13227..f8ea6c496 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx
@@ -152,6 +152,27 @@ export default function BookingDetailPage() {
+ {(booking.status === "CONFIRMED" || booking.status === "IN_TRANSIT") && (
+
+
+
+
Contract ready
+
+ Review the agreement and apply your digital signature.
+
+
+ navigate(`/bookings/${booking.id}/contract`)}
+ >
+
+ View & sign contract
+
+
+
+ )}
+
diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts
index 6ac93a742..2dc1ba235 100644
--- a/apps/edr-freight-web/portal/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts
@@ -1,9 +1,37 @@
import type { Freight, PaginatedResponse } from "@edr/types";
+import { URL_CONSTANTS } from "@/constants/URLS";
import { client } from "../utils/api";
+const B = URL_CONSTANTS.BOOKINGS;
+
export type CreateBookingPayload = Freight.CreateBookingDto;
+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;
+}
+
export const bookingsService = {
list: async (): Promise> => {
const { data } = await client.get("/api/bookings");
@@ -24,4 +52,24 @@ export const bookingsService = {
remove: async (id: string): Promise => {
await client.delete(`/api/bookings/${id}`);
},
+
+ getContractView: async (id: string): Promise => {
+ const { data } = await client.get(B.CONTRACT_VIEW(id));
+ return data.data ?? data;
+ },
+
+ downloadContractDocument: async (id: string): Promise => {
+ const { data } = await client.get(B.CONTRACT_DOCUMENT(id), {
+ responseType: "blob",
+ });
+ return data;
+ },
+
+ signContract: async (
+ id: string,
+ payload: SignContractPayload,
+ ): Promise => {
+ const { data } = await client.post(B.CONTRACT_SIGN(id), payload);
+ return data.data ?? data;
+ },
};
diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts
index 2dce17419..85c2f1a3b 100644
--- a/packages/types/src/freight/index.ts
+++ b/packages/types/src/freight/index.ts
@@ -27,6 +27,11 @@ export enum CalculationMethod {
PERCENTAGE = 'PERCENTAGE',
}
+export enum FreightType {
+ Container = 'CONTAINER',
+ Bulk = 'BULK',
+}
+
export enum BookingStatus {
Draft = "DRAFT",
Confirmed = "CONFIRMED",
@@ -176,7 +181,7 @@ export interface IBooking extends BaseEntity {
destinationStation: string;
cargoTotalWeightVgm: number;
- freightType: "BULK" | "BREAK_BULK";
+ freightType: FreightType;
freightSubtype?: string | null;
isHazardous: boolean;
@@ -294,7 +299,8 @@ export interface CreateBookingDto {
originYardId: string;
destinationYardId: string;
tradeDirection: "IMPORT" | "EXPORT" | "DOMESTIC";
- cargoTypeId: string;
+ freightType: FreightType;
+ cargoTypeId?: string;
cargoFreeText?: string;
shippingLineId?: string;
cargoTotalWeightVgm: number;
@@ -304,6 +310,6 @@ export interface CreateBookingDto {
startDate?: string;
endDate?: string;
financialTerms?: string;
- containers: CreateBookingContainerDto[];
+ containers?: CreateBookingContainerDto[];
allowConsolidation?: boolean;
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b9b737e64..dc317173c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -92,12 +92,18 @@ importers:
dotenv:
specifier: ^17.4.2
version: 17.4.2
+ handlebars:
+ specifier: ^4.7.9
+ version: 4.7.9
minio:
specifier: 7.1.3
version: 7.1.3
pg:
specifier: ^8.13.0
version: 8.21.0
+ puppeteer:
+ specifier: ^24.2.0
+ version: 24.43.1(typescript@5.9.3)
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@@ -2192,6 +2198,11 @@ packages:
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
+ '@puppeteer/browsers@2.13.2':
+ resolution: {integrity: sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -3622,6 +3633,9 @@ packages:
'@tokenizer/token@0.3.0':
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
+ '@tootallnate/quickjs-emscripten@0.23.0':
+ resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
+
'@tria-plc/api-common@0.1.4':
resolution: {integrity: sha512-lm9esp5PDxUyggqxYXbx2CQhZKwtcAaAcGFuuUURw3u5DlzGUNwMOFkQkDLM/fbgN+33+s1RKC7UynnG9NZFww==, tarball: https://npm.pkg.github.com/download/@tria-plc/api-common/0.1.4/de160e4bb61a882efd9c877b4614028f1ffd7cb8}
peerDependencies:
@@ -3903,6 +3917,9 @@ packages:
'@types/yargs@17.0.35':
resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==}
+ '@types/yauzl@2.10.3':
+ resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
+
'@typescript-eslint/eslint-plugin@8.59.4':
resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -4658,6 +4675,10 @@ packages:
resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==}
engines: {node: '>=0.10.0'}
+ ast-types@0.13.4:
+ resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==}
+ engines: {node: '>=4'}
+
ast-types@0.16.1:
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
engines: {node: '>=4'}
@@ -4698,6 +4719,14 @@ packages:
axios@1.16.1:
resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==}
+ b4a@1.8.1:
+ resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==}
+ peerDependencies:
+ react-native-b4a: '*'
+ peerDependenciesMeta:
+ react-native-b4a:
+ optional: true
+
babel-jest@29.7.0:
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -4734,6 +4763,47 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
+ bare-events@2.9.1:
+ resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==}
+ peerDependencies:
+ bare-abort-controller: '*'
+ peerDependenciesMeta:
+ bare-abort-controller:
+ optional: true
+
+ bare-fs@4.7.2:
+ resolution: {integrity: sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==}
+ engines: {bare: '>=1.16.0'}
+ peerDependencies:
+ bare-buffer: '*'
+ peerDependenciesMeta:
+ bare-buffer:
+ optional: true
+
+ bare-os@3.9.1:
+ resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==}
+ engines: {bare: '>=1.14.0'}
+
+ bare-path@3.0.1:
+ resolution: {integrity: sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==}
+
+ bare-stream@2.13.1:
+ resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==}
+ peerDependencies:
+ bare-abort-controller: '*'
+ bare-buffer: '*'
+ bare-events: '*'
+ peerDependenciesMeta:
+ bare-abort-controller:
+ optional: true
+ bare-buffer:
+ optional: true
+ bare-events:
+ optional: true
+
+ bare-url@2.4.4:
+ resolution: {integrity: sha512-zbQJi2YQUe3SrX19TItQ8DoPj9E1i5rrdE9iHV4PhUif1GodNRSe85lavVGbmU7P4M8579EQi4akGFuhCATWaQ==}
+
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
@@ -4754,6 +4824,10 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ basic-ftp@5.3.1:
+ resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==}
+ engines: {node: '>=10.0.0'}
+
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
@@ -4957,6 +5031,11 @@ packages:
resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==}
engines: {node: '>=6.0'}
+ chromium-bidi@14.0.0:
+ resolution: {integrity: sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==}
+ peerDependencies:
+ devtools-protocol: '*'
+
ci-info@3.9.0:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
engines: {node: '>=8'}
@@ -5317,6 +5396,10 @@ packages:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
+ data-uri-to-buffer@6.0.2:
+ resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
+ engines: {node: '>= 14'}
+
data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@@ -5458,6 +5541,10 @@ packages:
resolution: {integrity: sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==}
engines: {node: '>=0.10.0'}
+ degenerator@5.0.1:
+ resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==}
+ engines: {node: '>= 14'}
+
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@@ -5487,6 +5574,9 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+ devtools-protocol@0.0.1608973:
+ resolution: {integrity: sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==}
+
dezalgo@1.0.4:
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
@@ -5720,6 +5810,11 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
+ escodegen@2.1.0:
+ resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==}
+ engines: {node: '>=6.0'}
+ hasBin: true
+
eslint-config-prettier@9.1.2:
resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==}
hasBin: true
@@ -5852,6 +5947,9 @@ packages:
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+ events-universal@1.0.1:
+ resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
+
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
@@ -5918,6 +6016,11 @@ packages:
resolution: {integrity: sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==}
engines: {node: '>=0.10.0'}
+ extract-zip@2.0.1:
+ resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
+ engines: {node: '>= 10.17.0'}
+ hasBin: true
+
falsey@0.3.2:
resolution: {integrity: sha512-lxEuefF5MBIVDmE6XeqCdM4BWk1+vYmGZtkbKZ/VFcg6uBBw6fXNEbWmxCjDdQlFc9hy450nkiWwM3VAW6G1qg==}
engines: {node: '>=0.10.0'}
@@ -5933,6 +6036,9 @@ packages:
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
engines: {node: '>=6.0.0'}
+ fast-fifo@1.3.2:
+ resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
+
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@@ -5971,6 +6077,9 @@ packages:
fb-watchman@2.0.2:
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
+ fd-slicer@1.1.0:
+ resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
+
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -6222,6 +6331,10 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
+ get-stream@5.2.0:
+ resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
+ engines: {node: '>=8'}
+
get-stream@6.0.1:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'}
@@ -6238,6 +6351,10 @@ packages:
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
engines: {node: '>= 0.4'}
+ get-uri@6.0.5:
+ resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==}
+ engines: {node: '>= 14'}
+
get-value@2.0.6:
resolution: {integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==}
engines: {node: '>=0.10.0'}
@@ -7458,6 +7575,10 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+ lru-cache@7.18.3:
+ resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
+ engines: {node: '>=12'}
+
lucide-react@0.513.0:
resolution: {integrity: sha512-CJZKq2g8Y8yN4Aq002GahSXbG2JpFv9kXwyiOAMvUBv7pxeOFHUWKB0mO7MiY4ZVFCV4aNjv2BJFq/z3DgKPQg==}
peerDependencies:
@@ -7634,6 +7755,9 @@ packages:
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
engines: {node: '>= 8'}
+ mitt@3.0.1:
+ resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
+
mixin-deep@1.3.2:
resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==}
engines: {node: '>=0.10.0'}
@@ -7731,6 +7855,10 @@ packages:
'@nestjs/common': '>=9.0.0'
'@nestjs/core': '>=9.0.0'
+ netmask@2.1.1:
+ resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==}
+ engines: {node: '>= 0.4.0'}
+
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
@@ -7952,6 +8080,14 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
+ pac-proxy-agent@7.2.0:
+ resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==}
+ engines: {node: '>= 14'}
+
+ pac-resolver@7.0.1:
+ resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==}
+ engines: {node: '>= 14'}
+
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@@ -8070,6 +8206,9 @@ packages:
resolution: {integrity: sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==}
engines: {node: '>=14.16'}
+ pend@1.2.0:
+ resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
+
perfect-freehand@1.2.3:
resolution: {integrity: sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==}
@@ -8260,6 +8399,10 @@ packages:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
+ progress@2.0.3:
+ resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
+ engines: {node: '>=0.4.0'}
+
promise-breaker@6.0.0:
resolution: {integrity: sha512-BthzO9yTPswGf7etOBiHCVuugs2N01/Q/94dIPls48z2zCmrnDptUUZzfIb+41xq0MnYZ/BzmOd6ikDR4ibNZA==}
@@ -8310,9 +8453,16 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
+ proxy-agent@6.5.0:
+ resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==}
+ engines: {node: '>= 14'}
+
proxy-compare@3.0.1:
resolution: {integrity: sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q==}
+ proxy-from-env@1.1.0:
+ resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
@@ -8320,6 +8470,9 @@ packages:
proxy-memoize@3.0.1:
resolution: {integrity: sha512-VDdG/VYtOgdGkWJx7y0o7p+zArSf2383Isci8C+BP3YXgMYDoPd3cCBjw0JdWb6YBb9sFiOPbAADDVTPJnh+9g==}
+ pump@3.0.4:
+ resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
+
punycode@1.4.1:
resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==}
@@ -8327,6 +8480,15 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ puppeteer-core@24.43.1:
+ resolution: {integrity: sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==}
+ engines: {node: '>=18'}
+
+ puppeteer@24.43.1:
+ resolution: {integrity: sha512-/FSOViCrqRdb1HDocpsM9Z1giA71gTQPUt3SpHGVRALKAy/rJr1fLFYZW9F23qPxqVxTHQnbh/5B5opJST3kAw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
@@ -8977,6 +9139,10 @@ packages:
resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==}
engines: {node: '>=18'}
+ smart-buffer@4.2.0:
+ resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
+ engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
+
smob@1.6.2:
resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==}
engines: {node: '>=20.0.0'}
@@ -9001,6 +9167,14 @@ packages:
resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==}
engines: {node: '>=10.0.0'}
+ socks-proxy-agent@8.0.5:
+ resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==}
+ engines: {node: '>= 14'}
+
+ socks@2.8.9:
+ resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
+ engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
+
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
@@ -9098,6 +9272,9 @@ packages:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
+ streamx@2.26.0:
+ resolution: {integrity: sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A==}
+
strict-event-emitter@0.5.1:
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
@@ -9284,15 +9461,24 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
+ tar-fs@3.1.2:
+ resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==}
+
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
+ tar-stream@3.2.0:
+ resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==}
+
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+ teex@1.0.1:
+ resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
+
terser-webpack-plugin@5.6.0:
resolution: {integrity: sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA==}
engines: {node: '>= 10.13.0'}
@@ -9351,6 +9537,9 @@ packages:
resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==}
engines: {node: '>=8'}
+ text-decoder@1.2.7:
+ resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
+
text-extensions@2.4.0:
resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==}
engines: {node: '>=8'}
@@ -9629,6 +9818,9 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
+ typed-query-selector@2.12.2:
+ resolution: {integrity: sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==}
+
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
@@ -9988,6 +10180,9 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
+ webdriver-bidi-protocol@0.4.1:
+ resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==}
+
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -10197,6 +10392,9 @@ packages:
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
+ yauzl@2.10.0:
+ resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
+
year@0.2.1:
resolution: {integrity: sha512-9GnJUZ0QM4OgXuOzsKNzTJ5EOkums1Xc+3YQXp+Q+UxFjf7zLucp9dQ8QMIft0Szs1E1hUiXFim1OYfEKFq97w==}
engines: {node: '>=0.8'}
@@ -11965,6 +12163,21 @@ snapshots:
'@popperjs/core@2.11.8': {}
+ '@puppeteer/browsers@2.13.2':
+ dependencies:
+ debug: 4.4.3
+ extract-zip: 2.0.1
+ progress: 2.0.3
+ proxy-agent: 6.5.0
+ semver: 7.8.1
+ tar-fs: 3.1.2
+ yargs: 17.7.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - react-native-b4a
+ - supports-color
+
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@@ -13476,6 +13689,8 @@ snapshots:
'@tokenizer/token@0.3.0': {}
+ '@tootallnate/quickjs-emscripten@0.23.0': {}
+
'@tria-plc/api-common@0.1.4(kw56ayyn7pbkaoqn2kt3fjycd4)':
dependencies:
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.16.1)(rxjs@7.8.2)
@@ -13925,6 +14140,11 @@ snapshots:
dependencies:
'@types/yargs-parser': 21.0.3
+ '@types/yauzl@2.10.3':
+ dependencies:
+ '@types/node': 20.19.41
+ optional: true
+
'@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -15142,6 +15362,10 @@ snapshots:
assign-symbols@1.0.0: {}
+ ast-types@0.13.4:
+ dependencies:
+ tslib: 2.8.1
+
ast-types@0.16.1:
dependencies:
tslib: 2.8.1
@@ -15183,6 +15407,8 @@ snapshots:
- debug
- supports-color
+ b4a@1.8.1: {}
+
babel-jest@29.7.0(@babel/core@7.29.0):
dependencies:
'@babel/core': 7.29.0
@@ -15248,6 +15474,38 @@ snapshots:
balanced-match@4.0.4: {}
+ bare-events@2.9.1: {}
+
+ bare-fs@4.7.2:
+ dependencies:
+ bare-events: 2.9.1
+ bare-path: 3.0.1
+ bare-stream: 2.13.1(bare-events@2.9.1)
+ bare-url: 2.4.4
+ fast-fifo: 1.3.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+
+ bare-os@3.9.1: {}
+
+ bare-path@3.0.1:
+ dependencies:
+ bare-os: 3.9.1
+
+ bare-stream@2.13.1(bare-events@2.9.1):
+ dependencies:
+ streamx: 2.26.0
+ teex: 1.0.1
+ optionalDependencies:
+ bare-events: 2.9.1
+ transitivePeerDependencies:
+ - react-native-b4a
+
+ bare-url@2.4.4:
+ dependencies:
+ bare-path: 3.0.1
+
base64-arraybuffer@1.0.2: {}
base64-js@0.0.8: {}
@@ -15266,6 +15524,8 @@ snapshots:
baseline-browser-mapping@2.10.32: {}
+ basic-ftp@5.3.1: {}
+
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
@@ -15513,6 +15773,12 @@ snapshots:
chrome-trace-event@1.0.4: {}
+ chromium-bidi@14.0.0(devtools-protocol@0.0.1608973):
+ dependencies:
+ devtools-protocol: 0.0.1608973
+ mitt: 3.0.1
+ zod: 3.25.76
+
ci-info@3.9.0: {}
cjs-module-lexer@1.4.3: {}
@@ -15861,6 +16127,8 @@ snapshots:
data-uri-to-buffer@4.0.1: {}
+ data-uri-to-buffer@6.0.2: {}
+
data-urls@5.0.0:
dependencies:
whatwg-mimetype: 4.0.0
@@ -15979,6 +16247,12 @@ snapshots:
is-descriptor: 1.0.4
isobject: 3.0.1
+ degenerator@5.0.1:
+ dependencies:
+ ast-types: 0.13.4
+ escodegen: 2.1.0
+ esprima: 4.0.1
+
delayed-stream@1.0.0: {}
delegates@1.0.0:
@@ -15996,6 +16270,8 @@ snapshots:
detect-node-es@1.1.0: {}
+ devtools-protocol@0.0.1608973: {}
+
dezalgo@1.0.4:
dependencies:
asap: 2.0.6
@@ -16304,6 +16580,14 @@ snapshots:
escape-string-regexp@4.0.0: {}
+ escodegen@2.1.0:
+ dependencies:
+ esprima: 4.0.1
+ estraverse: 5.3.0
+ esutils: 2.0.3
+ optionalDependencies:
+ source-map: 0.6.1
+
eslint-config-prettier@9.1.2(eslint@8.57.1):
dependencies:
eslint: 8.57.1
@@ -16480,6 +16764,12 @@ snapshots:
eventemitter3@5.0.4: {}
+ events-universal@1.0.1:
+ dependencies:
+ bare-events: 2.9.1
+ transitivePeerDependencies:
+ - bare-abort-controller
+
events@3.3.0: {}
eventsource-parser@3.0.8: {}
@@ -16623,6 +16913,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ extract-zip@2.0.1:
+ dependencies:
+ debug: 4.4.3
+ get-stream: 5.2.0
+ yauzl: 2.10.0
+ optionalDependencies:
+ '@types/yauzl': 2.10.3
+ transitivePeerDependencies:
+ - supports-color
+
falsey@0.3.2:
dependencies:
kind-of: 5.1.0
@@ -16636,6 +16936,8 @@ snapshots:
fast-equals@5.4.0: {}
+ fast-fifo@1.3.2: {}
+
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -16680,6 +16982,10 @@ snapshots:
dependencies:
bser: 2.1.1
+ fd-slicer@1.1.0:
+ dependencies:
+ pend: 1.2.0
+
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -16956,6 +17262,10 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
+ get-stream@5.2.0:
+ dependencies:
+ pump: 3.0.4
+
get-stream@6.0.1: {}
get-stream@8.0.1: {}
@@ -16971,6 +17281,14 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
+ get-uri@6.0.5:
+ dependencies:
+ basic-ftp: 5.3.1
+ data-uri-to-buffer: 6.0.2
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
get-value@2.0.6: {}
git-raw-commits@4.0.0:
@@ -18364,6 +18682,8 @@ snapshots:
dependencies:
yallist: 3.1.1
+ lru-cache@7.18.3: {}
+
lucide-react@0.513.0(react@19.2.6):
dependencies:
react: 19.2.6
@@ -18530,6 +18850,8 @@ snapshots:
yallist: 4.0.0
optional: true
+ mitt@3.0.1: {}
+
mixin-deep@1.3.2:
dependencies:
for-in: 1.0.2
@@ -18644,6 +18966,8 @@ snapshots:
reflect-metadata: 0.1.14
rxjs: 7.8.2
+ netmask@2.1.1: {}
+
next-themes@0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
react: 19.2.6
@@ -18892,6 +19216,24 @@ snapshots:
p-try@2.2.0: {}
+ pac-proxy-agent@7.2.0:
+ dependencies:
+ '@tootallnate/quickjs-emscripten': 0.23.0
+ agent-base: 7.1.4
+ debug: 4.4.3
+ get-uri: 6.0.5
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ pac-resolver: 7.0.1
+ socks-proxy-agent: 8.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ pac-resolver@7.0.1:
+ dependencies:
+ degenerator: 5.0.1
+ netmask: 2.1.1
+
package-json-from-dist@1.0.1: {}
pako@0.2.9: {}
@@ -18990,6 +19332,8 @@ snapshots:
peek-readable@5.4.2: {}
+ pend@1.2.0: {}
+
perfect-freehand@1.2.3: {}
performance-now@2.1.0:
@@ -19135,6 +19479,8 @@ snapshots:
process@0.11.10: {}
+ progress@2.0.3: {}
+
promise-breaker@6.0.0: {}
prompts@2.4.2:
@@ -19222,18 +19568,72 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
+ proxy-agent@6.5.0:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ lru-cache: 7.18.3
+ pac-proxy-agent: 7.2.0
+ proxy-from-env: 1.1.0
+ socks-proxy-agent: 8.0.5
+ transitivePeerDependencies:
+ - supports-color
+
proxy-compare@3.0.1: {}
+ proxy-from-env@1.1.0: {}
+
proxy-from-env@2.1.0: {}
proxy-memoize@3.0.1:
dependencies:
proxy-compare: 3.0.1
+ pump@3.0.4:
+ dependencies:
+ end-of-stream: 1.4.5
+ once: 1.4.0
+
punycode@1.4.1: {}
punycode@2.3.1: {}
+ puppeteer-core@24.43.1:
+ dependencies:
+ '@puppeteer/browsers': 2.13.2
+ chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
+ debug: 4.4.3
+ devtools-protocol: 0.0.1608973
+ typed-query-selector: 2.12.2
+ webdriver-bidi-protocol: 0.4.1
+ ws: 8.20.1
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - bufferutil
+ - react-native-b4a
+ - supports-color
+ - utf-8-validate
+
+ puppeteer@24.43.1(typescript@5.9.3):
+ dependencies:
+ '@puppeteer/browsers': 2.13.2
+ chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
+ cosmiconfig: 9.0.1(typescript@5.9.3)
+ devtools-protocol: 0.0.1608973
+ puppeteer-core: 24.43.1
+ typed-query-selector: 2.12.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - bufferutil
+ - react-native-b4a
+ - supports-color
+ - typescript
+ - utf-8-validate
+
pure-rand@6.1.0: {}
qrcode@1.5.4:
@@ -20067,6 +20467,8 @@ snapshots:
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.1.0
+ smart-buffer@4.2.0: {}
+
smob@1.6.2: {}
snapdragon-node@2.1.1:
@@ -20110,6 +20512,19 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ socks-proxy-agent@8.0.5:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ socks: 2.8.9
+ transitivePeerDependencies:
+ - supports-color
+
+ socks@2.8.9:
+ dependencies:
+ ip-address: 10.2.0
+ smart-buffer: 4.2.0
+
sonner@2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
react: 19.2.6
@@ -20188,6 +20603,15 @@ snapshots:
streamsearch@1.1.0: {}
+ streamx@2.26.0:
+ dependencies:
+ events-universal: 1.0.1
+ fast-fifo: 1.3.2
+ text-decoder: 1.2.7
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+
strict-event-emitter@0.5.1: {}
strict-uri-encode@2.0.0: {}
@@ -20415,6 +20839,18 @@ snapshots:
tapable@2.3.3: {}
+ tar-fs@3.1.2:
+ dependencies:
+ pump: 3.0.4
+ tar-stream: 3.2.0
+ optionalDependencies:
+ bare-fs: 4.7.2
+ bare-path: 3.0.1
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - react-native-b4a
+
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
@@ -20423,6 +20859,17 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
+ tar-stream@3.2.0:
+ dependencies:
+ b4a: 1.8.1
+ bare-fs: 4.7.2
+ fast-fifo: 1.3.2
+ streamx: 2.26.0
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - react-native-b4a
+
tar@6.2.1:
dependencies:
chownr: 2.0.0
@@ -20433,6 +20880,13 @@ snapshots:
yallist: 4.0.0
optional: true
+ teex@1.0.1:
+ dependencies:
+ streamx: 2.26.0
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+
terser-webpack-plugin@5.6.0(webpack@5.106.0):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@@ -20470,6 +20924,12 @@ snapshots:
glob: 7.2.3
minimatch: 3.1.5
+ text-decoder@1.2.7:
+ dependencies:
+ b4a: 1.8.1
+ transitivePeerDependencies:
+ - react-native-b4a
+
text-extensions@2.4.0: {}
text-segmentation@1.0.3:
@@ -20759,6 +21219,8 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
+ typed-query-selector@2.12.2: {}
+
typedarray@0.0.6: {}
typeof-article@0.1.1:
@@ -21093,6 +21555,8 @@ snapshots:
web-streams-polyfill@3.3.3: {}
+ webdriver-bidi-protocol@0.4.1: {}
+
webidl-conversions@3.0.1: {}
webidl-conversions@7.0.0: {}
@@ -21347,6 +21811,11 @@ snapshots:
y18n: 5.0.8
yargs-parser: 22.0.0
+ yauzl@2.10.0:
+ dependencies:
+ buffer-crc32: 0.2.13
+ fd-slicer: 1.1.0
+
year@0.2.1: {}
yn@3.1.1: {}