implement booking flow

This commit is contained in:
marshal
2026-06-04 15:16:27 +03:00
parent a5578ce714
commit 125ee18308
89 changed files with 6190 additions and 1724 deletions

View File

@@ -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
}
}

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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<Buffer> {
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');
}
}

View File

@@ -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<PricingSchedule> {
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),
})),
};
}
}

View File

@@ -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<string, Handlebars.TemplateDelegate>();
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;
}
}

View File

@@ -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<string, string> = {
IMP: 'Import',
EXP: 'Export',
DOM: 'Domestic',
};
const FREIGHT_LABELS: Record<string, string> = {
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 AbabaDjibouti 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<string, ContractTemplateMeta> =
{};
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.',
}
);
}

View File

@@ -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';
}
}

View File

@@ -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<ContractSignatureView[]> {
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,
};
}
}

View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Import Container Transport — {{reference}}</title>
{{> styles}}
</head>
<body>
<div class="cover">
<h1>Contract Agreement</h1>
<h1>Import Container Transport Service by Railway</h1>
<p class="meta"><strong>Contract Ref No:</strong> {{reference}}</p>
<p class="meta"><strong>Year:</strong> {{contractYear}}</p>
</div>
<p>This Contract Agreement is made on <strong>{{contractDate}}</strong>.</p>
<p><strong>Between</strong> Ethio-Djibouti Standard Gauge Railway Share Company (EDR), Addis Ababa (“Service Provider”), and <strong>{{client.companyName}}</strong> at {{client.companyAddress}}, {{client.companyLocation}} (“Client”). Phone {{client.phone}} / {{client.email}}. TIN {{client.tinNumber}}.</p>
<h2>Whereas</h2>
<p>{{template.whereas}}</p>
<p>Now therefore, the parties agree as follows:</p>
<div class="article">
<h2>Article 1: Objective and Scope of Services</h2>
<p><strong>Objective:</strong> 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.</p>
<p><strong>Scope:</strong> (1) Railway transport service; (2) Cargo handling at Galaan Multipurpose port (GMP) where applicable.</p>
</div>
<div class="article">
<h2>Article 2: Obligations of the Client (summary)</h2>
<ol>
<li>Provide shipment instructions to EDR for container movements on the agreed corridor.</li>
<li>Meet minimum container supply per terminal (Modjo, Dire Dawa, GMP) as per EDR operational rules.</li>
<li>Submit required documents to Djibouti Nagad station at least 24 hours before loading.</li>
<li>Pay 100% transportation fees in advance per train set in {{paymentArticle}}.</li>
<li>Notify EDR 48 hours in advance for hazardous or valuable cargo.</li>
</ol>
</div>
<div class="article">
<h2>Article 3: Obligations of the Service Provider (summary)</h2>
<ol>
<li>Assign voyage per operational schedule and notify train schedule 48 hours in advance.</li>
<li>Provide safe transportation and deliver within agreed timelines when documents are complete.</li>
<li>Return empty containers from Dire Dawa, Modjo and GMP to SGTD within seven (7) calendar days of receipt.</li>
<li>Maintain cargo liability insurance per wagon.</li>
</ol>
</div>
{{> article5_pricing}}
<div class="article">
<h2>Article 4: Force Majeure</h2>
<p>Neither party is liable for delays due to force majeure interpreted under the Ethiopian Civil Code.</p>
</div>
<div class="article">
<h2>Article 6: Contract Documents</h2>
<ol>
<li>Amendments (if any)</li>
<li>This Contract Agreement</li>
<li>Final Minutes of Negotiation (if any)</li>
</ol>
</div>
{{> signatures_block}}
</body>
</html>

View File

@@ -0,0 +1,47 @@
<h2>Article 5: Contract Price and Terms of Payment</h2>
<div class="article">
<h3>Contract Price</h3>
<p><strong>Corridor:</strong> {{pricing.originLabel}}{{pricing.destinationLabel}}</p>
{{#if pricing.equipmentReturn}}
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
{{/if}}
{{#if pricing.containerLines.length}}
<table class="schedule">
<thead>
<tr><th>Container type</th><th>Quantity</th><th>VGM / unit (t)</th></tr>
</thead>
<tbody>
{{#each pricing.containerLines}}
<tr><td>{{label}}</td><td>{{quantity}}</td><td>{{vgmPerUnitTons}}</td></tr>
{{/each}}
</tbody>
</table>
{{/if}}
<table class="schedule">
<thead>
<tr><th>Item</th><th>Description</th><th>Amount</th></tr>
</thead>
<tbody>
{{#each pricing.lineItems}}
<tr>
<td>{{label}}</td>
<td>{{description}}</td>
<td>{{currency}} {{amount}}</td>
</tr>
{{/each}}
{{#each pricing.surcharges}}
<tr>
<td>{{label}}</td>
<td>{{description}}</td>
<td>{{currency}} {{amount}}</td>
</tr>
{{/each}}
<tr>
<td colspan="2"><strong>Total contract value</strong></td>
<td><strong>{{pricing.currency}} {{pricing.totalAmount}}</strong></td>
</tr>
</tbody>
</table>
<h3>Terms of payment</h3>
<p>All payments shall be made in accordance with EDR policy in <strong>{{paymentArticle}}</strong>, unless otherwise agreed in writing.</p>
</div>

View File

@@ -0,0 +1,30 @@
<div class="signatures">
<div class="sig-block">
<p><strong>Service Provider (EDR)</strong></p>
{{#if hasStaffSignature}}
{{#each signatures}}
{{#if (eq role "STAFF")}}
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Staff signature" />{{/if}}
<p>{{signerDisplayName}}</p>
<p class="sig-line">Signed: {{signedAt}}</p>
{{/if}}
{{/each}}
{{else}}
<p class="sig-line">Authorized representative (pending)</p>
{{/if}}
</div>
<div class="sig-block">
<p><strong>Client — {{client.companyName}}</strong></p>
{{#if hasCustomerSignature}}
{{#each signatures}}
{{#if (eq role "CUSTOMER")}}
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Customer signature" />{{/if}}
<p>{{signerDisplayName}}</p>
<p class="sig-line">Signed: {{signedAt}}</p>
{{/if}}
{{/each}}
{{else}}
<p class="sig-line">Client representative (pending)</p>
{{/if}}
</div>
</div>

View File

@@ -0,0 +1,22 @@
<style>
* { box-sizing: border-box; }
body { font-family: 'Times New Roman', Times, serif; font-size: 11pt; line-height: 1.45; color: #111; margin: 0; padding: 24px; }
h1 { text-align: center; font-size: 14pt; text-transform: uppercase; margin: 0 0 8px; }
h2 { font-size: 12pt; margin: 20px 0 8px; text-transform: uppercase; }
h3 { font-size: 11pt; margin: 14px 0 6px; }
.cover { text-align: center; margin-bottom: 32px; border-bottom: 2px solid #1e3a5f; padding-bottom: 24px; }
.meta { margin: 12px 0; }
.meta strong { display: inline-block; min-width: 140px; }
.article { margin-bottom: 16px; }
.article ol { padding-left: 20px; }
.article li { margin-bottom: 6px; }
table.schedule { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 10pt; }
table.schedule th, table.schedule td { border: 1px solid #333; padding: 6px 8px; text-align: left; }
table.schedule th { background: #f0f4f8; }
.signatures { display: flex; gap: 40px; margin-top: 40px; page-break-inside: avoid; }
.sig-block { flex: 1; }
.sig-block img { max-height: 64px; max-width: 200px; display: block; margin: 8px 0; }
.sig-line { border-top: 1px solid #000; margin-top: 48px; padding-top: 4px; font-size: 10pt; }
.pending-banner { background: #fff8e6; border: 1px solid #e6c200; padding: 8px 12px; margin-bottom: 16px; font-size: 10pt; }
@media print { body { padding: 0; } }
</style>

View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{{template.title}}{{reference}}</title>
{{> styles}}
</head>
<body>
<div class="cover">
<h1>Contract Agreement</h1>
<h1>{{template.title}}</h1>
<p class="meta"><strong>Contract Ref No:</strong> {{reference}}</p>
<p class="meta"><strong>Year:</strong> {{contractYear}}</p>
</div>
<p>This Contract Agreement is made on <strong>{{contractDate}}</strong>.</p>
<p><strong>Between</strong> Ethio-Djibouti Standard Gauge Railway Share Company (EDR) (“Service Provider”) and <strong>{{client.companyName}}</strong> (“Client”) at {{client.companyAddress}}, {{client.companyLocation}}. Phone: {{client.phone}}. Email: {{client.email}}. TIN: {{client.tinNumber}}.</p>
<h2>Whereas</h2>
<p>{{template.whereas}}</p>
<p>Now therefore, the parties agree as follows:</p>
<div class="article">
<h2>Article 1: Objective and Scope</h2>
<p>{{template.article1Objective}}</p>
</div>
{{> article5_pricing}}
<div class="article">
<h2>Article 4: Force Majeure</h2>
<p>Neither party shall be liable for delays caused by force majeure beyond reasonable control, interpreted per the Ethiopian Civil Code.</p>
</div>
<div class="article">
<h2>Article 6: Contract Documents</h2>
<p>This agreement, amendments (if any), and negotiated minutes constitute the contract.</p>
</div>
{{> signatures_block}}
</body>
</html>

View File

@@ -0,0 +1,71 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBookingFreightType1749300000000 implements MigrationInterface {
name = 'AddBookingFreightType1749300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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;
`);
}
}

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddContractSignatures1749400000000 implements MigrationInterface {
name = 'AddContractSignatures1749400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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;
`);
}
}

View File

@@ -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<ContractViewDto> {
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<string, unknown>,
};
}
async generateContract(bookingId: string): Promise<Booking> {
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<Booking> {
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<string, unknown> = {};
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<void> {
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<Booking> {

View File

@@ -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');
}
}
}

View File

@@ -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<GeneratePriceResponseDto> {
@@ -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<number> {
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<PriceLineItemDto[]> {
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);

View File

@@ -42,7 +42,7 @@ export class BookingTransitionService {
async requestChanges(
bookingId: string,
note: string,
actorId?: string,
actorId: string,
): Promise<Booking> {
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<Booking> {
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
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<Booking> {
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<Booking> {
async marketingApprove(bookingId: string, actorId: string): Promise<Booking> {
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);

View File

@@ -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);
}

View File

@@ -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],
})

View File

@@ -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<Booking> {
.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<Booking> {
order: options.order,
});
}
findContractSignatures(bookingId: string): Promise<BookingContractSignature[]> {
return this.dataSource.getRepository(BookingContractSignature).find({
where: { bookingId },
relations: ['signatureFile'],
order: { signedAt: 'ASC' },
});
}
findContractSignature(
bookingId: string,
role: ContractSignerRole,
): Promise<BookingContractSignature | null> {
return this.dataSource.getRepository(BookingContractSignature).findOne({
where: { bookingId, signerRole: role },
relations: ['signatureFile'],
});
}
async saveContractSignature(
data: Partial<BookingContractSignature>,
): Promise<BookingContractSignature> {
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));
}
}

View File

@@ -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<BookingEvaluationInput> {
/** 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<BookingEvaluationInput> {
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<string, unknown> = {
...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 };
}

View File

@@ -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<string, unknown>;
}

View File

@@ -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()

View File

@@ -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])

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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';
}
}

View File

@@ -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;
}

View File

@@ -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<string, unknown> | null;
@Column({ name: 'locked_at', type: 'timestamptz', nullable: true })
lockedAt?: Date | null;

View File

@@ -25,4 +25,12 @@ export class FilesRepository extends BaseRepository<FileRecord> {
): Promise<FileRecord | null> {
return this.repository.findOne({ where: { resourceId, resource, code } });
}
async deleteByCode(
resourceId: string,
resource: string,
code: string,
): Promise<void> {
await this.repository.delete({ resourceId, resource, code });
}
}

View File

@@ -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<FileRecord> {
const { resourceId, resource, code } = input;
await this.filesRepository.deleteByCode(resourceId, resource, code);
return this.upload(input);
}
async uploadMany(
resourceId: string,
resource: string,

View File

@@ -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')

View File

@@ -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()

View File

@@ -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<BookingApprovalStep[]> {
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<BookingApprovalStep[]> {
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);

View File

@@ -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<Rate> {
async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
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<Rate> {
async approve(id: string, approverUserId: string): Promise<Rate> {
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!;

View File

@@ -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,

View File

@@ -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 (
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/auth" element={<LoginPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
</Routes>
</QueryClientProvider>
<Routes>
<Route path="/auth" element={<LoginPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
</Routes>
);
}
return (
<QueryClientProvider client={queryClient}>
<Routes>
<Routes>
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
@@ -208,6 +195,10 @@ const App = () => {
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
@@ -251,8 +242,7 @@ const App = () => {
</Route>
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
</Routes>
</QueryClientProvider>
</Routes>
);
};

View File

@@ -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 (
<div className="overflow-hidden rounded-xl border border-amber-200/80 bg-gradient-to-b from-amber-50/40 to-card shadow-sm dark:from-amber-950/20">
<div className="flex items-center gap-3 border-b border-amber-200/50 bg-amber-50/50 px-5 py-4 dark:bg-amber-950/30">
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/15 text-amber-800 dark:text-amber-300">
<ShieldCheck className="size-4" />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
Approval chain
</h2>
<p className="text-xs text-muted-foreground">
Next:{" "}
{nextPending
? `${nextPending.requiredRole} · step ${nextPending.stepOrder}`
: steps.length
? "All steps complete"
: "Accept submission to begin"}
</p>
</div>
</div>
<div className="px-5 py-5">
{steps.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-muted/20 px-4 py-6 text-center text-sm text-muted-foreground">
Use <strong className="font-semibold text-foreground">Accept for approval</strong>{" "}
in staff actions to instantiate steps.
</p>
) : (
<ul className="space-y-2">
{steps.map((step) => (
<StepRow
key={step.id}
step={step}
isNext={nextPending?.id === step.id}
/>
))}
</ul>
)}
</div>
</div>
);
}
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 (
<li
className={cn(
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors",
isNext
? "border-primary/30 bg-primary/[0.03] shadow-sm"
: "border-border/60 bg-card",
)}
>
<div className="flex min-w-0 items-center gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-xs font-bold text-muted-foreground">
{step.stepOrder}
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">
{step.requiredRole}
</p>
{step.remarks && (
<p className="truncate text-xs text-muted-foreground">
{step.remarks}
</p>
)}
</div>
</div>
<Badge
variant="outline"
className={cn("shrink-0 text-[9px] uppercase", statusStyles)}
>
{step.status}
</Badge>
</li>
);
}

View File

@@ -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 (
<Button
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-primary"
onClick={() => navigate(`/dashboard/booking-requests/${row.id}`)}
aria-label="View booking"
>
<ChevronRight className="size-4" />
</Button>
);
}
return (
<>
<div
className={cn(
"flex items-center justify-end gap-1",
variant === "table" && "opacity-80 transition-opacity group-hover/tr:opacity-100",
className,
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{variant === "table" && primary && (
<Button
size="sm"
className="hidden h-8 gap-1.5 px-2.5 shadow-sm lg:inline-flex"
disabled={mutations.isPending}
onClick={() =>
primary.id === "viewContract"
? goToContract()
: flow.openAction(primary)
}
>
<primary.icon className="size-3.5" />
{primary.shortLabel}
</Button>
)}
{variant === "toolbar" && actions.length > 0 ? (
<div className="flex w-full flex-wrap gap-2">
{actions.map((action) => {
const Icon = action.icon;
return (
<Button
key={action.id}
size="sm"
variant={
action.variant === "destructive"
? "outline"
: action.primary
? "default"
: "outline"
}
className={cn(
"gap-2 shadow-sm",
action.variant === "destructive" &&
"border-red-200 text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30",
)}
disabled={mutations.isPending}
onClick={() =>
action.id === "viewContract"
? goToContract()
: flow.openAction(action)
}
>
<Icon className="size-4" />
{action.label}
</Button>
);
})}
</div>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={variant === "table" ? "ghost" : "outline"}
size={variant === "table" ? "icon" : "sm"}
className={cn(
variant === "table" ? "size-8" : "gap-2",
"shrink-0",
)}
disabled={mutations.isPending}
aria-label="Booking actions"
>
{mutations.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<MoreHorizontal className="size-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-mono text-xs text-muted-foreground">
{row.reference}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{actions.map((action) => {
const Icon = action.icon;
return (
<DropdownMenuItem
key={action.id}
className={cn(
"gap-2 cursor-pointer",
action.variant === "destructive" && "text-red-700 focus:text-red-700",
)}
onClick={() =>
action.id === "viewContract"
? goToContract()
: flow.openAction(action)
}
>
<Icon className="size-4 opacity-70" />
<span>{action.label}</span>
</DropdownMenuItem>
);
})}
{showUsdPaymentHint && (
<DropdownMenuItem
className="gap-2 cursor-pointer"
onClick={() =>
navigate(`/dashboard/booking-requests/${row.id}`)
}
>
<Upload className="size-4 opacity-70" />
Upload payment proof
</DropdownMenuItem>
)}
{(actions.length > 0 || showUsdPaymentHint) && (
<DropdownMenuSeparator />
)}
<DropdownMenuItem
className="gap-2 cursor-pointer"
onClick={() =>
navigate(`/dashboard/booking-requests/${row.id}`)
}
>
<ExternalLink className="size-4 opacity-70" />
Open full details
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<BookingConfirmDialog
open={flow.dialogOpen}
onOpenChange={flow.setDialogOpen}
action={pendingAction}
reference={flow.mergedContext.reference}
inputValue={flow.inputValue}
onInputChange={flow.setInputValue}
onConfirm={flow.runAction}
isPending={mutations.isPending || flow.detailLoading}
confirmDisabled={flow.confirmDisabled}
extra={
flow.detailLoading ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading approval steps
</p>
) : pendingAction?.id === "approve" &&
!flow.mergedContext.approvalSteps?.length ? (
<p className="rounded-lg border border-amber-200/80 bg-amber-50/50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
No pending approval step found. Accept the submission on the detail
page first.
</p>
) : null
}
/>
</>
);
}

View File

@@ -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<typeof useBookingMutations>;
interface BookingActionsToolbarProps {
booking: BookingDetail;
mutations: Mutations;
}
/** Detail-page actions: primary toolbar + payment uploads + downloads. */
export function BookingActionsToolbar({
booking,
mutations,
}: BookingActionsToolbarProps) {
const fileRef = useRef<HTMLInputElement>(null);
const row = toBookingListRow(booking);
const { status, paymentCurrency } = booking;
const pending = mutations.isPending;
const downloadBlob = async (fn: () => Promise<Blob>, 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 (
<PanelShell title="Awaiting customer" description="No staff actions until resubmit.">
{booking.latestChangeRequestNote && (
<p className="rounded-lg border bg-muted/30 p-3 text-sm leading-relaxed">
{booking.latestChangeRequestNote}
</p>
)}
</PanelShell>
);
}
if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) {
return (
<PanelShell
title="No staff actions"
description="Monitor until the customer or system advances status."
muted
/>
);
}
return (
<div className="space-y-4">
<PanelShell
title="Staff actions"
description="Confirm each step before it is applied."
>
<BookingActionsMenu row={row} variant="toolbar" />
</PanelShell>
{status === "FULLY_EXECUTED" && paymentCurrency === "USD" && (
<PanelShell title="Payment (USD)" description="Upload proof of payment.">
<input
ref={fileRef}
type="file"
className="hidden"
accept=".pdf,.png,.jpg,.jpeg"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) mutations.submitPaymentProof.mutate(file);
}}
/>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
disabled={pending}
className="gap-2"
onClick={() => fileRef.current?.click()}
>
<Upload className="size-4" />
Upload payment proof
</Button>
<Button
variant="outline"
className="gap-2"
onClick={() =>
downloadBlob(
() => mutations.downloadPaymentLetter(),
`payment-letter-${booking.reference}.txt`,
)
}
>
<Download className="size-4" />
Request letter
</Button>
</div>
</PanelShell>
)}
{status === "CONTRACT_READY" && (
<PanelShell title="Documents" description="Download generated contract.">
<Button
variant="outline"
className="gap-2"
onClick={() =>
downloadBlob(
() => mutations.downloadContract(),
`contract-${booking.reference}.txt`,
)
}
>
<Download className="size-4" />
Download contract
</Button>
</PanelShell>
)}
</div>
);
}
function PanelShell({
title,
description,
children,
muted,
}: {
title: string;
description: string;
children: React.ReactNode;
muted?: boolean;
}) {
return (
<div
className={
muted
? bookingSurface.sectionCard
: `${bookingSurface.sectionCard} ring-1 ring-primary/10`
}
>
<div className={bookingSurface.sectionHeader}>
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Zap className="size-4" />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
<div className="flex flex-col gap-3 px-5 py-5">{children}</div>
</div>
);
}

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-md">
<div
className={cn(
"border-b px-6 py-5",
isDestructive
? "bg-gradient-to-br from-red-500/10 via-background to-background"
: "bg-gradient-to-br from-primary/8 via-background to-background",
)}
>
<DialogHeader className="gap-3 text-left">
<div className="flex items-start gap-3">
<div
className={cn(
"flex size-11 shrink-0 items-center justify-center rounded-xl shadow-sm",
isDestructive
? "bg-red-500/15 text-red-700 dark:text-red-300"
: "bg-primary/15 text-primary",
)}
>
<Icon className="size-5" />
</div>
<div className="min-w-0 space-y-1 pt-0.5">
<DialogTitle className="text-base leading-snug">
{action.confirmTitle}
</DialogTitle>
{reference && (
<p className="font-mono text-xs font-semibold text-muted-foreground">
{reference}
</p>
)}
</div>
</div>
<DialogDescription className="text-left text-sm leading-relaxed">
{action.confirmDescription}
</DialogDescription>
</DialogHeader>
</div>
<div className="space-y-4 px-6 py-5">
{needsInput && (
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{action.inputLabel}
<span className="text-red-600"> *</span>
</label>
<Textarea
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
placeholder={action.inputPlaceholder}
rows={4}
className="min-h-[100px] resize-y"
/>
</div>
)}
{extra}
</div>
<DialogFooter className="gap-2 border-t bg-muted/20 px-6 py-4 sm:justify-end">
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="button"
variant={isDestructive ? "destructive" : "default"}
disabled={isPending || inputMissing || confirmDisabled}
className="min-w-[7rem] gap-2"
onClick={onConfirm}
>
{isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Icon className="size-4" />
)}
{action.shortLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 (
<div className={bookingSurface.sectionCard}>
<div className={bookingSurface.sectionHeader}>
<div className="flex size-9 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-700 dark:text-emerald-400">
<Banknote className="size-4" />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
Pricing & payment
</h2>
<p className="text-xs text-muted-foreground">Commercial terms</p>
</div>
</div>
<div className="space-y-4 px-5 py-5">
<div className="rounded-xl border border-primary/15 bg-gradient-to-br from-primary/[0.06] to-transparent p-4">
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
Total amount
</p>
<p className="mt-1 font-mono text-2xl font-bold tracking-tight text-foreground">
{booking.paymentCurrency}{" "}
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</p>
</div>
<Row label="Payment status" value={booking.paymentStatus} />
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
{modifiers.length > 0 && (
<>
<Separator />
<p className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<Receipt className="size-3" />
Surcharges applied
</p>
<ul className="space-y-2">
{modifiers.map((m) => (
<li
key={m.id}
className="flex justify-between rounded-lg border border-border/60 bg-muted/20 px-3 py-2 text-sm"
>
<span className="text-muted-foreground">Modifier</span>
<span className="font-mono font-semibold tabular-nums">
{Number(m.calculatedAmount).toLocaleString()}
</span>
</li>
))}
</ul>
</>
)}
</div>
</div>
);
}
function Row({
label,
value,
mono,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div className="flex items-center justify-between gap-2 text-sm">
<span className="text-muted-foreground">{label}</span>
<span
className={
mono
? "font-mono text-xs font-semibold text-foreground"
: "font-medium text-foreground"
}
>
{value}
</span>
</div>
);
}

View File

@@ -0,0 +1,21 @@
export function BookingPriorityBadge({ score }: { score: number }) {
if (score >= 1000) {
return (
<span className="rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-red-700">
Urgent
</span>
);
}
if (score >= 500) {
return (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700">
High
</span>
);
}
return (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600">
Normal
</span>
);
}

View File

@@ -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 (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{items.map((item) => {
const Icon = item.icon;
const accent = item.accent ?? "default";
return (
<div
key={item.label}
className="group relative overflow-hidden rounded-xl border border-border bg-card p-5 shadow-sm transition-all duration-200 hover:border-primary/20 hover:shadow-md"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{item.label}
</p>
<p className="mt-2 text-3xl font-bold tabular-nums tracking-tight text-foreground">
{item.value}
</p>
{item.hint && (
<p className="mt-1 text-xs text-muted-foreground">{item.hint}</p>
)}
</div>
<div
className={cn(
"flex size-11 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-105",
accentStyles[accent],
)}
>
<Icon className="size-5" strokeWidth={2} />
</div>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -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 (
<Badge
variant="outline"
className={cn(
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider",
style.color,
)}
>
{style.label}
</Badge>
);
}

View File

@@ -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<BookingStatusTabKey, React.ReactNode> = {
all: <LayoutGrid className="size-4" />,
SUBMITTED: <Inbox className="size-4" />,
PENDING_APPROVAL: <ClipboardCheck className="size-4" />,
APPROVED_PENDING_SIGNATURE: <FileSignature className="size-4" />,
SIGNED_CUSTOMER: <FileText className="size-4" />,
PAYMENT_VERIFICATION_IN_PROGRESS: <ShieldCheck className="size-4" />,
};
interface BookingStatusTabsProps {
active: BookingStatusTabKey;
onChange: (tab: BookingStatusTabKey) => void;
counts?: Partial<Record<BookingStatusTabKey, number>>;
}
export function BookingStatusTabs({
active,
onChange,
counts,
}: BookingStatusTabsProps) {
return (
<div className="rounded-xl border border-border bg-muted/30 p-1.5">
<div
className="flex gap-1 overflow-x-auto pb-0.5 scrollbar-thin"
role="tablist"
aria-label="Booking status filters"
>
{BOOKING_LIST_TABS.map((tab) => {
const isActive = active === tab.key;
const count = counts?.[tab.key];
return (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => 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",
)}
>
<span className="flex w-full items-center justify-between gap-2">
<span
className={cn(
"flex items-center gap-2 text-sm font-semibold",
isActive && "text-primary",
)}
>
{TAB_ICONS[tab.key]}
{tab.label}
</span>
{count !== undefined && count > 0 && (
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-bold tabular-nums",
isActive
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground",
)}
>
{count}
</span>
)}
</span>
</button>
);
})}
</div>
</div>
);
}
export type { BookingStatusTabKey };

View File

@@ -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 (
<div className={bookingSurface.emptyState}>
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
{hasSearch ? <Search className="size-6" /> : <Package className="size-6" />}
</div>
<div className="max-w-sm space-y-1">
<h3 className="text-base font-semibold text-foreground">
{isError
? "Could not load bookings"
: hasSearch
? "No matches on this page"
: "No bookings with this status"}
</h3>
<p className="text-sm text-muted-foreground">
{isError
? "Check your connection and try again."
: hasSearch
? "Try a different reference or customer name."
: "New customer submissions will appear when status is Submitted."}
</p>
</div>
{isError && onRetry && (
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
)}
</div>
);
}

View File

@@ -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 (
<div className={bookingSurface.sectionCard}>
<div className={bookingSurface.sectionHeader}>
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Train className="size-4" />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
Workflow progress
</h2>
<p className="text-xs text-muted-foreground">
Customer submission through completion
</p>
</div>
</div>
<div className="space-y-8 px-5 py-6">
<div className="relative px-2">
<div className="absolute left-4 right-4 top-5 h-0.5 bg-border" />
<div
className="absolute left-4 top-5 h-0.5 bg-primary transition-all duration-700 ease-out"
style={{
width:
!isTerminal && currentStage >= 0
? `calc(${(currentStage / (WORKFLOW_STAGES.length - 1)) * 100}% - 2rem)`
: "0%",
}}
/>
<div className="relative flex justify-between">
{WORKFLOW_STAGES.map((stage, idx) => {
const Icon = STAGE_ICONS[idx] ?? FileText;
const isCompleted = !isTerminal && idx < currentStage;
const isActive = !isTerminal && idx === currentStage;
return (
<div
key={stage.label}
className="flex max-w-[4.5rem] flex-col items-center gap-2.5 sm:max-w-none"
>
<div
className={cn(
"flex size-10 items-center justify-center rounded-full border-2 bg-card transition-all duration-300",
isCompleted &&
"border-primary bg-primary text-primary-foreground shadow-sm",
isActive &&
"scale-110 border-primary bg-background text-primary shadow-md ring-4 ring-primary/15",
!isCompleted &&
!isActive &&
"border-border text-muted-foreground",
)}
>
{isCompleted ? (
<CheckCircle2 className="size-4" />
) : (
<Icon className="size-4" />
)}
</div>
<span
className={cn(
"text-center text-[10px] font-bold uppercase leading-tight tracking-wide",
isActive ? "text-primary" : "text-muted-foreground",
)}
>
{stage.label}
</span>
</div>
);
})}
</div>
</div>
<div
className={cn(
"rounded-xl border px-5 py-4",
isTerminal
? "border-destructive/20 bg-destructive/5"
: "border-primary/15 bg-primary/[0.04]",
)}
>
<h4
className={cn("text-sm font-bold tracking-tight", titleColor)}
>
{title}
</h4>
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">
{description}
</p>
</div>
</div>
</div>
);
}

View File

@@ -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<HTMLCanvasElement>(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 (
<div className={cn("space-y-2", className)}>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<canvas
ref={canvasRef}
className="h-36 w-full touch-none cursor-crosshair"
onMouseDown={start}
onMouseMove={move}
onMouseUp={end}
onMouseLeave={end}
onTouchStart={start}
onTouchMove={move}
onTouchEnd={end}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Draw your signature above
</p>
<Button type="button" variant="ghost" size="sm" className="gap-1" onClick={clear}>
<Eraser className="size-3.5" />
Clear
</Button>
</div>
{empty && (
<p className="text-xs text-amber-700">Signature is required before confirming.</p>
)}
</div>
);
}

View File

@@ -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;

View File

@@ -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<BookingActionDef | null>(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,
};
}

View File

@@ -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 ? (
<span>{label}</span>
) : (
<span className="text-muted-foreground"></span>
);
}
if (format === "rateLabel") {
if (!value || typeof value !== "object") {
return value ? (
<span className="font-mono text-xs text-muted-foreground">{String(value)}</span>
) : (
<span className="text-muted-foreground"></span>
);
}
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 ? (
<span>{parts.join(" · ")}</span>
) : (
<span className="text-muted-foreground"></span>
);
}
return String(value);
};

View File

@@ -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<string, unknown>,
) => ["rule-engine", "select-options", resource, params ?? {}] as const,
},
} as const;

View File

@@ -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,
},
}

View File

@@ -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: {

View File

@@ -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";
}

View File

@@ -0,0 +1,260 @@
import type { BookingStatus } from "@/types/booking";
export interface StatusStyle {
label: string;
color: string;
}
export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
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<string, StatusMeta> = {
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;
}

View File

@@ -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,
};
}

View File

@@ -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),
};
}

View File

@@ -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<RuleEngineRecord>("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<RuleEngineRecord>("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<RuleEngineRecord>("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<string, unknown>) =>
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<string, unknown>;
}) => 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"),
});

View File

@@ -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";

View File

@@ -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,
},
},
});

View File

@@ -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(
<QueryClientProvider client={queryClient}>

View File

@@ -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<string | null>(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 (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="size-8 animate-spin text-primary" />
</div>
);
}
if (isError || !data) {
return (
<div className={bookingSurface.pageInner}>
<p className="text-muted-foreground">Could not load contract.</p>
<Button variant="outline" className="mt-4" onClick={() => navigate(-1)}>
Go back
</Button>
</div>
);
}
return (
<div className={bookingSurface.page}>
<div className={cn(bookingSurface.pageInner, "print:p-0")}>
<div className="print:hidden">
<Breadcrumbs
items={[
{ label: "Booking requests", href: "/dashboard/booking-requests" },
{
label: data.reference,
href: `/dashboard/booking-requests/${id}`,
},
{ label: "Contract" },
]}
/>
</div>
<div className="sticky top-0 z-10 mb-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-background/95 p-4 shadow-sm backdrop-blur print:hidden">
<Button variant="ghost" size="sm" className="gap-2" onClick={() => navigate(-1)}>
<ArrowLeft className="size-4" />
Back
</Button>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" className="gap-2" onClick={handlePrint}>
<Printer className="size-4" />
Print
</Button>
<Button variant="outline" size="sm" className="gap-2" onClick={downloadPdf}>
<Download className="size-4" />
Download PDF
</Button>
{signRole && (
<Button size="sm" className="gap-2" onClick={openSign}>
<FileSignature className="size-4" />
Sign as {signRole === "CUSTOMER" ? "Customer" : "Staff"}
</Button>
)}
</div>
</div>
<article
className="contract-document mx-auto max-w-[210mm] rounded-xl border bg-white p-8 shadow-sm print:border-0 print:shadow-none"
dangerouslySetInnerHTML={{ __html: extractBodyHtml(data.html) }}
/>
<Dialog open={signOpen} onOpenChange={setSignOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"}
</DialogTitle>
<DialogDescription>
Sign to execute the contract for {data.reference}.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="signerName">Full name</Label>
<Input
id="signerName"
value={signerName}
onChange={(e) => setSignerName(e.target.value)}
placeholder="As shown on the contract"
/>
</div>
<ContractSignaturePad onChange={setSignatureData} />
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSignOpen(false)}>
Cancel
</Button>
<Button
disabled={
signMutation.isPending ||
!signatureData ||
!signerName.trim()
}
onClick={confirmSign}
>
{signMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Confirm signature"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
}
/** Render server HTML body content inside our layout wrapper. */
function extractBodyHtml(fullHtml: string): string {
const match = fullHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
return match ? match[1] : fullHtml;
}

View File

@@ -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<string, { label: string; color: string }> = {
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 (
<Badge
variant="outline"
className={cn(
"px-2 py-0.5 font-bold uppercase tracking-wider text-[9px]",
style.color,
)}
>
{style.label}
</Badge>
);
}
function PriorityBadge({ score }: { score: number }) {
if (score >= 3) {
return (
<span className="rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-red-700">
Urgent
</span>
);
}
if (score === 2) {
return (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700">
High
</span>
);
}
return (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600">
Normal
</span>
);
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<string | null>(null);
const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("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<BookingRequest>[] = [
const columns: ColumnDef<BookingListRow>[] = [
{
id: "booking",
header: "Booking",
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Booking</span>,
cell: ({ row }) => {
const b = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Package className="h-5 w-5" />
<div className="flex items-center gap-3 py-1">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-sm">
<Package className="size-4" />
</div>
<div>
<p className="font-medium text-slate-900">{b.reference}</p>
<p className="flex items-center gap-1 text-xs text-slate-500">
<User className="h-3 w-3" />
{b.customer}
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{b.reference}</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0" />
{b.customerLabel}
</p>
</div>
</div>
@@ -224,264 +111,225 @@ export default function BookingRequestsPage() {
},
{
id: "route",
header: "Route",
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Route</span>,
cell: ({ row }) => {
const b = row.original;
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1 text-xs font-medium text-slate-700">
<span>{b.originYard}</span>
<ArrowRight className="h-3 w-3 text-slate-400" />
<span>{b.destinationYard}</span>
<div className="space-y-1 py-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<span className="max-w-[8rem] truncate">{b.originLabel}</span>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span className="max-w-[8rem] truncate">{b.destinationLabel}</span>
</div>
<div className="flex gap-1.5">
<Badge variant="outline" className="h-5 px-1.5 text-[10px] font-semibold uppercase">
{b.tradeDirection}
</Badge>
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-medium">
{b.freightType}
</Badge>
</div>
<span className="text-[10px] uppercase tracking-wide text-slate-500">
{b.tradeDirection}
</span>
</div>
);
},
},
{
id: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Status</span>,
cell: ({ row }) => <BookingStatusBadge status={row.original.status} />,
},
{
id: "service",
header: "Service",
cell: ({ row }) => {
const b = row.original;
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-slate-700">
{b.serviceType.replace(/_/g, " ")}
</span>
<span className="flex items-center gap-1 text-[10px] text-slate-500">
<Calendar className="h-3 w-3" />
{b.scheduledDate}
</span>
</div>
);
},
},
{
id: "cargo",
header: "Cargo",
cell: ({ row }) => {
const b = row.original;
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-slate-700">
{b.cargoType}
</span>
<span className="text-[10px] text-slate-500">
{b.cargoTotalWeightVgm}T
</span>
</div>
);
},
id: "scheduled",
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Scheduled</span>,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" />
{row.original.scheduledDate}
</span>
),
},
{
id: "priority",
header: "Priority",
cell: ({ row }) => <PriorityBadge score={row.original.priorityScore} />,
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Priority</span>,
cell: ({ row }) => (
<BookingPriorityBadge score={row.original.priorityScore} />
),
},
{
id: "amount",
header: "Amount",
header: () => (
<span className="text-xs font-semibold uppercase tracking-wider">Amount</span>
),
cell: ({ row }) => {
const b = row.original;
return (
<span className="font-mono text-xs font-semibold text-slate-900">
{b.paymentCurrency} {b.totalAmount.toLocaleString()}
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
{b.paymentCurrency}{" "}
{b.totalAmount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}
</span>
);
},
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const b = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() =>
navigate(`/dashboard/booking-requests/${b.id}`)
}
>
<Eye />
View Details
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() =>
navigate(`/dashboard/booking-requests/${b.id}`)
}
>
<AlertCircle />
Review
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
size: 140,
header: () => (
<span className="text-xs font-semibold uppercase tracking-wider">
Actions
</span>
),
cell: ({ row }) => (
<BookingActionsMenu row={row.original} variant="table" />
),
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Booking Requests" }]} />
<div className={bookingSurface.page}>
<div className={bookingSurface.pageInner}>
<Breadcrumbs items={[{ label: "Operations" }, { label: "Booking requests" }]} />
<Card className="flex-row justify-between p-6">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Booking Requests
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Review, approve, or reject customer booking requests across the
freight network.
</p>
<div className={bookingSurface.hero}>
<div className={bookingSurface.heroGlow} />
<div className="relative flex flex-col gap-6 p-6 sm:flex-row sm:items-center sm:justify-between sm:p-8">
<div className="flex items-start gap-4">
<div className="flex size-14 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg shadow-primary/25">
<Inbox className="size-7" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
Booking requests
</h1>
<p className="mt-1.5 max-w-xl text-sm leading-relaxed text-muted-foreground">
Track bookings from submission through payment and operations.
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
className="gap-2"
disabled={isFetching}
onClick={() => refetch()}
>
<RefreshCw
className={cn("size-4", isFetching && "animate-spin")}
/>
Refresh
</Button>
</div>
</div>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-72">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<BookingStatGrid
items={[
{
label: "In queue",
value: total,
hint: "Total matching filter",
icon: LayoutList,
},
{
label: "On this page",
value: rows.length,
hint: "Current view",
icon: FileText,
},
{
label: "Needs action",
value: pendingCount,
hint: "Submitted or pending approval",
icon: Clock,
accent: "amber",
},
{
label: "Urgent",
value: urgentCount,
hint: "High priority score",
icon: AlertCircle,
accent: urgentCount > 0 ? "rose" : "default",
},
]}
/>
<BookingStatusTabs
active={activeTab}
onChange={(tab) => {
setActiveTab(tab);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
counts={{
[activeTab]: total,
}}
/>
<div className={bookingSurface.panel}>
<div className={bookingSurface.panelToolbar}>
<div className="relative min-w-[12rem] flex-1 sm:max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={query}
onChange={(e) => {
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 && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={() => setQuery("")}
aria-label="Clear search"
>
<X className="size-3.5" />
</button>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="hidden text-xs text-muted-foreground sm:inline">
{total} record{total !== 1 ? "s" : ""}
</span>
</div>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-4">
<StatCard
label="Total Requests"
value={bookingRequests.length}
icon={<FileText />}
/>
<StatCard
label="Pending Action"
value={pendingCount}
icon={<Clock />}
/>
<StatCard label="Active" value={activeCount} icon={<Train />} />
<StatCard label="Urgent" value={urgentCount} icon={<AlertCircle />} />
</div>
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>All Booking Requests</CardTitle>
<CardDescription>
{total} request{total !== 1 ? "s" : ""} found
</CardDescription>
</div>
<div className="flex items-center gap-2">
{statusFilter && (
<Button
variant="ghost"
size="sm"
onClick={() => setStatusFilter(null)}
>
Clear filter
</Button>
)}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="sm">
<Filter />
{statusFilter
? (STATUS_STYLES[statusFilter]?.label ?? "Filter")
: "Filter"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{BOOKING_STATUSES.map((s) => (
<DropdownMenuItem
key={s}
onSelect={() => setStatusFilter(s)}
>
{STATUS_STYLES[s]?.label ?? s}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="px-0">
<DataTable
columns={columns}
data={paginatedData}
status="success"
onRowClick={(row) =>
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 ? (
<BookingTableEmpty
isError={isError}
hasSearch={hasSearch}
onRetry={() => refetch()}
/>
</CardContent>
</Card>
) : (
<div className={bookingSurface.tableWrap}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) =>
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}
/>
</div>
)}
</div>
</div>
</div>
);
}
function StatCard({
label,
value,
icon,
}: {
label: string;
value: number;
icon: React.ReactNode;
}) {
return (
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{label}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
{icon}
</div>
</CardContent>
</Card>
);
}

View File

@@ -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<T>(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";

View File

@@ -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[] = [];

View File

@@ -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<RuleEngineRecord | null>(null);
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(null);
const [chainOpen, setChainOpen] = useState(false);
const [approveTarget, setApproveTarget] = useState<RuleEngineRecord | null>(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<RuleEngineRecord>[] => {
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}
/>
</div>
),
});
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}
/>
)}
</Card>
@@ -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 = () => {
</DialogContent>
</Dialog>
<Dialog open={Boolean(approveTarget)} onOpenChange={(o) => !o && setApproveTarget(null)}>
<DialogContent className={ruleEngineSurface.dialogSm}>
<DialogHeader>
<DialogTitle>Approve rate</DialogTitle>
<DialogDescription>Enter the CEO staff ID to approve this rate.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="ceoId" className={ruleEngineField.label}>
CEO staff ID
</Label>
<Input
id="ceoId"
value={ceoId}
onChange={(e) => setCeoId(e.target.value)}
placeholder="UUID"
className={ruleEngineField.input}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setApproveTarget(null)}>
Cancel
</Button>
<Button
disabled={!ceoId.trim() || approve.isPending}
onClick={() => {
if (!approveTarget) return;
approve.mutate(
{ id: approveTarget.id, payload: { approvedByCeoId: ceoId.trim() } },
{
onSuccess: () => {
setApproveTarget(null);
setCeoId("");
},
},
);
}}
>
{approve.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Approve"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={chainOpen} onOpenChange={setChainOpen}>
<DialogContent className={ruleEngineSurface.dialog}>
<DialogHeader>

View File

@@ -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" },
],

View File

@@ -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<RuleEngineRecord>
>("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<void, RuleEngineRecord[]>(
"rule-engine",
"getApprovalChain",
() => ruleEngineService.getApprovalChain(),
() => QUERY_KEYS.RULE_ENGINE.chain,
),
},
bookings: {
list: endpoint<
{ filter?: BookingListFilter },
PaginatedResponse<Freight.IBooking>
>("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<ApproveStepPayload, BookingDetail>(
"bookings",
"approveStep",
(payload) => bookingsService.approveStep(payload),
),
rejectStep: endpoint<RejectStepPayload, BookingDetail>(
"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),
),
},
};

View File

@@ -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<T>(url: string, body?: unknown): Promise<T> {
const response = await client.post<T>(url, body ?? {});
return unwrap(response.data);
}
export const bookingsService = {
list: async (
filter?: BookingListFilter,
): Promise<PaginatedResponse<Freight.IBooking>> => {
const response = await client.get<PaginatedResponse<Freight.IBooking>>(
BASE,
{ params: filter },
);
return unwrap(response.data);
list: async (filter?: BookingListFilter): Promise<PaginatedBookings> => {
const response = await client.get<PaginatedBookings>(B.BASE, {
params: filter,
});
const data = unwrap(response.data);
return {
items: (data.items ?? []) as BookingDetail[],
total: data.total ?? 0,
};
},
getById: async (id: string): Promise<Freight.IBooking> => {
const response = await client.get<Freight.IBooking>(
URL_CONSTANTS.BOOKINGS.BY_ID(id),
);
return unwrap(response.data);
},
updateStatus: async (
id: string,
payload: { action: string; reason?: string },
): Promise<Freight.IBooking> => {
const response = await client.patch<Freight.IBooking>(
`${URL_CONSTANTS.BOOKINGS.BY_ID(id)}/status`,
payload,
);
return unwrap(response.data);
getById: async (id: string): Promise<BookingDetail> => {
const response = await client.get<BookingDetail>(B.BY_ID(id));
return unwrap(response.data) as BookingDetail;
},
remove: async (id: string): Promise<void> => {
await client.delete(URL_CONSTANTS.BOOKINGS.BY_ID(id));
await client.delete(B.BY_ID(id));
},
staffAccept: (id: string) => postBooking<BookingDetail>(B.STAFF_ACCEPT(id)),
requestChanges: (id: string, note: string) =>
postBooking<BookingDetail>(B.STAFF_REQUEST_CHANGES(id), { note }),
staffReject: (id: string, reason: string) =>
postBooking<BookingDetail>(B.STAFF_REJECT(id), { reason }),
approveStep: ({ id, stepId, requiredRole }: ApproveStepPayload) =>
postBooking<BookingDetail>(B.APPROVE_STEP(id, stepId), { requiredRole }),
rejectStep: ({ id, stepId, reason }: RejectStepPayload) =>
postBooking<BookingDetail>(B.REJECT_STEP(id, stepId), { reason }),
generateContract: (id: string) =>
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),
getContractView: async (id: string): Promise<ContractView> => {
const response = await client.get<ContractView>(B.CONTRACT_VIEW(id));
return unwrap(response.data) as ContractView;
},
downloadContract: async (id: string): Promise<Blob> => {
const response = await client.get(B.CONTRACT_DOWNLOAD(id), {
responseType: "blob",
});
return response.data as Blob;
},
downloadContractDocument: async (id: string): Promise<Blob> => {
const response = await client.get(B.CONTRACT_DOCUMENT(id), {
responseType: "blob",
});
return response.data as Blob;
},
signContract: (id: string, payload: SignContractPayload) =>
postBooking<BookingDetail>(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<BookingDetail>(B.CUSTOMER_SIGN(id), {
...payload,
role: "CUSTOMER",
}),
marketingApprove: (id: string, payload: SignContractPayload) =>
postBooking<BookingDetail>(B.MARKETING_APPROVE(id), {
...payload,
role: "STAFF",
}),
generatePnr: (id: string) => postBooking<BookingDetail>(B.PAYMENT_PNR(id)),
submitPaymentProof: async (id: string, file: File): Promise<BookingDetail> => {
const form = new FormData();
form.append("file", file);
const response = await client.post<BookingDetail>(B.PAYMENT_PROOF(id), form, {
headers: { "Content-Type": "multipart/form-data" },
});
return unwrap(response.data) as BookingDetail;
},
verifyPayment: (id: string) =>
postBooking<BookingDetail>(B.PAYMENT_VERIFY(id)),
downloadPaymentRequestLetter: async (id: string): Promise<Blob> => {
const response = await client.get(B.PAYMENT_REQUEST_LETTER(id), {
responseType: "blob",
});
return response.data as Blob;
},
startTransit: (id: string) =>
postBooking<BookingDetail>(B.START_TRANSIT(id)),
complete: (id: string) => postBooking<BookingDetail>(B.COMPLETE(id)),
cancel: (id: string, reason: string) =>
postBooking<BookingDetail>(B.CANCEL(id), { reason }),
};

View File

@@ -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<T>(response.data);
},
approveRate: async <T extends RuleEngineRecord>(
id: string,
payload: ApproveRatePayload,
): Promise<T> => {
const response = await client.post(URL_CONSTANTS.RULE_ENGINE.RATE_APPROVE(id), payload);
approveRate: async <T extends RuleEngineRecord>(id: string): Promise<T> => {
const response = await client.post(URL_CONSTANTS.RULE_ENGINE.RATE_APPROVE(id));
return normalizeEntity<T>(response.data);
},

View File

@@ -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;
}

View File

@@ -23,7 +23,3 @@ export interface RuleEngineListResult<T> {
}
export type RuleEngineRecord = Record<string, unknown> & { id: string };
export interface ApproveRatePayload {
approvedByCeoId: string;
}

View File

@@ -44,11 +44,19 @@ export function endpoint<TInput, TResponse>(
service: string,
action: string,
execute: (input: TInput) => Promise<TResponse>,
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);

View File

@@ -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<void> {
return qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT });
}
export function invalidateBookingDetail(
qc: QueryClient,
id: string,
): Promise<void> {
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<RuleEngineListResult<RuleEngineRecord>>(
{ 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<void> {
const queryKey = ["rule-engine", "list", resource] as const;
await qc.invalidateQueries({ queryKey });
await qc.refetchQueries({ queryKey, type: "active" });
}
export function invalidateRuleEngineRoot(qc: QueryClient): Promise<void> {
return qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.ROOT });
}

View File

@@ -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 = () => {
<Route path="/bookings" element={<MyBookings />} />
<Route path="/bookings/new" element={<NewBookingPage />} />
<Route path="/bookings/:id" element={<BookingDetailPage />} />
<Route path="/bookings/:id/contract" element={<BookingContractPage />} />
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/profile" element={<ProfilePage />} />

View File

@@ -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<HTMLCanvasElement>(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 (
<div className={cn("space-y-2", className)}>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<canvas
ref={canvasRef}
className="h-36 w-full touch-none cursor-crosshair"
onMouseDown={start}
onMouseMove={move}
onMouseUp={end}
onMouseLeave={end}
onTouchStart={start}
onTouchMove={move}
onTouchEnd={end}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Draw your signature above</p>
<Button type="button" variant="ghost" size="sm" className="gap-1" onClick={clear}>
<Eraser className="size-3.5" />
Clear
</Button>
</div>
{empty && (
<p className="text-xs text-amber-700">Signature is required before confirming.</p>
)}
</div>
);
}

View File

@@ -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`,
},

View File

@@ -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<string | null>(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 (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="size-8 animate-spin text-primary" />
</div>
);
}
if (isError || !data) {
return (
<div className="p-8">
<p className="text-muted-foreground">Could not load contract.</p>
<Button variant="outline" className="mt-4" onClick={() => navigate(-1)}>
Go back
</Button>
</div>
);
}
const bodyHtml = extractBodyHtml(data.html);
return (
<div className="min-h-screen bg-muted/30 p-4 md:p-8">
<div className="mx-auto max-w-4xl">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 print:hidden">
<Button variant="ghost" size="sm" onClick={() => navigate(`/bookings/${id}`)}>
<ArrowLeft className="mr-2 size-4" />
Back to booking
</Button>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => window.print()}>
<Printer className="mr-2 size-4" />
Print
</Button>
<Button variant="outline" size="sm" onClick={downloadPdf}>
<Download className="mr-2 size-4" />
PDF
</Button>
{data.canSignCustomer && (
<Button size="sm" onClick={() => setSignOpen(true)}>
<FileSignature className="mr-2 size-4" />
Sign contract
</Button>
)}
</div>
</div>
<article
className="contract-document rounded-lg border bg-white p-6 shadow-sm print:shadow-none md:p-10"
dangerouslySetInnerHTML={{ __html: bodyHtml }}
/>
</div>
{signOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 print:hidden">
<div className="w-full max-w-md rounded-xl bg-background p-6 shadow-xl">
<h2 className="text-lg font-semibold">Sign contract</h2>
<p className="mt-1 text-sm text-muted-foreground">
{data.reference} your signature will be stored securely.
</p>
<div className="mt-4 space-y-3">
<label className="text-sm font-medium" htmlFor="portalSigner">
Full name
</label>
<input
id="portalSigner"
className="w-full rounded-md border px-3 py-2 text-sm"
value={signerName}
onChange={(e) => setSignerName(e.target.value)}
/>
<ContractSignaturePad onChange={setSignatureData} />
</div>
<div className="mt-6 flex justify-end gap-2">
<Button variant="outline" onClick={() => setSignOpen(false)}>
Cancel
</Button>
<Button
disabled={
signMutation.isPending ||
!signatureData ||
!signerName.trim()
}
onClick={() =>
signMutation.mutate({
role: "CUSTOMER",
signatureImageBase64: signatureData!,
signerDisplayName: signerName.trim(),
consentText: "I agree to the terms of this contract.",
})
}
>
Confirm signature
</Button>
</div>
</div>
</div>
)}
</div>
);
}
function extractBodyHtml(fullHtml: string): string {
const match = fullHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
return match ? match[1] : fullHtml;
}

View File

@@ -152,6 +152,27 @@ export default function BookingDetailPage() {
</CardHeader>
</Card>
{(booking.status === "CONFIRMED" || booking.status === "IN_TRANSIT") && (
<Card className="border-primary/30 bg-primary/5">
<CardContent className="flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-semibold text-foreground">Contract ready</p>
<p className="text-sm text-muted-foreground">
Review the agreement and apply your digital signature.
</p>
</div>
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
onClick={() => navigate(`/bookings/${booking.id}/contract`)}
>
<FileSignature className="size-4" />
View &amp; sign contract
</button>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">

View File

@@ -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<PaginatedResponse<Freight.IBooking>> => {
const { data } = await client.get("/api/bookings");
@@ -24,4 +52,24 @@ export const bookingsService = {
remove: async (id: string): Promise<void> => {
await client.delete(`/api/bookings/${id}`);
},
getContractView: async (id: string): Promise<ContractView> => {
const { data } = await client.get(B.CONTRACT_VIEW(id));
return data.data ?? data;
},
downloadContractDocument: async (id: string): Promise<Blob> => {
const { data } = await client.get(B.CONTRACT_DOCUMENT(id), {
responseType: "blob",
});
return data;
},
signContract: async (
id: string,
payload: SignContractPayload,
): Promise<Freight.IBooking> => {
const { data } = await client.post(B.CONTRACT_SIGN(id), payload);
return data.data ?? data;
},
};

View File

@@ -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;
}

469
pnpm-lock.yaml generated
View File

@@ -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: {}