booking flow,summtion, approval, contract, mock payemnt and integration to back office, and also add permissions

This commit is contained in:
marshal
2026-06-05 10:38:31 +03:00
parent 810bbc1168
commit d226d7ef22
94 changed files with 3509 additions and 1042 deletions

View File

@@ -23,6 +23,10 @@ SUPER_ADMIN_EMAIL=superadmin@tria.com
SUPER_ADMIN_PHONE=
DEFAULT_PASSWORD=password@tria
# Freight org + staff (bookings / rule-engine IAM)
SEED_EDR_ORG=true
SEED_FREIGHT_STAFF=true
# MinIO (used by @tria-plc/iamapi-common for file storage)
MINIO_ENDPOINT=localhost
MINIO_PORT=9000

View File

@@ -24,12 +24,14 @@ import { OtpModule } from './modules/otp/otp.module';
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
import { FreightAuthModule } from "./modules/auth/freight-auth.module";
import {
EDR_FREIGHT_APPLICATION,
EDR_FREIGHT_PERMISSIONS,
} from "./seed/edr-freight.seed";
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
@Module({
imports: [
@@ -70,19 +72,22 @@ import { DemoUsersSeeder } from "./seed/demo-users.seeder";
RuleEngineModule,
BackofficeModule,
DemoPermissionsModule,
FreightAuthModule,
],
providers: [EdrOrgSeeder, DemoUsersSeeder],
providers: [EdrOrgSeeder, DemoUsersSeeder, FreightStaffUsersSeeder],
})
export class AppModule implements OnApplicationBootstrap {
constructor(
private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
) { }
async onApplicationBootstrap() {
await this.seeder.run();
await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run();
await this.freightStaffUsersSeeder.run();
}
}

View File

@@ -0,0 +1,17 @@
import { applyDecorators, UseGuards } from '@nestjs/common';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightPermissionGuard } from './freight-permission.guard';
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
export const BookingStaff = (permission: string | string[]) =>
applyDecorators(
UseGuards(
JwtGuard,
FreightPermissionGuard(
Array.isArray(permission) ? permission : [permission],
),
),
);
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);

View File

@@ -0,0 +1,38 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
Type,
UnauthorizedException,
} from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { hasFreightPermission } from './freight-permission.util';
export function FreightPermissionGuard(
permissions: string[],
): Type<CanActivate> {
@Injectable()
class FreightPermissionsGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
const user = request.user;
if (!permissions?.length) return true;
if (!user) {
throw new UnauthorizedException('Authentication required');
}
if (permissions.some((p) => hasFreightPermission(user, p))) {
return true;
}
throw new ForbiddenException(
`Missing permission. Required one of: ${permissions.join(', ')}`,
);
}
}
return FreightPermissionsGuard;
}

View File

@@ -0,0 +1,99 @@
import { ForbiddenException } from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
const SUPER_ADMIN_ROLE = 'super_admin';
type PermissionLike = { key?: string };
type MeLikeUser = {
roles?: { key?: string }[];
permissions?: PermissionLike[];
employee?:
| {
position?: { permissions?: PermissionLike[] };
delegatedPositions?: { permissions?: PermissionLike[] }[];
}
| {
positions?: { permissions?: PermissionLike[] }[];
}[]
| null;
};
export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean {
if (!user?.roles?.length) return false;
return user.roles.some((r) => r.key === SUPER_ADMIN_ROLE);
}
/** Flat permission keys from JWT / session user (roles + position permissions). */
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
if (!user) return [];
const keys = new Set<string>();
for (const p of user.permissions ?? []) {
if (p.key) keys.add(p.key);
}
const employee = user.employee;
if (!employee) {
return [...keys];
}
if (Array.isArray(employee)) {
for (const emp of employee) {
for (const pos of emp.positions ?? []) {
for (const p of pos.permissions ?? []) {
if (p.key) keys.add(p.key);
}
}
}
return [...keys];
}
for (const p of employee.position?.permissions ?? []) {
if (p.key) keys.add(p.key);
}
for (const delegated of employee.delegatedPositions ?? []) {
for (const p of delegated.permissions ?? []) {
if (p.key) keys.add(p.key);
}
}
return [...keys];
}
export function hasFreightPermission(
user: MeLikeUser | null | undefined,
permissionKey: string,
): boolean {
if (!user) return false;
if (isSuperAdmin(user)) return true;
return collectPermissionKeys(user).includes(permissionKey);
}
export function assertFreightPermission(
user: TCurrentUser | MeLikeUser | null | undefined,
permissionKey: string,
): void {
if (hasFreightPermission(user, permissionKey)) return;
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
}
const APPROVE_ROLE_PERMISSION: Record<string, string> = {
LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff,
DIRECTOR: FREIGHT_PERMS.bookings.approveDirector,
CEO: FREIGHT_PERMS.bookings.approveCeo,
};
export function assertCanApproveBookingStep(
user: TCurrentUser | MeLikeUser | null | undefined,
requiredRole: string,
): void {
if (isSuperAdmin(user)) return;
const perm = APPROVE_ROLE_PERMISSION[requiredRole];
if (!perm) {
throw new ForbiddenException(`Unknown approval role: ${requiredRole}`);
}
assertFreightPermission(user, perm);
}

View File

@@ -0,0 +1,18 @@
import { applyDecorators, UseGuards } from '@nestjs/common';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightPermissionGuard } from './freight-permission.guard';
import {
FREIGHT_PERMS,
type RuleEngineResourceSlug,
} from '../seed/freight-permissions.registry';
export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
);
export const RuleEngineManage = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])),
);

View File

@@ -1,57 +1,116 @@
import { Injectable, Logger } from '@nestjs/common';
import { existsSync } from 'fs';
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
const MIN_VALID_PDF_BYTES = 2_000;
const PDF_PRINT_STYLES = `
<style id="contract-pdf-print-fix">
@media print {
html, body {
background: #fff !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.cover {
min-height: auto !important;
page-break-after: always;
}
.cover-title {
margin: 24mm 0 20mm !important;
}
}
</style>`;
@Injectable()
export class ContractPdfService {
private readonly logger = new Logger(ContractPdfService.name);
async htmlToPdfBuffer(html: string): Promise<Buffer> {
const preparedHtml = this.injectPdfPrintStyles(html);
const executablePath = this.resolveExecutablePath();
try {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
const launchOptions: import('puppeteer').LaunchOptions = {
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
],
...(executablePath ? { executablePath } : {}),
};
const browser = await puppeteer.default.launch(launchOptions);
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'load' });
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
await page.setContent(preparedHtml, {
waitUntil: 'load',
timeout: 60_000,
});
await page.emulateMediaType('print');
await new Promise((resolve) => setTimeout(resolve, 400));
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
displayHeaderFooter: true,
headerTemplate: '<span></span>',
footerTemplate:
'<div style="width:100%;font-size:8px;color:#64748b;text-align:center;font-family:Arial,sans-serif;">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
margin: { top: '18mm', bottom: '22mm', left: '14mm', right: '14mm' },
});
return Buffer.from(pdf);
const buffer = Buffer.from(pdf);
if (!this.isValidPdf(buffer)) {
throw new Error(
`Puppeteer produced invalid PDF (${buffer.length} bytes)`,
);
}
this.logger.log(
`Contract PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`,
);
return buffer;
} finally {
await browser.close();
}
} catch (err) {
this.logger.warn(
`Puppeteer PDF failed, falling back to minimal PDF stub: ${err}`,
this.logger.error(
`Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`,
);
throw new InternalServerErrorException(
'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
);
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');
private injectPdfPrintStyles(html: string): string {
if (html.includes('contract-pdf-print-fix')) return html;
if (html.includes('</head>')) {
return html.replace('</head>', `${PDF_PRINT_STYLES}</head>`);
}
return `${PDF_PRINT_STYLES}${html}`;
}
private resolveExecutablePath(): string | undefined {
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
if (fromEnv && existsSync(fromEnv)) return fromEnv;
const candidates = [
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/google-chrome-stable',
'/usr/bin/google-chrome',
];
return candidates.find((p) => existsSync(p));
}
private isValidPdf(buffer: Buffer): boolean {
return (
buffer.length >= MIN_VALID_PDF_BYTES &&
buffer.subarray(0, 5).toString('ascii') === '%PDF-'
);
}
}

View File

@@ -55,7 +55,7 @@ export class ContractPricingScheduleBuilder {
})),
totalAmount,
currency,
equipmentReturn: booking.equipmentReturn ?? undefined,
equipmentReturn: booking.equipmentReturn ?? '—',
originLabel: booking.originYard?.label ?? booking.originYard?.code ?? '—',
destinationLabel:
booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—',

View File

@@ -23,6 +23,31 @@ describe('ContractRendererService', () => {
phone: '+251900000000',
email: 'test@example.com',
tinNumber: '1234567890',
vatNumber: 'VAT-001',
fanNumber: 'FAN-001',
businessLicense: 'BL-001',
},
provider: {
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
address: 'Addis Ababa, Ethiopia',
phone: '+251 11 872 0000',
email: 'info@edr.gov.et',
tinNumber: '—',
},
schedule: {
originLabel: 'SGTD',
destinationLabel: 'Modjo',
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
serviceType: 'Rail transport',
scheduledDate: '1 January 2026',
contractType: 'NEW',
cargoDescription: 'Container cargo',
totalWeightVgm: '24 tons',
equipmentReturn: 'RETURN',
hazardousLabel: 'No',
firstMilePickupAddress: '—',
lastMileDeliveryAddress: '—',
},
pricing: {
lineItems: [{ label: 'RAIL', description: 'Rail transport', amount: 1000, currency: 'ETB' }],

View File

@@ -32,6 +32,31 @@ export interface ContractViewModel {
phone: string;
email: string;
tinNumber: string;
vatNumber: string;
fanNumber: string;
businessLicense: string;
};
provider: {
name: string;
address: string;
phone: string;
email: string;
tinNumber: string;
};
schedule: {
originLabel: string;
destinationLabel: string;
tradeDirection: string;
freightType: string;
serviceType: string;
scheduledDate: string;
contractType: string;
cargoDescription: string;
totalWeightVgm: string;
equipmentReturn: string;
hazardousLabel: string;
firstMilePickupAddress: string;
lastMileDeliveryAddress: string;
};
pricing: PricingSchedule;
signatures: ContractSignatureView[];
@@ -81,19 +106,24 @@ export class ContractViewModelBuilder {
}),
contractYear: new Date().getFullYear(),
client: {
// companyName: booking.customer?.companyName ?? 'Client',
// companyAddress: booking.customer?.companyAddress ?? '—',
// companyLocation: booking.customer?.companyLocation ?? '—',
// phone: booking.customer?.companyPhone ?? booking.customer?.phone ?? '—',
// email: booking.customer?.companyEmail ?? booking.customer?.email ?? '—',
// tinNumber: booking.customer?.tinNumber ?? '—',
companyName: booking.company?.name ?? 'Client',
companyAddress: booking.company?.address ?? '—',
companyLocation: booking.company?.country ?? '—',
phone: booking.company?.phone ?? '—',
email: booking.company?.email ?? '—',
tinNumber: booking.company?.tin ?? '—',
companyAddress: this.valueOrDash(booking.company?.address),
companyLocation: this.valueOrDash(booking.company?.country),
phone: this.valueOrDash(booking.company?.phone),
email: this.valueOrDash(booking.company?.email),
tinNumber: this.valueOrDash(booking.company?.tin),
vatNumber: this.valueOrDash(booking.company?.vatNumber),
fanNumber: this.valueOrDash(booking.company?.fanNumber),
businessLicense: this.valueOrDash(booking.company?.businessLicense),
},
provider: {
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
address: 'Addis Ababa, Ethiopia',
phone: '+251 11 872 0000',
email: 'info@edr.gov.et',
tinNumber: '—',
},
schedule: this.buildSchedule(booking),
pricing,
signatures,
canSignCustomer:
@@ -117,8 +147,61 @@ export class ContractViewModelBuilder {
return {
role: row.signerRole,
signerDisplayName: row.signerDisplayName,
signedAt: row.signedAt.toISOString(),
signedAt: this.formatDate(row.signedAt),
signatureImageUrl: row.signatureFile?.url ?? null,
};
}
private buildSchedule(booking: Booking): ContractViewModel['schedule'] {
const cargoName =
booking.freightType === 'BULK'
? booking.cargoFreeText ||
booking.cargoType?.cargoTypeName ||
'Bulk commodity'
: booking.cargoType?.cargoTypeName || 'Container cargo';
const totalWeight = Number(booking.cargoTotalWeightVgm || 0);
return {
originLabel: this.yardLabel(booking.originYard),
destinationLabel: this.yardLabel(booking.destinationYard),
tradeDirection: this.valueOrDash(booking.tradeDirection),
freightType: this.valueOrDash(booking.freightType),
serviceType: this.valueOrDash(
booking.serviceType?.serviceName ?? booking.serviceType?.code,
),
scheduledDate: this.formatDate(booking.scheduledDate),
contractType: this.valueOrDash(booking.contractType),
cargoDescription: this.valueOrDash(cargoName),
totalWeightVgm:
totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—',
equipmentReturn: this.valueOrDash(booking.equipmentReturn),
hazardousLabel: booking.isHazardous ? 'Yes' : 'No',
firstMilePickupAddress: this.valueOrDash(
booking.firstMilePickupAddress,
),
lastMileDeliveryAddress: this.valueOrDash(
booking.lastMileDeliveryAddress,
),
};
}
private yardLabel(yard?: { label?: string; code?: string } | null): string {
return this.valueOrDash(yard?.label ?? yard?.code);
}
private formatDate(value?: Date | string | null): string {
if (!value) return '—';
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
private valueOrDash(value?: string | number | null): string {
if (value === undefined || value === null || value === '') return '—';
return String(value);
}
}

View File

@@ -1,8 +1,11 @@
<div class="article">
<h2>Article 1: Objective and Scope of Services</h2>
<p><strong>Objective:</strong> {{template.article1.objective}}</p>
<p>
<strong>1.1 Objective.</strong>
{{template.article1.objective}}
</p>
{{#if template.article1.scope.length}}
<p><strong>Scope:</strong></p>
<p><strong>1.2 Scope of Services.</strong></p>
<ol>
{{#each template.article1.scope}}
<li>{{this}}</li>

View File

@@ -1,22 +1,31 @@
<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>
<p>
The contract price is calculated based on the agreed railway corridor, cargo details, applicable rate
schedule, and any approved operational surcharges.
</p>
<table class="details-table">
<tbody>
<tr>
<th>Corridor</th>
<td>{{pricing.originLabel}}{{pricing.destinationLabel}}</td>
<th>Currency</th>
<td>{{pricing.currency}}</td>
</tr>
<tr>
<th>Payment currency</th>
<td>{{paymentArticle}}</td>
<th>Equipment return</th>
<td>{{pricing.equipmentReturn}}</td>
</tr>
</tbody>
</table>
{{#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}}
<h3>Charges</h3>
<table class="schedule">
<thead>
<tr><th>Item</th><th>Description</th><th>Amount</th></tr>
@@ -29,6 +38,10 @@
<td>{{currency}} {{amount}}</td>
</tr>
{{/each}}
{{#if pricing.surcharges.length}}
<tr>
<th colspan="3">Surcharges and Adjustments</th>
</tr>
{{#each pricing.surcharges}}
<tr>
<td>{{label}}</td>
@@ -36,12 +49,18 @@
<td>{{currency}} {{amount}}</td>
</tr>
{{/each}}
<tr>
{{/if}}
<tr class="total-row">
<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>
<p>
Unless otherwise agreed in writing, the Client shall settle the contract value in
<strong>{{paymentArticle}}</strong> before the service is performed and in accordance with EDR payment
instructions. Bank charges, penalties, demurrage, storage, and third-party charges remain the
responsibility of the Client where applicable.
</p>
</div>

View File

@@ -1,5 +1,6 @@
<div class="article">
<h2>Article 2: Obligations of the Client (summary)</h2>
<h2>Article 2: Obligations of the Client</h2>
<p>The Client shall perform the following obligations in good faith and within the operational timelines communicated by EDR:</p>
<ol>
{{#each template.clientObligations}}
<li>{{this}}</li>
@@ -8,7 +9,8 @@
</div>
<div class="article">
<h2>Article 3: Obligations of the Service Provider (summary)</h2>
<h2>Article 3: Obligations of the Service Provider</h2>
<p>EDR shall provide the agreed railway freight services in accordance with this Agreement and applicable operational rules:</p>
<ol>
{{#each template.providerObligations}}
<li>{{this}}</li>

View File

@@ -1,5 +1,6 @@
<div class="article">
<h2>Article 6: Contract Documents</h2>
<p>The following documents form part of this Agreement and shall be read together with the signed contract:</p>
<ol>
{{#each template.contractDocuments}}
<li>{{this}}</li>

View File

@@ -0,0 +1,65 @@
<section class="page-section">
<h2>Booking Schedule and Commercial Summary</h2>
<table class="details-table">
<tbody>
<tr>
<th>Route</th>
<td>{{schedule.originLabel}}{{schedule.destinationLabel}}</td>
<th>Trade direction</th>
<td>{{schedule.tradeDirection}}</td>
</tr>
<tr>
<th>Freight type</th>
<td>{{schedule.freightType}}</td>
<th>Service type</th>
<td>{{schedule.serviceType}}</td>
</tr>
<tr>
<th>Scheduled date</th>
<td>{{schedule.scheduledDate}}</td>
<th>Contract type</th>
<td>{{schedule.contractType}}</td>
</tr>
<tr>
<th>Cargo</th>
<td>{{schedule.cargoDescription}}</td>
<th>Total VGM</th>
<td>{{schedule.totalWeightVgm}}</td>
</tr>
<tr>
<th>Equipment return</th>
<td>{{schedule.equipmentReturn}}</td>
<th>Hazardous cargo</th>
<td>{{schedule.hazardousLabel}}</td>
</tr>
<tr>
<th>First mile</th>
<td>{{schedule.firstMilePickupAddress}}</td>
<th>Last mile</th>
<td>{{schedule.lastMileDeliveryAddress}}</td>
</tr>
</tbody>
</table>
{{#if pricing.containerLines.length}}
<h3>Container Details</h3>
<table class="schedule">
<thead>
<tr>
<th>Container type</th>
<th>Quantity</th>
<th>VGM / unit (tons)</th>
</tr>
</thead>
<tbody>
{{#each pricing.containerLines}}
<tr>
<td>{{label}}</td>
<td>{{quantity}}</td>
<td>{{vgmPerUnitTons}}</td>
</tr>
{{/each}}
</tbody>
</table>
{{/if}}
</section>

View File

@@ -1,4 +1,12 @@
<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>
<p>
Neither party shall be liable for delay or non-performance caused by events beyond its reasonable control,
including natural disaster, war, civil unrest, government restriction, railway interruption, port closure,
or other force majeure events interpreted under the Ethiopian Civil Code.
</p>
<p>
The affected party shall notify the other party promptly and shall use reasonable efforts to reduce the
effect of the force majeure event on the performance of this Agreement.
</p>
</div>

View File

@@ -1,30 +1,44 @@
<div class="signatures">
<div class="sig-block">
<p><strong>Service Provider (EDR)</strong></p>
<p class="sig-title">For the Service Provider</p>
<p><strong>{{provider.name}}</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>
<div class="sig-image-box">
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Staff signature" />{{/if}}
</div>
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
<p class="sig-meta"><strong>Role:</strong> Authorized EDR representative</p>
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
{{/if}}
{{/each}}
{{else}}
<p class="sig-line">Authorized representative (pending)</p>
<div class="sig-image-box"><span class="sig-placeholder">Signature pending</span></div>
<p class="sig-line"><strong>Name:</strong> Authorized representative</p>
<p class="sig-meta"><strong>Role:</strong> EDR representative</p>
<p class="sig-meta"><strong>Date:</strong></p>
{{/if}}
</div>
<div class="sig-block">
<p><strong>Client — {{client.companyName}}</strong></p>
<p class="sig-title">For the Client</p>
<p><strong>{{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>
<div class="sig-image-box">
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Customer signature" />{{/if}}
</div>
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
<p class="sig-meta"><strong>Role:</strong> Authorized client representative</p>
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
{{/if}}
{{/each}}
{{else}}
<p class="sig-line">Client representative (pending)</p>
<div class="sig-image-box"><span class="sig-placeholder">Signature pending</span></div>
<p class="sig-line"><strong>Name:</strong> Client representative</p>
<p class="sig-meta"><strong>Role:</strong> Authorized client representative</p>
<p class="sig-meta"><strong>Date:</strong></p>
{{/if}}
</div>
</div>

View File

@@ -1,22 +1,258 @@
<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; } }
@page { size: A4; margin: 18mm 14mm; }
body {
margin: 0;
background: #f5f7fb;
color: #111827;
font-family: "Times New Roman", Times, serif;
font-size: 10.5pt;
line-height: 1.48;
}
.contract {
width: 210mm;
min-height: 297mm;
margin: 0 auto;
background: #fff;
padding: 18mm 15mm;
}
h1, h2, h3, p { margin-top: 0; }
h1 {
color: #0f2742;
font-size: 18pt;
line-height: 1.25;
margin-bottom: 10px;
text-align: center;
text-transform: uppercase;
}
h2 {
border-bottom: 1.5px solid #1e3a5f;
color: #1e3a5f;
font-size: 12pt;
letter-spacing: 0.03em;
margin: 18px 0 10px;
padding-bottom: 5px;
text-transform: uppercase;
}
h3 {
color: #0f2742;
font-size: 10.8pt;
margin: 12px 0 6px;
}
p { margin-bottom: 8px; }
ol { margin: 6px 0 0; padding-left: 20px; }
li { margin-bottom: 5px; }
.page-section,
.article {
margin-bottom: 18px;
page-break-inside: avoid;
}
.brand-row {
align-items: center;
border-bottom: 3px solid #1e3a5f;
display: flex;
gap: 14px;
padding-bottom: 14px;
}
.logo-mark {
align-items: center;
background: #1e3a5f;
border-radius: 8px;
color: #fff;
display: flex;
font-family: Arial, sans-serif;
font-size: 16pt;
font-weight: 700;
height: 52px;
justify-content: center;
letter-spacing: 0.08em;
width: 72px;
}
.kicker {
color: #1e3a5f;
font-family: Arial, sans-serif;
font-size: 10pt;
font-weight: 700;
letter-spacing: 0.04em;
margin-bottom: 2px;
text-transform: uppercase;
}
.muted {
color: #6b7280;
font-family: Arial, sans-serif;
font-size: 9pt;
margin: 0;
}
.cover {
min-height: 255mm;
position: relative;
}
.cover-title {
margin: 54mm 0 34mm;
text-align: center;
}
.document-label {
color: #6b7280;
font-family: Arial, sans-serif;
font-size: 10pt;
font-weight: 700;
letter-spacing: 0.12em;
margin-bottom: 10px;
text-transform: uppercase;
}
.summary-line {
color: #374151;
font-family: Arial, sans-serif;
font-size: 9.5pt;
margin-top: 12px;
}
table {
border-collapse: collapse;
width: 100%;
}
.meta-grid,
.details-table,
.schedule {
font-size: 9.5pt;
margin: 10px 0 16px;
}
.meta-grid th,
.meta-grid td,
.details-table th,
.details-table td,
.schedule th,
.schedule td {
border: 1px solid #cbd5e1;
padding: 7px 8px;
text-align: left;
vertical-align: top;
}
.meta-grid th,
.details-table th,
.schedule th {
background: #eef4fb;
color: #1e3a5f;
font-family: Arial, sans-serif;
font-size: 8.5pt;
text-transform: uppercase;
}
.schedule tbody tr:nth-child(even) td { background: #f8fafc; }
.total-row td {
background: #e8f0f8 !important;
color: #0f2742;
font-weight: 700;
}
.lead {
color: #374151;
font-size: 10.5pt;
}
.party-grid {
display: grid;
gap: 12px;
grid-template-columns: 1fr 1fr;
}
.party-card {
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 12px;
}
.party-card h3 {
background: #1e3a5f;
border-radius: 5px;
color: #fff;
font-family: Arial, sans-serif;
font-size: 9pt;
margin: 0 0 10px;
padding: 7px 9px;
text-transform: uppercase;
}
.party-name {
color: #0f2742;
font-weight: 700;
margin-bottom: 8px;
}
dl {
display: grid;
grid-template-columns: 32% 68%;
margin: 0;
}
dt {
color: #475569;
font-family: Arial, sans-serif;
font-size: 8.5pt;
font-weight: 700;
padding: 2px 6px 2px 0;
}
dd {
margin: 0;
padding: 2px 0;
}
.signatures {
display: grid;
gap: 18px;
grid-template-columns: 1fr 1fr;
margin-top: 24px;
page-break-inside: avoid;
}
.sig-block {
border: 1.5px solid #1e3a5f;
border-radius: 8px;
min-height: 96mm;
padding: 12px;
}
.sig-title {
color: #1e3a5f;
font-family: Arial, sans-serif;
font-size: 9pt;
font-weight: 700;
margin-bottom: 10px;
text-transform: uppercase;
}
.sig-image-box {
align-items: center;
border: 1px dashed #94a3b8;
display: flex;
height: 28mm;
justify-content: center;
margin: 14px 0;
}
.sig-image-box img {
display: block;
max-height: 24mm;
max-width: 70mm;
}
.sig-placeholder {
color: #94a3b8;
font-family: Arial, sans-serif;
font-size: 8.5pt;
}
.sig-line {
border-top: 1px solid #111827;
margin-top: 16px;
padding-top: 5px;
}
.sig-meta {
color: #475569;
font-size: 9pt;
margin: 4px 0;
}
@media print {
body { background: #fff; }
.contract {
margin: 0;
padding: 0;
width: auto;
}
.cover { page-break-after: always; }
}
</style>

View File

@@ -6,25 +6,86 @@
{{> 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>
<main class="contract">
<section class="cover page-section">
<div class="brand-row">
<div class="logo-mark">EDR</div>
<div>
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
<p class="muted">Freight Transport Contract</p>
</div>
</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>
<div class="cover-title">
<p class="document-label">Contract Agreement</p>
<h1>{{template.title}}</h1>
<p class="summary-line">{{template.directionLabel}}{{template.freightLabel}}{{template.currency}}{{template.serviceScope}}</p>
</div>
<h2>Whereas</h2>
<p>{{template.whereas}}</p>
<p>Now therefore, the parties agree as follows:</p>
<table class="meta-grid">
<tr>
<th>Contract Ref No.</th>
<td>{{reference}}</td>
<th>Contract Year</th>
<td>{{contractYear}}</td>
</tr>
<tr>
<th>Contract Date</th>
<td>{{contractDate}}</td>
<th>Status</th>
<td>{{status}}</td>
</tr>
</table>
</section>
{{> article1}}
{{> articles_obligations}}
{{> article5_pricing}}
{{> force_majeure}}
{{> contract_documents}}
{{> signatures_block}}
<section class="page-section">
<h2>Parties to the Agreement</h2>
<p class="lead">
This Contract Agreement is made on <strong>{{contractDate}}</strong> between the Service Provider and the Client named below.
</p>
<div class="party-grid">
<div class="party-card">
<h3>Service Provider</h3>
<p class="party-name">{{provider.name}}</p>
<dl>
<dt>Address</dt><dd>{{provider.address}}</dd>
<dt>Phone</dt><dd>{{provider.phone}}</dd>
<dt>Email</dt><dd>{{provider.email}}</dd>
<dt>TIN</dt><dd>{{provider.tinNumber}}</dd>
</dl>
</div>
<div class="party-card">
<h3>Client</h3>
<p class="party-name">{{client.companyName}}</p>
<dl>
<dt>Address</dt><dd>{{client.companyAddress}}</dd>
<dt>Location</dt><dd>{{client.companyLocation}}</dd>
<dt>Phone</dt><dd>{{client.phone}}</dd>
<dt>Email</dt><dd>{{client.email}}</dd>
<dt>TIN</dt><dd>{{client.tinNumber}}</dd>
<dt>VAT</dt><dd>{{client.vatNumber}}</dd>
<dt>FAN</dt><dd>{{client.fanNumber}}</dd>
<dt>Business license</dt><dd>{{client.businessLicense}}</dd>
</dl>
</div>
</div>
</section>
{{> contract_schedule}}
<section class="page-section">
<h2>Whereas</h2>
<p>{{template.whereas}}</p>
<p>Now therefore, the parties agree as follows:</p>
</section>
{{> article1}}
{{> articles_obligations}}
{{> force_majeure}}
{{> article5_pricing}}
{{> contract_documents}}
{{> signatures_block}}
</main>
</body>
</html>

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBlocksRoleToApprovalStep1749600000000 implements MigrationInterface {
name = 'AddBlocksRoleToApprovalStep1749600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_approval_step
ADD COLUMN IF NOT EXISTS blocks_role VARCHAR(30) NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_approval_step
DROP COLUMN IF EXISTS blocks_role;
`);
}
}

View File

@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Seed ITMLS US-06 approval chains if missing (standard + bulk).
*/
export class SeedDefaultApprovalRules1749700000000 implements MigrationInterface {
name = 'SeedDefaultApprovalRules1749700000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO freight.approval_rules
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
SELECT uuid_generate_v4(), false, 1, 'LINE_STAFF', 'Review & Approve', NULL, now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM freight.approval_rules
WHERE requires_director_approval = false AND step_order = 1 AND deleted_at IS NULL
);
INSERT INTO freight.approval_rules
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
SELECT uuid_generate_v4(), false, 2, 'DIRECTOR', 'Final Signature', 'LINE_STAFF', now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM freight.approval_rules
WHERE requires_director_approval = false AND step_order = 2 AND deleted_at IS NULL
);
INSERT INTO freight.approval_rules
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
SELECT uuid_generate_v4(), true, 1, 'DIRECTOR', 'Review & Approve', 'LINE_STAFF', now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM freight.approval_rules
WHERE requires_director_approval = true AND step_order = 1 AND deleted_at IS NULL
);
INSERT INTO freight.approval_rules
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
SELECT uuid_generate_v4(), true, 2, 'CEO', 'Final Signature', NULL, now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM freight.approval_rules
WHERE requires_director_approval = true AND step_order = 2 AND deleted_at IS NULL
);
`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// Keep seeded rules on rollback to avoid breaking in-flight bookings.
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { FreightMeController } from './freight-me.controller';
import { FreightMeService } from './freight-me.service';
@Module({
controllers: [FreightMeController],
providers: [FreightMeService],
})
export class FreightAuthModule {}

View File

@@ -0,0 +1,23 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { FreightMeService } from './freight-me.service';
@ApiTags('auth')
@Controller('me')
@ApiBearerAuth()
export class FreightMeController {
constructor(private readonly freightMeService: FreightMeService) {}
@Get()
@UseGuards(JwtGuard)
@ApiOperation({
summary: 'Current user with flat permissionKeys for backoffice gating',
})
getMe(@CurrentUser() user: TCurrentUser) {
return this.freightMeService.getEnrichedProfile(user);
}
}

View File

@@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import {
collectPermissionKeys,
isSuperAdmin,
} from '../../common/freight-permission.util';
import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry';
@Injectable()
export class FreightMeService {
getEnrichedProfile(user: TCurrentUser) {
const employee = user.employee
? [
{
id: user.employee.id,
organizationId: user.employee.organizationId,
unitId: user.employee.unitId,
name: user.employee.name,
positions: user.employee.position
? [
{
id: user.employee.position.id,
key: user.employee.position.key,
employeePositionId: user.employee.position.employeePositionId,
name: user.employee.position.name,
isDelegate: user.employee.position.isDelegate,
parentPositionId: user.employee.position.parentPositionId,
permissions: user.employee.position.permissions ?? [],
},
]
: [],
},
]
: [];
const permissionKeys = collectPermissionKeys(user);
return {
id: user.id,
email: user.email,
name: user.name,
username: user.username,
phoneNumber: user.phoneNumber,
userType: user.userType,
status: user.status,
hasFinishedRegistration: user.hasFinishedRegistration,
hasFinishedDMSOnboarding: user.hasFinishedDMSOnboarding,
roles: user.roles,
permissions: user.permissions,
employee,
permissionKeys,
isSuperAdmin: isSuperAdmin(user),
permissionsCatalog: PERMISSIONS_CATALOG,
};
}
}

View File

@@ -12,6 +12,7 @@ import { ContractTemplateResolver } from '../../contracts/contract-template.reso
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { MinioService } from '../minio/minio.service';
import { FilesService } from '../files/files.service';
import { FileRecord } from '../files/entities/file.entity';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
@@ -68,7 +69,7 @@ export class BookingContractService {
async getContractView(bookingId: string): Promise<ContractViewDto> {
const { view } = await this.viewModelBuilder.build(bookingId);
await this.enrichSignatureUrls(view.signatures);
await this.inlineSignatureImages(view.signatures);
const html = this.renderer.render(view);
return {
bookingId: view.bookingId,
@@ -90,33 +91,8 @@ export class BookingContractService {
assertBookingStatus(booking, ['APPROVED']);
const templateKey = this.templateResolver.resolve(booking);
const { view } = await this.viewModelBuilder.build(bookingId);
view.templateKey = templateKey;
view.template = getTemplateMeta(templateKey);
const html = this.renderer.render(view);
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
const summary = this.buildContractSummary(booking);
const file: Express.Multer.File = {
fieldname: 'contract',
originalname: `contract-${booking.reference}.pdf`,
encoding: '7bit',
mimetype: 'application/pdf',
size: pdfBuffer.length,
buffer: pdfBuffer,
stream: Readable.from(pdfBuffer),
destination: '',
filename: '',
path: '',
};
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'contract',
file,
});
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
const now = new Date();
const updated = await this.bookingsRepository.update(bookingId, {
@@ -129,18 +105,15 @@ export class BookingContractService {
}
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.',
);
}
const booking = await this.requireBooking(bookingId);
const templateKey =
booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
const record = await this.upsertContractPdf(
bookingId,
booking.reference,
templateKey,
);
return this.filesService.streamById(record.id);
}
async signContract(
@@ -218,33 +191,84 @@ export class BookingContractService {
}
const updated = await this.bookingsRepository.update(bookingId, updates as never);
await this.upsertContractPdf(
bookingId,
booking.reference,
booking.contractTemplateKey ?? this.templateResolver.resolve(booking),
);
return updated!;
}
async getSignatures(bookingId: string) {
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r));
await this.enrichSignatureUrls(views);
await this.inlineSignatureImages(views);
return { signatures: views };
}
private async enrichSignatureUrls(
private async upsertContractPdf(
bookingId: string,
reference: string,
templateKey: string,
): Promise<FileRecord> {
const { view } = await this.viewModelBuilder.build(bookingId);
view.templateKey = templateKey;
view.template = getTemplateMeta(templateKey);
await this.inlineSignatureImages(view.signatures);
const html = this.renderer.render(view);
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
const file: Express.Multer.File = {
fieldname: 'contract',
originalname: `contract-${reference}.pdf`,
encoding: '7bit',
mimetype: 'application/pdf',
size: pdfBuffer.length,
buffer: pdfBuffer,
stream: Readable.from(pdfBuffer),
destination: '',
filename: '',
path: '',
};
return this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'contract',
file,
});
}
private async inlineSignatureImages(
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);
if (sig.signatureImageUrl.startsWith('data:')) continue;
const objectName = this.minioService.getObjectNameFromUrl(
sig.signatureImageUrl,
);
const stream = await this.minioService.getFileStream(objectName);
const buffer = await this.streamToBuffer(stream);
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString(
'base64',
)}`;
} catch {
/* keep original url */
}
}
}
private extractObjectName(url: string): string {
const parts = url.split('/');
return parts.slice(4).join('/');
private streamToBuffer(stream: Readable): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks)));
});
}
private decodeSignatureImage(base64: string): Buffer {

View File

@@ -0,0 +1,54 @@
export const BOOKING_LIST_TAB_KEYS = [
'all',
'intake',
'in_approval',
'approved_contract',
'payment',
'operations',
'completed',
'closed',
] as const;
export type BookingListTabKey = (typeof BOOKING_LIST_TAB_KEYS)[number];
export const BOOKING_LIST_TABS: ReadonlyArray<{
key: BookingListTabKey;
statuses: readonly string[] | null;
}> = [
{ key: 'all', statuses: null },
{ key: 'intake', statuses: ['SUBMITTED'] },
{
key: 'in_approval',
statuses: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
},
{
key: 'approved_contract',
statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
},
{ key: 'payment', statuses: ['FULLY_EXECUTED', 'PAID'] },
{
key: 'operations',
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED'],
},
{ key: 'completed', statuses: ['COMPLETED'] },
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
];
export function mapStatusCountsToTabs(
statusCounts: Record<string, number>,
): Record<BookingListTabKey, number> {
const result = {} as Record<BookingListTabKey, number>;
for (const tab of BOOKING_LIST_TABS) {
if (!tab.statuses?.length) {
result[tab.key] = Object.values(statusCounts).reduce((sum, n) => sum + n, 0);
continue;
}
result[tab.key] = tab.statuses.reduce(
(sum, status) => sum + (statusCounts[status] ?? 0),
0,
);
}
return result;
}

View File

@@ -0,0 +1,68 @@
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { Booking } from './entities/booking.entity';
export interface BookingNextStep {
action: string;
description: string;
requiredRole?: string;
}
export function computeNextStep(
booking: Pick<Booking, 'status' | 'paymentCurrency'>,
nextPendingStep?: Pick<BookingApprovalStep, 'requiredRole' | 'stepOrder'> | null,
): BookingNextStep | null {
const { status } = booking;
switch (status) {
case 'SUBMITTED':
return {
action: 'ACCEPT_INTAKE',
description: 'Line Staff must accept the submission to begin approval',
};
case 'PENDING_APPROVAL':
case 'APPROVED_PENDING_SIGNATURE':
if (nextPendingStep) {
return {
action: 'APPROVE_STEP',
requiredRole: nextPendingStep.requiredRole,
description: `${nextPendingStep.requiredRole} must approve step ${nextPendingStep.stepOrder}`,
};
}
return {
action: 'APPROVE_STEP',
description: 'Complete the pending approval step in sequence',
};
case 'APPROVED':
return {
action: 'GENERATE_CONTRACT',
description: 'Generate the contract document',
};
case 'CONTRACT_READY':
return {
action: 'CUSTOMER_SIGN',
description: 'Customer must sign the contract',
};
case 'SIGNED_CUSTOMER':
return {
action: 'STAFF_SIGN',
description: 'Internal staff must counter-sign the contract',
};
case 'FULLY_EXECUTED':
return {
action: 'PAY',
description: 'Complete in-app payment',
};
case 'PAID':
return {
action: 'START_TRANSIT',
description: 'Mark shipment as in transit',
};
case 'IN_TRANSIT':
return {
action: 'COMPLETE',
description: 'Mark shipment complete',
};
default:
return null;
}
}

View File

@@ -1,127 +1,47 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { FilesService } from '../files/files.service';
import { Injectable, NotFoundException } from '@nestjs/common';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
const PROOF_MAX_BYTES = 5 * 1024 * 1024;
const PROOF_MIMES = ['application/pdf', 'image/jpeg', 'image/png'];
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto {}
@Injectable()
export class BookingPaymentService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
) {}
constructor(private readonly bookingsRepository: BookingsRepository) {}
async generatePnr(bookingId: string): Promise<Booking> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED']);
if (booking.paymentCurrency !== 'ETB') {
throw new BadRequestException('PNR generation is only for ETB payers');
}
const year = new Date().getFullYear();
const pnrCode = `PNR-${year}-${Math.random().toString(36).slice(2, 10).toUpperCase()}`;
const updated = await this.bookingsRepository.update(bookingId, {
status: 'PNR_GENERATED',
pnrCode,
paymentStatus: 'PNR_GENERATED',
} as never);
return updated!;
}
async submitPaymentProof(
async pay(
bookingId: string,
file: Express.Multer.File,
): Promise<Booking> {
): Promise<{ booking: Booking; receipt: InAppPaymentReceipt }> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED']);
if (booking.paymentCurrency !== 'USD') {
throw new BadRequestException('Payment proof upload is only for USD payers');
}
this.validateProofFile(file);
await this.filesService.upload({
resourceId: bookingId,
resource: 'bookings',
code: 'payment_proof',
file,
});
const updated = await this.bookingsRepository.update(bookingId, {
status: 'PAYMENT_VERIFICATION_IN_PROGRESS',
paymentStatus: 'VERIFICATION_IN_PROGRESS',
} as never);
return updated!;
}
async verifyPayment(bookingId: string): Promise<Booking> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['PAYMENT_VERIFICATION_IN_PROGRESS']);
const receipt = this.buildMockReceipt(booking);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'PAID',
paymentStatus: 'PAID',
} as never);
return updated!;
return { booking: updated!, receipt };
}
async handleBankCallback(pnrCode: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByPnrCode(pnrCode);
if (!booking) {
throw new NotFoundException(`No booking found for PNR ${pnrCode}`);
}
private buildMockReceipt(booking: Booking): InAppPaymentReceipt {
const timestamp = Date.now();
const isEtb = booking.paymentCurrency === 'ETB';
const prefix = isEtb ? 'TB' : 'CARD';
const provider = isEtb ? 'TELEBIRR' : 'CARD';
if (booking.status !== 'PNR_GENERATED') {
throw new BadRequestException(
`Booking ${booking.reference} is not awaiting bank payment (status: ${booking.status})`,
);
}
const updated = await this.bookingsRepository.update(booking.id, {
status: 'PAID',
paymentStatus: 'PAID',
} as never);
return updated!;
}
async getPaymentRequestLetter(
bookingId: string,
): Promise<{ buffer: Buffer; filename: string }> {
const booking = await this.requireBooking(bookingId);
const body = [
'PAYMENT REQUEST LETTER (STUB)',
`Reference: ${booking.reference}`,
`Amount: ${booking.totalAmount} ${booking.paymentCurrency}`,
'Pay at your bank and upload stamped proof.',
].join('\n');
return {
buffer: Buffer.from(body, 'utf-8'),
filename: `payment-request-${booking.reference}.txt`,
success: true,
provider,
providerRef: `${prefix}-${booking.reference}-${timestamp}`,
amount: booking.totalAmount,
currency: booking.paymentCurrency,
paidAt: new Date().toISOString(),
};
}
private validateProofFile(file: Express.Multer.File): void {
if (!file?.buffer?.length) {
throw new BadRequestException('Payment proof file is required');
}
if (file.size > PROOF_MAX_BYTES) {
throw new BadRequestException('Payment proof must be 5MB or less');
}
if (!PROOF_MIMES.includes(file.mimetype)) {
throw new BadRequestException('Payment proof must be PDF, JPG, or PNG');
}
}
private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findById(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);

View File

@@ -1,10 +1,13 @@
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
import { Booking } from './entities/booking.entity';
import { BookingsService } from './bookings.service';
@@ -60,6 +63,16 @@ export class BookingTransitionService {
return this.bookingsService.findById(updated!.id);
}
/** Auto-create booking approval steps from system rules when none exist yet. */
private async ensureBookingApprovalSteps(booking: Booking): Promise<void> {
if ((booking.approvalSteps?.length ?? 0) > 0) return;
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId,
});
}
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']);
@@ -103,13 +116,23 @@ export class BookingTransitionService {
stepId: string,
actorId: string,
requiredRole: string,
authUser?: TCurrentUser,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
if (authUser) {
assertCanApproveBookingStep(authUser, requiredRole);
}
let booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
]);
if ((booking.approvalSteps?.length ?? 0) === 0) {
await this.ensureBookingApprovalSteps(booking);
booking = await this.bookingsService.findById(bookingId);
}
const step = await this.bookingsRepository.findApprovalStepById(
bookingId,
stepId,
@@ -131,7 +154,7 @@ export class BookingTransitionService {
);
}
const blocksRole = step.approvalRule?.blocksRole;
const blocksRole = step.blocksRole;
if (blocksRole && blocksRole === requiredRole) {
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
}
@@ -271,6 +294,7 @@ export class BookingTransitionService {
async enrichBookingResponse(booking: Booking): Promise<Booking & {
latestChangeRequestNote?: string | null;
contractSummary?: string | null;
nextStep: BookingNextStep | null;
}> {
const note = await this.bookingsRepository.findLatestReviewNote(
booking.id,
@@ -279,10 +303,17 @@ export class BookingTransitionService {
const summary =
booking.contractSummary ??
this.contractService.buildContractSummary(booking);
const nextPending =
booking.status === 'PENDING_APPROVAL' ||
booking.status === 'APPROVED_PENDING_SIGNATURE'
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
: null;
const nextStep = computeNextStep(booking, nextPending);
return {
...booking,
latestChangeRequestNote: note?.note ?? null,
contractSummary: summary,
nextStep,
};
}
}

View File

@@ -3,7 +3,6 @@ import {
Controller,
Delete,
Get,
Header,
HttpCode,
Param,
ParseUUIDPipe,
@@ -12,13 +11,13 @@ import {
Query,
Request,
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 type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import {
ApiBearerAuth,
@@ -31,13 +30,13 @@ import {
import type { Response } from 'express';
import { BookingContractService } from './booking-contract.service';
import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingTransitionService } from './booking-transition.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingsService } from './bookings.service';
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
import { CreateBookingDto } from './dto/create-booking.dto';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import {
@@ -65,7 +64,6 @@ export class BookingsController {
private readonly pricingService: BookingPricingService,
private readonly transitionService: BookingTransitionService,
private readonly contractService: BookingContractService,
private readonly paymentService: BookingPaymentService,
) {}
@Post()
@@ -104,6 +102,13 @@ export class BookingsController {
return this.bookingsService.findAll(filter);
}
@Get('list-summary')
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
@ApiOkResponse({ type: BookingListSummaryDto })
findListSummary(@Query() filter: FilterBookingDto) {
return this.bookingsService.getListSummary(filter);
}
@Get('queues/:queue')
@ApiOperation({
summary: 'List bookings for a dashboard queue',
@@ -162,7 +167,7 @@ export class BookingsController {
}
@Post(':id/staff/request-changes')
@UseGuards(JwtGuard)
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
@ApiOperation({ summary: 'Staff return booking for customer updates' })
async requestChanges(
@Param('id', ParseUUIDPipe) id: string,
@@ -178,7 +183,7 @@ export class BookingsController {
}
@Post(':id/staff/accept')
@UseGuards(JwtGuard)
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Staff accept intake → start approval chain' })
async acceptIntake(
@Param('id', ParseUUIDPipe) id: string,
@@ -192,7 +197,7 @@ export class BookingsController {
}
@Post(':id/staff/reject')
@UseGuards(JwtGuard)
@BookingStaff(FREIGHT_PERMS.bookings.reject)
@ApiOperation({ summary: 'Staff final reject' })
async staffReject(
@Param('id', ParseUUIDPipe) id: string,
@@ -208,25 +213,30 @@ export class BookingsController {
}
@Post(':id/approval-steps/:stepId/approve')
@UseGuards(JwtGuard)
@BookingStaff([
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.approveDirector,
FREIGHT_PERMS.bookings.approveCeo,
])
@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,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.transitionService.approveStep(
id,
stepId,
resolveAuthUserId(user),
dto.requiredRole,
user,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/approval-steps/:stepId/reject')
@UseGuards(JwtGuard)
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
@ApiOperation({ summary: 'Reject at approval step' })
async rejectStep(
@Param('id', ParseUUIDPipe) id: string,
@@ -244,7 +254,7 @@ export class BookingsController {
}
@Post(':id/contract/generate')
@UseGuards(JwtGuard)
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
@ApiOperation({ summary: 'Generate contract PDF from template' })
async generateContract(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.contractService.generateContract(id);
@@ -262,22 +272,23 @@ export class BookingsController {
@ApiOperation({ summary: 'Download contract PDF' })
async downloadContractDocument(
@Param('id', ParseUUIDPipe) id: string,
@Res({ passthrough: true }) res: Response,
) {
@Res() res: Response,
): Promise<void> {
const { stream, record } = await this.contractService.streamContract(id);
res.set({
'Content-Type': record.mimeType ?? 'application/pdf',
'Content-Disposition': `attachment; filename="${record.name}"`,
});
return new StreamableFile(stream);
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
res.setHeader(
'Content-Disposition',
`attachment; filename="${record.name}"`,
);
stream.pipe(res);
}
@Get(':id/contract')
@ApiOperation({ summary: 'Download contract file (alias)' })
async downloadContract(
@Param('id', ParseUUIDPipe) id: string,
@Res({ passthrough: true }) res: Response,
) {
@Res() res: Response,
): Promise<void> {
return this.downloadContractDocument(id, res);
}
@@ -326,7 +337,7 @@ export class BookingsController {
}
@Post(':id/marketing/approve')
@UseGuards(JwtGuard)
@BookingStaff(FREIGHT_PERMS.bookings.signStaff)
@ApiOperation({
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
})
@@ -347,47 +358,8 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/payment/pnr')
@ApiOperation({ summary: 'Generate PNR code (ETB)' })
async generatePnr(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.paymentService.generatePnr(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/payment/proof')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload USD payment proof' })
async submitPaymentProof(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const file = files?.[0];
const booking = await this.paymentService.submitPaymentProof(id, file);
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/payment/request-letter')
@ApiOperation({ summary: 'Download payment request letter (USD stub)' })
@Header('Content-Type', 'text/plain')
async paymentRequestLetter(
@Param('id', ParseUUIDPipe) id: string,
@Res({ passthrough: true }) res: Response,
) {
const { buffer, filename } =
await this.paymentService.getPaymentRequestLetter(id);
res.set('Content-Disposition', `attachment; filename="${filename}"`);
return new StreamableFile(buffer);
}
@Post(':id/payment/verify')
@ApiOperation({ summary: 'Finance verify USD payment' })
async verifyPayment(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.paymentService.verifyPayment(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operations/start-transit')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark in transit' })
async startTransit(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.transitionService.startTransit(id);
@@ -395,6 +367,7 @@ export class BookingsController {
}
@Post(':id/operations/complete')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark completed' })
async complete(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.transitionService.complete(id);
@@ -402,6 +375,7 @@ export class BookingsController {
}
@Post(':id/cancel')
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
@ApiOperation({ summary: 'Cancel booking' })
async cancel(
@Param('id', ParseUUIDPipe) id: string,

View File

@@ -12,10 +12,10 @@ import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingTransitionService } from './booking-transition.service';
import { BookingsController } from './bookings.controller';
import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { BookingsService } from './bookings.service';
import { PaymentsWebhookController } from './payments-webhook.controller';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingContainer } from './entities/booking-container.entity';
@@ -46,7 +46,7 @@ import { ContractViewModelBuilder } from '../../contracts/contract-view-model.bu
// CustomersModule,
RuleEngineModule,
],
controllers: [BookingsController, PaymentsWebhookController],
controllers: [BookingsController, PayController],
providers: [
BookingsService,
BookingsRepository,

View File

@@ -1,7 +1,7 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, FindOptionsWhere, Repository } from 'typeorm';
import { DataSource, FindOptionsWhere, Repository, SelectQueryBuilder } from 'typeorm';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
@@ -17,6 +17,20 @@ import {
import { FileRecord } from '../files/entities/file.entity';
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
export interface BookingListFilterOptions {
statuses?: string[];
status?: string;
companyId?: string;
contractType?: string;
serviceTypeId?: string;
cargoTypeId?: string;
freightType?: string;
tradeDirection?: string;
paymentCurrency?: string;
allowConsolidation?: boolean;
consolidationPaired?: string;
}
@Injectable()
export class BookingsRepository extends BaseRepository<Booking> {
constructor(
@@ -230,7 +244,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, status: 'PENDING' },
order: { stepOrder: 'ASC' },
relations: ['approvalRule'],
});
}
@@ -240,7 +253,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
): Promise<BookingApprovalStep | null> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, id: stepId },
relations: ['approvalRule'],
});
}
@@ -333,10 +345,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
}
async findByPnrCode(pnrCode: string): Promise<Booking | null> {
return this.repository.findOne({ where: { pnrCode } });
}
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
async findQueue(options: {
status: string | string[];
@@ -353,9 +361,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
const qb = this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
// .leftJoinAndSelect('booking.customer', 'customer')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.cargoType', 'cargo')
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.where('booking.status IN (:...statuses)', { statuses });
if (options.excludeBulk) {
@@ -363,7 +373,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
}
const sortField =
options.sortBy === 'priorityScore' ? 'booking.priority_score' : 'booking.created_at';
options.sortBy === 'priorityScore'
? 'booking.priorityScore'
: 'booking.createdAt';
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
const [items, total] = await qb
@@ -374,6 +386,158 @@ export class BookingsRepository extends BaseRepository<Booking> {
return { items, total };
}
/** Paginated list with optional multi-status filter (API tab queues). */
async findAllPaginated(options: BookingListFilterOptions & {
page: number;
pageSize: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ items: Booking[]; total: number }> {
const page = options.page;
const pageSize = options.pageSize;
const qb = this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.where('booking.deleted_at IS NULL');
this.applyListFilters(qb, options);
const sortField =
options.sortBy === 'priorityScore'
? 'booking.priorityScore'
: 'booking.createdAt';
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
const [items, total] = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { items, total };
}
async getStatusCounts(): Promise<Record<string, number>> {
const rows = await this.repository
.createQueryBuilder('booking')
.select('booking.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.groupBy('booking.status')
.getRawMany<{ status: string; count: string }>();
return Object.fromEntries(
rows.map((row) => [row.status, Number(row.count)]),
);
}
async getListSummaryMetrics(
options: BookingListFilterOptions & {
page: number;
pageSize: number;
needsActionStatuses: readonly string[];
urgentPriorityThreshold: number;
},
): Promise<{
inQueue: number;
onThisPage: number;
needsAction: number;
urgent: number;
}> {
const baseQb = () => {
const qb = this.repository
.createQueryBuilder('booking')
.where('booking.deleted_at IS NULL');
this.applyListFilters(qb, options);
return qb;
};
const inQueue = await baseQb().getCount();
const needsAction = await baseQb()
.andWhere('booking.status IN (:...needsActionStatuses)', {
needsActionStatuses: [...options.needsActionStatuses],
})
.getCount();
const urgent = await baseQb()
.andWhere('booking.priority_score >= :urgentPriorityThreshold', {
urgentPriorityThreshold: options.urgentPriorityThreshold,
})
.getCount();
const offset = (options.page - 1) * options.pageSize;
const onThisPage = Math.min(
options.pageSize,
Math.max(0, inQueue - offset),
);
return { inQueue, onThisPage, needsAction, urgent };
}
private applyListFilters(
qb: SelectQueryBuilder<Booking>,
options: BookingListFilterOptions,
): void {
if (options.statuses?.length) {
qb.andWhere('booking.status IN (:...statuses)', {
statuses: options.statuses,
});
} else if (options.status) {
qb.andWhere('booking.status = :status', { status: options.status });
}
if (options.companyId) {
qb.andWhere('booking.company_id = :companyId', {
companyId: options.companyId,
});
}
if (options.contractType) {
qb.andWhere('booking.contract_type = :contractType', {
contractType: options.contractType,
});
}
if (options.serviceTypeId) {
qb.andWhere('booking.service_type_id = :serviceTypeId', {
serviceTypeId: options.serviceTypeId,
});
}
if (options.cargoTypeId) {
qb.andWhere('booking.cargo_type_id = :cargoTypeId', {
cargoTypeId: options.cargoTypeId,
});
}
if (options.freightType) {
qb.andWhere('booking.freight_type = :freightType', {
freightType: options.freightType,
});
}
if (options.tradeDirection) {
qb.andWhere('booking.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection,
});
}
if (options.paymentCurrency) {
qb.andWhere('booking.payment_currency = :paymentCurrency', {
paymentCurrency: options.paymentCurrency,
});
}
if (options.allowConsolidation !== undefined) {
qb.andWhere('booking.allow_consolidation = :allowConsolidation', {
allowConsolidation: options.allowConsolidation,
});
}
if (options.consolidationPaired === 'true') {
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
} else if (options.consolidationPaired === 'false') {
qb.andWhere('booking.consolidation_partner_id IS NULL');
}
}
async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
skip: number;
take: number;

View File

@@ -4,8 +4,6 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { FilesService } from '../files/files.service';
@@ -19,12 +17,25 @@ 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 { mapStatusCountsToTabs } from './booking-list-tabs.config';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import { CUSTOMER_EDITABLE_STATUSES, FreightType } from './entities/booking.entity';
import {
BOOKING_STATUSES,
CUSTOMER_EDITABLE_STATUSES,
FreightType,
} from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
const URGENT_PRIORITY_THRESHOLD = 1000;
const NEEDS_ACTION_STATUSES = [
'SUBMITTED',
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
] as const;
@Injectable()
export class BookingsService {
constructor(
@@ -379,44 +390,88 @@ export class BookingsService {
return { booking, warnings };
}
/** Parse comma-separated or repeated status query values. */
private parseStatusFilter(filter: FilterBookingDto): {
statuses?: string[];
status?: string;
} {
const allowed = new Set<string>(BOOKING_STATUSES);
const raw = filter.statuses;
const statusList = raw
? raw
.split(',')
.map((s) => s.trim())
.filter((s) => allowed.has(s))
: [];
if (statusList.length > 0) {
return { statuses: statusList };
}
if (filter.status && allowed.has(filter.status)) {
return { status: filter.status };
}
return {};
}
/** Return a paginated list of bookings matching the filter. */
async findAll(
filter: FilterBookingDto,
): Promise<{ items: Booking[]; total: number }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const statusFilter = this.parseStatusFilter(filter);
const where: Record<string, unknown> = {};
if (filter.status) where.status = filter.status;
// if (filter.customerId) where.customerId = filter.customerId;
if (filter.companyId) where.companyId = filter.companyId;
if (filter.contractType) where.contractType = filter.contractType;
if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId;
if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId;
if (filter.freightType) where.freightType = filter.freightType;
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency;
if (filter.allowConsolidation !== undefined) {
where.allowConsolidation = filter.allowConsolidation;
}
if (filter.consolidationPaired === 'true') {
where.consolidationPartnerId = Not(IsNull());
} else if (filter.consolidationPaired === 'false') {
where.consolidationPartnerId = IsNull();
}
const sortField = filter.sortBy ?? 'createdAt';
const sortDir = filter.sortOrder ?? 'DESC';
const [items, total] = await this.bookingsRepository.findAndCount({
where,
skip: (page - 1) * pageSize,
take: pageSize,
order: { [sortField]: sortDir },
relations: ['company', 'originYard', 'destinationYard', 'serviceType'],
// relations: ['customer', 'originYard', 'destinationYard', 'serviceType'],
return this.bookingsRepository.findAllPaginated({
page,
pageSize,
...statusFilter,
companyId: filter.companyId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
return { items, total };
}
/** Aggregate metrics and tab counts for the backoffice booking list. */
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const statusFilter = this.parseStatusFilter(filter);
const listFilter = {
...statusFilter,
companyId: filter.companyId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
};
const [statusCounts, metrics] = await Promise.all([
this.bookingsRepository.getStatusCounts(),
this.bookingsRepository.getListSummaryMetrics({
...listFilter,
page,
pageSize,
needsActionStatuses: NEEDS_ACTION_STATUSES,
urgentPriorityThreshold: URGENT_PRIORITY_THRESHOLD,
}),
]);
return {
metrics,
tabs: mapStatusCountsToTabs(statusCounts),
};
}
/** Get a single booking by ID with files. */
@@ -429,7 +484,7 @@ export class BookingsService {
if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => {
const objectName = this.extractObjectName(file.url);
const objectName = this.minioService.getObjectNameFromUrl(file.url);
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
return { ...file, signedUrl };
}),
@@ -439,11 +494,6 @@ export class BookingsService {
return booking;
}
private extractObjectName(url: string): string {
const parts = url.split('/');
return parts.slice(4).join('/');
}
async findByReference(reference: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByReferenceWithFiles(reference);
if (!booking) {
@@ -467,10 +517,11 @@ export class BookingsService {
): Promise<{ items: Booking[]; total: number }> {
const statusMap: Record<string, string | string[]> = {
intake: 'SUBMITTED',
approval: 'PENDING_APPROVAL',
approval: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'],
contract: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
marketing: 'SIGNED_CUSTOMER',
finance: 'PAYMENT_VERIFICATION_IN_PROGRESS',
finance: 'FULLY_EXECUTED',
};
const status = statusMap[queue];

View File

@@ -0,0 +1,34 @@
import { ApiProperty } from '@nestjs/swagger';
export class BookingListSummaryMetricsDto {
@ApiProperty({ example: 42 })
inQueue!: number;
@ApiProperty({ example: 10 })
onThisPage!: number;
@ApiProperty({ example: 8 })
needsAction!: number;
@ApiProperty({ example: 3 })
urgent!: number;
}
export class BookingListSummaryTabsDto {
@ApiProperty() all!: number;
@ApiProperty() intake!: number;
@ApiProperty() in_approval!: number;
@ApiProperty() approved_contract!: number;
@ApiProperty() payment!: number;
@ApiProperty() operations!: number;
@ApiProperty() completed!: number;
@ApiProperty() closed!: number;
}
export class BookingListSummaryDto {
@ApiProperty({ type: BookingListSummaryMetricsDto })
metrics!: BookingListSummaryMetricsDto;
@ApiProperty({ type: BookingListSummaryTabsDto })
tabs!: BookingListSummaryTabsDto;
}

View File

@@ -14,6 +14,18 @@ export class FilterBookingDto {
@IsIn([...BOOKING_STATUSES])
status?: string;
@ApiPropertyOptional({
description:
'Filter by statuses: comma-separated (PENDING_APPROVAL,APPROVED) or repeated query params. Overrides status when set.',
})
@IsOptional()
@Transform(({ value }) => {
if (value === undefined || value === null || value === '') return undefined;
if (Array.isArray(value)) return value.map(String).join(',');
return String(value);
})
statuses?: string;
// @ApiPropertyOptional({ format: 'uuid' })
// @IsOptional()
// @IsUUID()

View File

@@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
export class InAppPaymentReceiptDto {
@ApiProperty({ example: true })
success!: boolean;
@ApiProperty({ example: 'TELEBIRR' })
provider!: string;
@ApiProperty({ example: 'TB-BK-2026-000123-1717584000000' })
providerRef!: string;
@ApiProperty({ example: 15000 })
amount!: number;
@ApiProperty({ example: 'ETB' })
currency!: string;
@ApiProperty({ example: '2026-06-05T12:00:00.000Z' })
paidAt!: string;
}
export class PayBookingResponseDto {
@ApiProperty({ type: InAppPaymentReceiptDto })
paymentReceipt!: InAppPaymentReceiptDto;
}

View File

@@ -34,9 +34,3 @@ export class CancelBookingDto {
@MinLength(1)
reason!: string;
}
export class BankCallbackDto {
@ApiProperty()
@IsString()
pnrCode!: string;
}

View File

@@ -31,6 +31,9 @@ export class BookingApprovalStep extends BaseEntity {
@Column({ name: 'required_role', type: 'varchar', length: 30 })
requiredRole!: string;
@Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true })
blocksRole?: string | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
status!: ApprovalStepStatus;

View File

@@ -0,0 +1,34 @@
import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingPaymentService } from './booking-payment.service';
import { BookingTransitionService } from './booking-transition.service';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
import { Booking } from './entities/booking.entity';
import { BookingNextStep } from './booking-next-step.util';
@ApiTags('payments')
@ApiBearerAuth()
@Controller('bookings')
export class PayController {
constructor(
private readonly paymentService: BookingPaymentService,
private readonly transitionService: BookingTransitionService,
) {}
@Post(':id/payment/pay')
@ApiOperation({ summary: 'Complete in-app payment (mock)' })
@ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' })
async pay(@Param('id', ParseUUIDPipe) id: string): Promise<
Booking & {
latestChangeRequestNote?: string | null;
contractSummary?: string | null;
nextStep: BookingNextStep | null;
paymentReceipt: InAppPaymentReceiptDto;
}
> {
const { booking, receipt } = await this.paymentService.pay(id);
const abstract = await this.transitionService.enrichBookingResponse(booking);
return { ...abstract, paymentReceipt: receipt };
}
}

View File

@@ -1,17 +0,0 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingPaymentService } from './booking-payment.service';
import { BankCallbackDto } from './dto/request-changes.dto';
@ApiTags('payments')
@Controller('webhooks/payments')
export class PaymentsWebhookController {
constructor(private readonly paymentService: BookingPaymentService) {}
@Post('bank')
@ApiOperation({ summary: 'Bank payment callback (stub)' })
bankCallback(@Body() dto: BankCallbackDto) {
return this.paymentService.handleBankCallback(dto.pnrCode);
}
}

View File

@@ -79,13 +79,8 @@ export class FilesService {
async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> {
const record = await this.findById(id);
const objectName = this.extractObjectName(record.url);
const objectName = this.minioService.getObjectNameFromUrl(record.url);
const stream = await this.minioService.getFileStream(objectName);
return { stream, record };
}
private extractObjectName(url: string): string {
const parts = url.split("/");
return parts.slice(4).join("/");
}
}

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable, Logger } from "@nestjs/common";
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { ConfigType } from "@nestjs/config";
import { Client } from "minio";
import { Readable } from "stream";
@@ -54,6 +54,30 @@ export class MinioService {
return `${protocol}://${this.config.endPoint}:${this.config.port}/${this.bucket}/${objectName}`;
}
getObjectNameFromUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
throw new NotFoundException("File object path is empty");
}
if (!/^https?:\/\//i.test(trimmed)) {
return trimmed.replace(/^\/+/, "");
}
const url = new URL(trimmed);
const parts = url.pathname.split("/").filter(Boolean);
if (parts[0] === this.bucket) {
parts.shift();
}
const objectName = parts.join("/");
if (!objectName) {
throw new NotFoundException("File object path is empty");
}
return objectName;
}
async deleteFile(objectName: string): Promise<void> {
try {
await this.client.removeObject(this.bucket, objectName);

View File

@@ -0,0 +1,31 @@
/** ITMLS US-06 default approval chains — seeded automatically when missing. */
export const DEFAULT_APPROVAL_RULE_ROWS = [
{
requiresDirectorApproval: false,
stepOrder: 1,
requiredRole: 'LINE_STAFF',
actionLabel: 'Review & Approve',
blocksRole: null as string | null,
},
{
requiresDirectorApproval: false,
stepOrder: 2,
requiredRole: 'DIRECTOR',
actionLabel: 'Final Signature',
blocksRole: 'LINE_STAFF',
},
{
requiresDirectorApproval: true,
stepOrder: 1,
requiredRole: 'DIRECTOR',
actionLabel: 'Review & Approve',
blocksRole: 'LINE_STAFF',
},
{
requiresDirectorApproval: true,
stepOrder: 2,
requiredRole: 'CEO',
actionLabel: 'Final Signature',
blocksRole: null as string | null,
},
] as const;

View File

@@ -2,6 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
@@ -9,12 +10,12 @@ import { ApprovalRulesService } from '../services/approval-rules.service';
@ApiTags('approval-rules')
@Controller('approval-rules')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class ApprovalRulesController {
constructor(private readonly service: ApprovalRulesService) {}
@Get()
@RuleEngineView('approval-rules')
@ApiOperation({ summary: 'List approval rules' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -28,30 +29,35 @@ export class ApprovalRulesController {
}
@Get('chain')
@RuleEngineView('approval-rules')
@ApiOperation({ summary: 'Get approval chain for cargo routing flag' })
findChain(@Query('requiresDirectorApproval') flag: string) {
return this.service.findChain(flag === 'true');
}
@Get(':id')
@RuleEngineView('approval-rules')
@ApiOperation({ summary: 'Get an approval rule by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('approval-rules')
@ApiOperation({ summary: 'Create an approval rule step' })
create(@Body() dto: CreateApprovalRuleDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('approval-rules')
@ApiOperation({ summary: 'Update an approval rule' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('approval-rules')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete an approval rule' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -3,18 +3,19 @@ import {
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
import { CargoTypesService } from '../services/cargo-types.service';
@ApiTags('cargo-types')
@Controller('cargo-types')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class CargoTypesController {
constructor(private readonly service: CargoTypesService) {}
@Get()
@RuleEngineView('cargo-types')
@ApiOperation({ summary: 'List cargo types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -32,24 +33,28 @@ export class CargoTypesController {
}
@Get(':id')
@RuleEngineView('cargo-types')
@ApiOperation({ summary: 'Get a cargo type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('cargo-types')
@ApiOperation({ summary: 'Create a cargo type' })
create(@Body() dto: CreateCargoTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('cargo-types')
@ApiOperation({ summary: 'Update a cargo type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('cargo-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a cargo type' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -3,18 +3,19 @@ import {
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
import { ContainerTypesService } from '../services/container-types.service';
@ApiTags('container-types')
@Controller('container-types')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class ContainerTypesController {
constructor(private readonly service: ContainerTypesService) {}
@Get()
@RuleEngineView('container-types')
@ApiOperation({ summary: 'List container types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -25,24 +26,28 @@ export class ContainerTypesController {
}
@Get(':id')
@RuleEngineView('container-types')
@ApiOperation({ summary: 'Get a container type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('container-types')
@ApiOperation({ summary: 'Create a container type' })
create(@Body() dto: CreateContainerTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('container-types')
@ApiOperation({ summary: 'Update a container type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('container-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a container type' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -2,6 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto';
import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto';
@@ -9,12 +10,12 @@ import { PriorityRulesService } from '../services/priority-rules.service';
@ApiTags('priority-rules')
@Controller('priority-rules')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class PriorityRulesController {
constructor(private readonly service: PriorityRulesService) {}
@Get()
@RuleEngineView('priority-rules')
@ApiOperation({ summary: 'List priority rules' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -25,24 +26,28 @@ export class PriorityRulesController {
}
@Get(':id')
@RuleEngineView('priority-rules')
@ApiOperation({ summary: 'Get a priority rule by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('priority-rules')
@ApiOperation({ summary: 'Create a priority rule' })
create(@Body() dto: CreatePriorityRuleDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('priority-rules')
@ApiOperation({ summary: 'Update a priority rule' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityRuleDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('priority-rules')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a priority rule' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -1,10 +1,10 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query, UseGuards,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateRateDto } from '../dto/create-rate.dto';
import {
type AuthUserPayload,
@@ -15,12 +15,12 @@ import { RatesService } from '../services/rates.service';
@ApiTags('rates')
@Controller('rates')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class RatesController {
constructor(private readonly service: RatesService) {}
@Get()
@RuleEngineView('rates')
@ApiOperation({ summary: 'List rates' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -32,19 +32,21 @@ export class RatesController {
}
@Get('live')
@RuleEngineView('rates')
@ApiOperation({ summary: 'List all LIVE rates effective now' })
findLive() {
return this.service.findLiveRates();
}
@Get(':id')
@RuleEngineView('rates')
@ApiOperation({ summary: 'Get a rate by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@UseGuards(JwtGuard)
@RuleEngineManage('rates')
@ApiOperation({ summary: 'Create a rate (DRAFT)' })
create(
@Body() dto: CreateRateDto,
@@ -54,19 +56,21 @@ export class RatesController {
}
@Patch(':id')
@RuleEngineManage('rates')
@ApiOperation({ summary: 'Update a DRAFT rate' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRateDto) {
return this.service.update(id, dto);
}
@Post(':id/submit')
@RuleEngineManage('rates')
@ApiOperation({ summary: 'Submit rate for CEO approval' })
submit(@Param('id', ParseUUIDPipe) id: string) {
return this.service.submitForApproval(id);
}
@Post(':id/approve')
@UseGuards(JwtGuard)
@RuleEngineManage('rates')
@ApiOperation({ summary: 'CEO approves a rate' })
approve(
@Param('id', ParseUUIDPipe) id: string,
@@ -76,6 +80,7 @@ export class RatesController {
}
@Delete(':id')
@RuleEngineManage('rates')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a rate' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -2,6 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
@@ -9,12 +10,12 @@ import { ServiceTypesService } from '../services/service-types.service';
@ApiTags('service-types')
@Controller('service-types')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class ServiceTypesController {
constructor(private readonly service: ServiceTypesService) {}
@Get()
@RuleEngineView('service-types')
@ApiOperation({ summary: 'List service types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -29,24 +30,28 @@ export class ServiceTypesController {
}
@Get(':id')
@RuleEngineView('service-types')
@ApiOperation({ summary: 'Get a service type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('service-types')
@ApiOperation({ summary: 'Create a service type' })
create(@Body() dto: CreateServiceTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('service-types')
@ApiOperation({ summary: 'Update a service type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateServiceTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('service-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a service type' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -2,6 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto';
@@ -9,12 +10,12 @@ import { ShippingLinesService } from '../services/shipping-lines.service';
@ApiTags('shipping-lines')
@Controller('shipping-lines')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class ShippingLinesController {
constructor(private readonly service: ShippingLinesService) {}
@Get()
@RuleEngineView('shipping-lines')
@ApiOperation({ summary: 'List shipping lines' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -25,24 +26,28 @@ export class ShippingLinesController {
}
@Get(':id')
@RuleEngineView('shipping-lines')
@ApiOperation({ summary: 'Get a shipping line by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('shipping-lines')
@ApiOperation({ summary: 'Create a shipping line' })
create(@Body() dto: CreateShippingLineDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('shipping-lines')
@ApiOperation({ summary: 'Update a shipping line' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateShippingLineDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('shipping-lines')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a shipping line' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -2,6 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto';
import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto';
@@ -9,12 +10,12 @@ import { SurchargeTypesService } from '../services/surcharge-types.service';
@ApiTags('surcharge-types')
@Controller('surcharge-types')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class SurchargeTypesController {
constructor(private readonly service: SurchargeTypesService) {}
@Get()
@RuleEngineView('surcharge-types')
@ApiOperation({ summary: 'List surcharge types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -25,24 +26,28 @@ export class SurchargeTypesController {
}
@Get(':id')
@RuleEngineView('surcharge-types')
@ApiOperation({ summary: 'Get a surcharge type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('surcharge-types')
@ApiOperation({ summary: 'Create a surcharge type' })
create(@Body() dto: CreateSurchargeTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('surcharge-types')
@ApiOperation({ summary: 'Update a surcharge type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('surcharge-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a surcharge type' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -2,6 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
@@ -9,12 +10,12 @@ import { WeightLimitRulesService } from '../services/weight-limit-rules.service'
@ApiTags('weight-limit-rules')
@Controller('weight-limit-rules')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class WeightLimitRulesController {
constructor(private readonly service: WeightLimitRulesService) {}
@Get()
@RuleEngineView('weight-limit-rules')
@ApiOperation({ summary: 'List weight limit rules' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -26,24 +27,28 @@ export class WeightLimitRulesController {
}
@Get(':id')
@RuleEngineView('weight-limit-rules')
@ApiOperation({ summary: 'Get a weight limit rule by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('weight-limit-rules')
@ApiOperation({ summary: 'Create a weight limit rule' })
create(@Body() dto: CreateWeightLimitRuleDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('weight-limit-rules')
@ApiOperation({ summary: 'Update a weight limit rule' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWeightLimitRuleDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('weight-limit-rules')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a weight limit rule' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -2,6 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateYardDto } from '../dto/create-yard.dto';
import { UpdateYardDto } from '../dto/update-yard.dto';
@@ -9,12 +10,12 @@ import { YardsService } from '../services/yards.service';
@ApiTags('yards')
@Controller('yards')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class YardsController {
constructor(private readonly service: YardsService) {}
@Get()
@RuleEngineView('yards')
@ApiOperation({ summary: 'List yards' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -26,24 +27,28 @@ export class YardsController {
}
@Get(':id')
@RuleEngineView('yards')
@ApiOperation({ summary: 'Get a yard by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('yards')
@ApiOperation({ summary: 'Create a yard' })
create(@Body() dto: CreateYardDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('yards')
@ApiOperation({ summary: 'Update a yard' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('yards')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a yard' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -35,6 +35,7 @@ import {
IShippingLinesRepository,
SHIPPING_LINES_REPOSITORY,
} from './interfaces/shipping-lines.repository.interface';
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
export interface BookingContainerEvalInput {
containerTypeId: string;
@@ -238,6 +239,29 @@ export class RuleEngineService {
};
}
/**
* Ensure ITMLS default approval chains exist (container + bulk). Idempotent.
*/
async ensureDefaultApprovalRules(): Promise<void> {
for (const flag of [false, true] as const) {
const existing = await this.approvalRulesRepo.findChainForCargo(flag);
if (existing.length > 0) continue;
const rows = DEFAULT_APPROVAL_RULE_ROWS.filter(
(r) => r.requiresDirectorApproval === flag,
);
for (const row of rows) {
await this.approvalRulesRepo.create({
requiresDirectorApproval: row.requiresDirectorApproval,
stepOrder: row.stepOrder,
requiredRole: row.requiredRole,
actionLabel: row.actionLabel,
blocksRole: row.blocksRole,
});
}
}
}
/**
* Instantiate booking_approval_step rows from approval_rules by freight type.
*/
@@ -248,6 +272,8 @@ export class RuleEngineService {
cargoTypeId?: string | null;
},
): Promise<BookingApprovalStep[]> {
await this.ensureDefaultApprovalRules();
let requiresDirectorApproval = options.freightType === 'BULK';
if (options.cargoTypeId) {
@@ -264,6 +290,12 @@ export class RuleEngineService {
requiresDirectorApproval,
);
if (chain.length === 0) {
throw new BadRequestException(
`Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
);
}
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
const steps: BookingApprovalStep[] = [];
@@ -273,6 +305,7 @@ export class RuleEngineService {
approvalRuleId: rule.id,
stepOrder: rule.stepOrder,
requiredRole: rule.requiredRole,
blocksRole: rule.blocksRole ?? null,
status: 'PENDING',
});
steps.push(await stepRepo.save(step));

View File

@@ -1,3 +1,9 @@
import {
BOOKING_RULE_ENGINE_PERMISSIONS,
BOOKING_RULE_ENGINE_PERMISSION_KEYS,
ROLE_PERMISSION_PRESETS,
} from './freight-permissions.registry';
export type FreightSeedRole = {
key: string;
name: { en: string };
@@ -183,8 +189,11 @@ export const EDR_FREIGHT_PERMISSIONS = [
...HIERARCHY_POSITION_PERMISSIONS,
...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS,
...POSITION_TYPE_PERMISSIONS,
...BOOKING_RULE_ENGINE_PERMISSIONS,
];
export { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from './freight-permissions.registry';
export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
{
key: "edr_employee",
@@ -198,11 +207,42 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
"edr_freight_app:position_types:view",
],
},
{
key: "edr_line_staff",
name: { en: "EDR Line Staff" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff],
},
{
key: "edr_director",
name: { en: "EDR Director" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.director],
},
{
key: "edr_ceo",
name: { en: "EDR CEO" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.ceo],
},
{
key: "edr_finance",
name: { en: "EDR Finance" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.finance],
},
{
key: "edr_marketing",
name: { en: "EDR Marketing" },
permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing],
},
{
key: "edr_org_manager",
name: { en: "EDR Org Manager" },
permissionKeys: [
...EDR_FREIGHT_PERMISSIONS.map((permission) => permission.key),
...BOOKING_RULE_ENGINE_PERMISSION_KEYS,
...EMPLOYEE_REGISTRATION_PERMISSIONS.map((p) => p.key),
...ROLE_ASSIGNMENT_PERMISSIONS.map((p) => p.key),
...HIERARCHY_UNIT_PERMISSIONS.map((p) => p.key),
...HIERARCHY_POSITION_PERMISSIONS.map((p) => p.key),
...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS.map((p) => p.key),
...POSITION_TYPE_PERMISSIONS.map((p) => p.key),
IAM_PERMISSION_KEYS.createEmployee,
IAM_PERMISSION_KEYS.deactivateEmployee,
IAM_PERMISSION_KEYS.activateEmployee,

View File

@@ -8,6 +8,8 @@ import {
} from "@tria-plc/iamapi-common";
import { DataSource, EntityManager, In } from "typeorm";
import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum";
import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry";
import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed";
const EDR_ORG_KEY = "edr_freight";
@@ -37,6 +39,7 @@ export class EdrOrgSeeder {
await this.ensureOrganizationConfiguration(manager, organization.id);
await this.ensureRoles(manager, EDR_FREIGHT_ROLES);
await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES);
await this.ensureSuperAdminPermissions(manager);
});
this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`);
@@ -166,4 +169,40 @@ export class EdrOrgSeeder {
this.logger.log(`Ensured ${rolePermissions.length} EDR role-permission links`);
}
private async ensureSuperAdminPermissions(manager: EntityManager) {
const role = await manager.getRepository(Role).findOne({
where: { key: ERoleKey.SUPER_ADMIN },
select: { id: true, key: true },
});
if (!role) {
this.logger.warn(
`Role ${ERoleKey.SUPER_ADMIN} not found; skipping booking/rule-engine super_admin links`,
);
return;
}
const permissions = await manager.getRepository(Permission).find({
where: { key: In(BOOKING_RULE_ENGINE_PERMISSION_KEYS) },
select: { id: true, key: true },
});
if (!permissions.length) {
this.logger.warn('No booking/rule-engine permissions found for super_admin');
return;
}
await manager.getRepository(RolePermission).upsert(
permissions.map((permission) => ({
roleId: role.id,
permissionId: permission.id,
})),
{ conflictPaths: { roleId: true, permissionId: true } },
);
this.logger.log(
`Ensured ${permissions.length} booking+rule-engine permissions on super_admin`,
);
}
}

View File

@@ -0,0 +1,152 @@
const EDR_FREIGHT_APP_KEY = 'edr_freight_app';
export type FreightPermissionSeed = {
id: string;
key: string;
name: { am: string; en: string };
applicationKey: string;
};
export const RULE_ENGINE_RESOURCE_SLUGS = [
'cargo-types',
'container-types',
'service-types',
'yards',
'shipping-lines',
'weight-limit-rules',
'surcharge-types',
'priority-rules',
'rates',
'approval-rules',
] as const;
export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number];
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
slug.replace(/-/g, '_');
const perm = (
id: string,
key: string,
en: string,
): FreightPermissionSeed => ({
id,
key,
name: { am: en, en },
applicationKey: EDR_FREIGHT_APP_KEY,
});
export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
perm('a1000001-0001-4000-8000-000000000001', 'edr_freight_app:bookings:view', 'View bookings'),
perm('a1000001-0001-4000-8000-000000000002', 'edr_freight_app:bookings:staff_accept', 'Accept booking intake'),
perm('a1000001-0001-4000-8000-000000000003', 'edr_freight_app:bookings:request_changes', 'Request booking changes'),
perm('a1000001-0001-4000-8000-000000000004', 'edr_freight_app:bookings:reject', 'Reject booking submission'),
perm('a1000001-0001-4000-8000-000000000005', 'edr_freight_app:bookings:approve_line_staff', 'Approve as line staff'),
perm('a1000001-0001-4000-8000-000000000006', 'edr_freight_app:bookings:approve_director', 'Approve as director'),
perm('a1000001-0001-4000-8000-000000000007', 'edr_freight_app:bookings:approve_ceo', 'Approve as CEO'),
perm('a1000001-0001-4000-8000-000000000008', 'edr_freight_app:bookings:reject_approval', 'Reject at approval step'),
perm('a1000001-0001-4000-8000-000000000009', 'edr_freight_app:bookings:generate_contract', 'Generate contract'),
perm('a1000001-0001-4000-8000-00000000000a', 'edr_freight_app:bookings:sign_staff', 'Staff contract signature'),
perm('a1000001-0001-4000-8000-00000000000b', 'edr_freight_app:bookings:payment_pnr', 'Generate PNR'),
perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'),
perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'),
perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'),
];
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' },
'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' },
'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' },
yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' },
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },
'weight-limit-rules': { view: 'b2000001-0001-4000-8000-00000000000b', manage: 'b2000001-0001-4000-8000-00000000000c' },
'surcharge-types': { view: 'b2000001-0001-4000-8000-00000000000d', manage: 'b2000001-0001-4000-8000-00000000000e' },
'priority-rules': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' },
rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' },
'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' },
};
export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap(
(slug) => {
const resource = slugToResourceKey(slug);
const ids = RULE_ENGINE_PERMISSION_IDS[slug];
return [
perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`),
perm(ids.manage, `edr_freight_app:rule_engine:${resource}:manage`, `Manage ${slug}`),
];
},
);
export const BOOKING_RULE_ENGINE_PERMISSIONS = [
...BOOKING_PERMISSIONS,
...RULE_ENGINE_PERMISSIONS,
];
export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map(
(p) => p.key,
);
export const FREIGHT_PERMS = {
bookings: {
view: 'edr_freight_app:bookings:view',
staffAccept: 'edr_freight_app:bookings:staff_accept',
requestChanges: 'edr_freight_app:bookings:request_changes',
reject: 'edr_freight_app:bookings:reject',
approveLineStaff: 'edr_freight_app:bookings:approve_line_staff',
approveDirector: 'edr_freight_app:bookings:approve_director',
approveCeo: 'edr_freight_app:bookings:approve_ceo',
rejectApproval: 'edr_freight_app:bookings:reject_approval',
generateContract: 'edr_freight_app:bookings:generate_contract',
signStaff: 'edr_freight_app:bookings:sign_staff',
operations: 'edr_freight_app:bookings:operations',
cancel: 'edr_freight_app:bookings:cancel',
},
ruleEngine: {
view: (slug: RuleEngineResourceSlug) =>
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`,
manage: (slug: RuleEngineResourceSlug) =>
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`,
},
} as const;
const allRuleEngineViewKeys = () =>
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
export const ROLE_PERMISSION_PRESETS = {
lineStaff: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.staffAccept,
FREIGHT_PERMS.bookings.requestChanges,
FREIGHT_PERMS.bookings.reject,
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.rejectApproval,
FREIGHT_PERMS.bookings.cancel,
...allRuleEngineViewKeys(),
],
director: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.approveDirector,
FREIGHT_PERMS.bookings.rejectApproval,
FREIGHT_PERMS.bookings.generateContract,
...allRuleEngineViewKeys(),
],
ceo: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.approveCeo,
FREIGHT_PERMS.bookings.rejectApproval,
...allRuleEngineViewKeys(),
],
finance: [FREIGHT_PERMS.bookings.view],
marketing: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.generateContract,
FREIGHT_PERMS.bookings.signStaff,
],
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
} as const;
export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({
key: p.key,
label: p.name.en,
module: p.key.includes(':bookings:') ? 'bookings' : 'rule_engine',
}));

View File

@@ -0,0 +1,127 @@
import { Injectable, Logger } from '@nestjs/common';
import { hashPassword } from '@tria-plc/api-common/utils/argon';
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
import {
Employee,
Organization,
Role,
User,
UserCredential,
UserRole,
} from '@tria-plc/iamapi-common';
import { DataSource } from 'typeorm';
const SEED_FLAG = 'SEED_FREIGHT_STAFF';
const EDR_ORG_KEY = 'edr_freight';
const STAFF_USERS = [
{ email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' },
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
] as const;
@Injectable()
export class FreightStaffUsersSeeder {
private readonly logger = new Logger(FreightStaffUsersSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run() {
if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') {
this.logger.log(`Skipping freight staff seed because ${SEED_FLAG} is not enabled`);
return;
}
const password =
process.env.DEFAULT_PASSWORD?.trim() || '12345678';
await this.dataSource.transaction(async (manager) => {
const organization = await manager.getRepository(Organization).findOne({
where: { key: EDR_ORG_KEY },
select: { id: true, key: true },
});
if (!organization) {
throw new Error(`missing_organization:${EDR_ORG_KEY}`);
}
const roleRepository = manager.getRepository(Role);
const userRepository = manager.getRepository(User);
const userCredentialRepository = manager.getRepository(UserCredential);
const userRoleRepository = manager.getRepository(UserRole);
const employeeRepository = manager.getRepository(Employee);
const hashedPassword = await hashPassword(password);
for (const staff of STAFF_USERS) {
const role = await roleRepository.findOne({
where: { key: staff.roleKey },
select: { id: true, key: true },
});
if (!role) {
throw new Error(`missing_role:${staff.roleKey}`);
}
let user = await userRepository.findOne({
where: { email: staff.email },
select: { id: true, email: true },
});
if (!user) {
user = await userRepository.save(
userRepository.create({
email: staff.email,
username: staff.username,
name: { en: staff.username },
isActive: true,
hasSetPassword: true,
status: EUserStatus.ACCEPTED,
}),
);
this.logger.log(`Seeded freight staff user ${staff.email}`);
}
const activeCredentialExists = await userCredentialRepository.exists({
where: { userId: user.id, isActive: true },
});
if (!activeCredentialExists) {
await userCredentialRepository.insert({
userId: user.id,
password: hashedPassword,
isActive: true,
});
}
await userRoleRepository.upsert(
{
userId: user.id,
roleId: role.id,
organizationId: organization.id,
},
{ conflictPaths: { userId: true, roleId: true } },
);
const employeeExists = await employeeRepository.exists({
where: {
userId: user.id,
organizationId: organization.id,
isCurrent: true,
},
});
if (!employeeExists) {
await employeeRepository.insert({
userId: user.id,
organizationId: organization.id,
isCurrent: true,
name: { en: staff.username },
});
}
}
});
this.logger.log('Ensured freight staff users (linestaff@, director@, ceo@)');
}
}

View File

@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import {
Boxes,
@@ -12,6 +13,11 @@ import {
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
import LoadingScreen from "./components/LoadingScreen";
import { useAuth } from "./auth/useAuth";
import {
canAccessBookings,
canAccessRuleEngineResource,
hasPermission,
} from "@/lib/permissions";
import LoginPage from "./pages/auth/LoginPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
@@ -19,7 +25,6 @@ import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
import OverviewPage from "./pages/dashboard/OverviewPage";
import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
import RolesPage from "./pages/dashboard/user-management/RolesPage";
@@ -29,102 +34,114 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import {
getCategorySidebarChildren,
RULE_ENGINE_RESOURCES,
type RuleEngineNavCategory,
} from "./pages/ruleEngine/config/resources";
import type { RuleEngineResourceSlug } from "./types/rule-engine";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Main menu",
mutedTitle: true,
items: [
{
label: "Overview",
href: "/dashboard/overview",
icon: <LayoutDashboard />,
},
{
label: "Booking requests",
href: "/dashboard/booking-requests",
icon: <FileText />,
},
...demoItems,
],
},
{
title: "Administration",
items: [
{
label: "User management",
href: "/dashboard/user-management",
icon: <Network />,
children: [
{
label: "Users",
href: "/dashboard/user-management/users",
},
{
label: "Employees",
href: "/dashboard/user-management/employees",
},
{
label: "Position Types",
href: "/dashboard/user-management/position-types",
},
{
label: "Permissions",
href: "/dashboard/user-management/permissions",
},
{
label: "Roles",
href: "/dashboard/user-management/roles",
},
],
},
{
label: "File settings",
href: "/dashboard/file-settings",
icon: <Paperclip />,
},
{
label: "Dropdown settings",
href: "/dashboard/dropdown-settings",
icon: <Settings />,
},
],
},
{
title: "Freight configuration",
mutedTitle: true,
items: [
{
label: "Configuration",
href: "/dashboard/configuration",
icon: <Boxes />,
children: getCategorySidebarChildren("configuration"),
},
{
label: "Rules",
href: "/dashboard/rules",
icon: <SlidersHorizontal />,
children: getCategorySidebarChildren("rules"),
},
],
},
];
const hasPermission = (
const filterRuleEngineChildren = (
user: ReturnType<typeof useAuth>["user"],
key: string,
) => {
if (!user) return false;
if (user.permissions?.some((p) => p.key === key)) return true;
category: RuleEngineNavCategory,
): SidebarItem[] =>
getCategorySidebarChildren(category).filter((item) => {
const slug = item.href.split("/").pop() as RuleEngineResourceSlug;
return canAccessRuleEngineResource(user, slug, "view");
});
return (user.employee ?? []).some((emp) =>
(emp.positions ?? []).some((pos) =>
(pos.permissions ?? []).some((p) => p.key === key),
),
);
const buildSidebarSections = (
user: ReturnType<typeof useAuth>["user"],
demoItems: SidebarItem[],
): SidebarSection[] => {
const configurationChildren = filterRuleEngineChildren(user, "configuration");
const rulesChildren = filterRuleEngineChildren(user, "rules");
const mainItems: SidebarItem[] = [
{
label: "Overview",
href: "/dashboard/overview",
icon: <LayoutDashboard />,
},
...(canAccessBookings(user)
? [
{
label: "Booking requests",
href: "/dashboard/booking-requests",
icon: <FileText />,
},
]
: []),
...demoItems,
];
const freightConfigItems: SidebarItem[] = [];
if (configurationChildren.length) {
freightConfigItems.push({
label: "Configuration",
href: "/dashboard/configuration",
icon: <Boxes />,
children: configurationChildren,
});
}
if (rulesChildren.length) {
freightConfigItems.push({
label: "Rules",
href: "/dashboard/rules",
icon: <SlidersHorizontal />,
children: rulesChildren,
});
}
const sections: SidebarSection[] = [
{ title: "Main menu", mutedTitle: true, items: mainItems },
{
title: "Administration",
items: [
{
label: "User management",
href: "/dashboard/user-management",
icon: <Network />,
children: [
{ label: "Users", href: "/dashboard/user-management/users" },
{ label: "Position Types", href: "/dashboard/user-management/position-types" },
{ label: "Permissions", href: "/dashboard/user-management/permissions" },
{ label: "Roles", href: "/dashboard/user-management/roles" },
],
},
{
label: "File settings",
href: "/dashboard/file-settings",
icon: <Paperclip />,
},
{
label: "Dropdown settings",
href: "/dashboard/dropdown-settings",
icon: <Settings />,
},
],
},
];
if (freightConfigItems.length) {
sections.push({
title: "Freight configuration",
mutedTitle: true,
items: freightConfigItems,
});
}
return sections;
};
const PermissionRoute = ({
allow,
children,
}: {
allow: boolean;
children: ReactNode;
}) => (allow ? children : <Navigate to="/dashboard/overview" replace />);
const DashboardShell = () => {
const navigate = useNavigate();
const location = useLocation();
@@ -132,26 +149,14 @@ const DashboardShell = () => {
const demoItems: SidebarItem[] = [
...(hasPermission(user, "can:demo:user1")
? [
{
label: "User1",
href: "/dashboard/user1",
icon: <Settings />,
},
]
? [{ label: "User1", href: "/dashboard/user1", icon: <Settings /> }]
: []),
...(hasPermission(user, "can:demo:user2")
? [
{
label: "User2",
href: "/dashboard/user2",
icon: <Settings />,
},
]
? [{ label: "User2", href: "/dashboard/user2", icon: <Settings /> }]
: []),
];
const sidebarSections = buildSidebarSections(demoItems);
const sidebarSections = buildSidebarSections(user, demoItems);
const displayName = user?.name?.en || user?.username || user?.email || "User";
return (
@@ -169,6 +174,8 @@ const DashboardShell = () => {
);
};
const ruleEngineSlugs = RULE_ENGINE_RESOURCES.map((r) => r.slug);
const App = () => {
const { user, loading } = useAuth();
@@ -185,63 +192,104 @@ const App = () => {
);
}
const canBookings = canAccessBookings(user);
return (
<Routes>
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
<Route
path="booking-requests"
element={
<PermissionRoute allow={canBookings}>
<BookingRequestsPage />
</PermissionRoute>
}
/>
<Route
path="booking-requests/:id"
element={
<PermissionRoute allow={canBookings}>
<BookingRequestDetailPage />
</PermissionRoute>
}
/>
<Route
path="booking-requests/:id/contract"
element={
<PermissionRoute allow={canBookings}>
<BookingContractPage />
</PermissionRoute>
}
/>
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} />
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} />
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
<Route path="file-settings" element={<FileUploadSettingsPage />} />
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
<Route path="file-settings" element={<FileUploadSettingsPage />} />
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
<Route
path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
<Route
path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route
path="configuration/:resource"
element={
<PermissionRoute
allow={ruleEngineSlugs.some((slug) =>
canAccessRuleEngineResource(user, slug, "view"),
)}
>
<RuleEngineResourcePage />
</PermissionRoute>
}
/>
<Route
path="rules"
element={<Navigate to="/dashboard/rules/priority-rules" replace />}
/>
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
<Route
path="rules"
element={<Navigate to="/dashboard/rules/priority-rules" replace />}
/>
<Route
path="rules/:resource"
element={
<PermissionRoute
allow={ruleEngineSlugs.some((slug) =>
canAccessRuleEngineResource(user, slug, "view"),
)}
>
<RuleEngineResourcePage />
</PermissionRoute>
}
/>
<Route
path="rule-engine"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
<Route
path="rule-engine"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
<Route
path="org-structure"
element={<Navigate to="/dashboard/user-management" replace />}
/>
<Route
path="org-structure/*"
element={<Navigate to="/dashboard/user-management" replace />}
/>
</Route>
<Route
path="org-structure"
element={<Navigate to="/dashboard/user-management" replace />}
/>
<Route
path="org-structure/*"
element={<Navigate to="/dashboard/user-management" replace />}
/>
</Route>
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
</Routes>
);
};

View File

@@ -18,6 +18,6 @@ export const verifyMfaRequest = async (payload: {
};
export const getMeRequest = async () => {
const response = await api.get<AuthUser>("/auth/me");
const response = await api.get<AuthUser>("/me");
return response.data;
};

View File

@@ -39,6 +39,9 @@ export interface AuthUser {
name?: LocaleText;
roles?: AuthRole[];
permissions?: AuthPermission[];
/** Flat keys from GET /api/me (roles + position permissions). */
permissionKeys?: string[];
isSuperAdmin?: boolean;
employee?: AuthEmployeeRecord[];
hasSetPassword?: boolean;
status?: string;

View File

@@ -2,7 +2,9 @@ import { useMemo } from "react";
import { ShieldCheck } from "lucide-react";
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
import { formatApprovalProgress } from "@/features/bookings/approval-progress";
import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
import { Badge } from "@edr/ui-common";
import { cn } from "@/lib/utils";
@@ -21,31 +23,32 @@ export function ApprovalStepsCard({ booking }: ApprovalStepsCardProps) {
);
const nextPending = getNextPendingApprovalStep(steps);
const summary = formatApprovalProgress(booking.status, 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 className={cn(bookingSurface.sectionCard, bookingGlass.activeTab)}>
<div className={bookingSurface.sectionHeader}>
<div className={bookingSurface.sectionIcon}>
<ShieldCheck className="size-4" strokeWidth={1.75} />
</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"}
{summary.detail ||
(nextPending
? `Next: ${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">
<p className="rounded-lg border border-dashed border-border/60 bg-muted/10 px-4 py-6 text-center text-sm text-muted-foreground backdrop-blur-sm">
Use <strong className="font-semibold text-foreground">Accept for approval</strong>{" "}
in staff actions to instantiate steps.
</p>
@@ -74,24 +77,31 @@ function StepRow({
}) {
const statusStyles =
step.status === "APPROVED"
? "bg-emerald-500/15 text-emerald-800 dark:text-emerald-300"
? "border-emerald-500/25 bg-emerald-500/10 text-black"
: step.status === "REJECTED"
? "bg-red-500/15 text-red-800 dark:text-red-300"
? "bg-red-500/10 text-red-800 dark:text-red-300"
: isNext
? "bg-amber-500/15 text-amber-800 dark:text-amber-300"
: "bg-muted text-muted-foreground";
? "border-emerald-500/25 bg-emerald-500/10 text-black"
: "bg-muted/40 text-muted-foreground";
return (
<li
className={cn(
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors",
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors backdrop-blur-sm",
isNext
? "border-primary/30 bg-primary/[0.03] shadow-sm"
: "border-border/60 bg-card",
? bookingGlass.activeTab
: "border-border/50 bg-card/60",
)}
>
<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">
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold",
isNext
? cn(bookingGlass.iconWellGreen, "text-black")
: "bg-muted/40 text-muted-foreground",
)}
>
{step.stepOrder}
</span>
<div className="min-w-0">
@@ -107,7 +117,7 @@ function StepRow({
</div>
<Badge
variant="outline"
className={cn("shrink-0 text-[9px] uppercase", statusStyles)}
className={cn("shrink-0 border text-[9px] uppercase", statusStyles)}
>
{step.status}
</Badge>

View File

@@ -4,12 +4,14 @@ import {
ExternalLink,
Loader2,
MoreHorizontal,
Upload,
} from "lucide-react";
import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useBookingActionDialog } from "./useBookingActionDialog";
import { useAuth } from "@/auth/useAuth";
import {
getNextPendingApprovalStep,
isContractNavAction,
listRowHasActions,
type BookingActionContext,
} from "@/features/bookings/booking-actions.config";
@@ -30,18 +32,23 @@ interface BookingActionsMenuProps {
/** Compact table cell vs. larger detail toolbar */
variant?: "table" | "toolbar";
className?: string;
/** Suppresses table row navigation after menu/dialog close (click-through). */
onSuppressRowClick?: () => void;
}
export function BookingActionsMenu({
row,
variant = "table",
className,
onSuppressRowClick,
}: BookingActionsMenuProps) {
const navigate = useNavigate();
const { user } = useAuth();
const context: BookingActionContext = {
status: row.status,
paymentCurrency: row.paymentCurrency,
reference: row.reference,
approvalSteps: row.approvalSteps,
};
const flow = useBookingActionDialog(row.id, context);
@@ -50,9 +57,7 @@ export function BookingActionsMenu({
const goToContract = () =>
navigate(`/dashboard/booking-requests/${row.id}/contract`);
const showUsdPaymentHint =
row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD";
const hasMenu = listRowHasActions(row) || showUsdPaymentHint;
const hasMenu = listRowHasActions(row, user);
const primary = actions.find((a) => a.primary) ?? actions[0];
@@ -73,8 +78,9 @@ export function BookingActionsMenu({
return (
<>
<div
data-stop-row-click
className={cn(
"flex items-center justify-end gap-1",
"flex w-full min-h-[2.5rem] items-center justify-end gap-1",
variant === "table" && "opacity-80 transition-opacity group-hover/tr:opacity-100",
className,
)}
@@ -87,7 +93,7 @@ export function BookingActionsMenu({
className="hidden h-8 gap-1.5 px-2.5 shadow-sm lg:inline-flex"
disabled={mutations.isPending}
onClick={() =>
primary.id === "viewContract"
isContractNavAction(primary.id)
? goToContract()
: flow.openAction(primary)
}
@@ -119,7 +125,7 @@ export function BookingActionsMenu({
)}
disabled={mutations.isPending}
onClick={() =>
action.id === "viewContract"
isContractNavAction(action.id)
? goToContract()
: flow.openAction(action)
}
@@ -164,36 +170,29 @@ export function BookingActionsMenu({
"gap-2 cursor-pointer",
action.variant === "destructive" && "text-red-700 focus:text-red-700",
)}
onClick={() =>
action.id === "viewContract"
? goToContract()
: flow.openAction(action)
}
onSelect={(event) => {
event.preventDefault();
onSuppressRowClick?.();
if (isContractNavAction(action.id)) {
goToContract();
} else {
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 />
)}
{actions.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem
className="gap-2 cursor-pointer"
onClick={() =>
navigate(`/dashboard/booking-requests/${row.id}`)
}
onSelect={(event) => {
event.preventDefault();
onSuppressRowClick?.();
navigate(`/dashboard/booking-requests/${row.id}`);
}}
>
<ExternalLink className="size-4 opacity-70" />
Open full details
@@ -205,12 +204,20 @@ export function BookingActionsMenu({
<BookingConfirmDialog
open={flow.dialogOpen}
onOpenChange={flow.setDialogOpen}
onOpenChange={(open) => {
if (!open) onSuppressRowClick?.();
flow.setDialogOpen(open);
}}
action={pendingAction}
reference={flow.mergedContext.reference}
inputValue={flow.inputValue}
onInputChange={flow.setInputValue}
onConfirm={flow.runAction}
selectedFile={flow.selectedFile}
onFileChange={flow.setSelectedFile}
onConfirm={() => {
onSuppressRowClick?.();
flow.runAction();
}}
isPending={mutations.isPending || flow.detailLoading}
confirmDisabled={flow.confirmDisabled}
extra={
@@ -220,10 +227,10 @@ export function BookingActionsMenu({
Loading approval steps
</p>
) : pendingAction?.id === "approve" &&
!flow.mergedContext.approvalSteps?.length ? (
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? (
<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.
No pending approval step. Refresh the page after staff accept, or
reject the booking.
</p>
) : null
}

View File

@@ -1,5 +1,4 @@
import { useRef } from "react";
import { Download, Upload, Zap } from "lucide-react";
import { Download, Zap } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { BookingActionsMenu } from "./BookingActionsMenu";
@@ -7,6 +6,7 @@ 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";
import { cn } from "@/lib/utils";
type Mutations = ReturnType<typeof useBookingMutations>;
@@ -15,15 +15,13 @@ interface BookingActionsToolbarProps {
mutations: Mutations;
}
/** Detail-page actions: primary toolbar + payment uploads + downloads. */
/** Detail-page actions: primary toolbar + 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 { status } = booking;
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
const blob = await fn();
@@ -47,7 +45,7 @@ export function BookingActionsToolbar({
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">
<p className="rounded-lg border border-border/50 bg-muted/15 p-3 text-sm leading-relaxed backdrop-blur-sm">
{booking.latestChangeRequestNote}
</p>
)}
@@ -74,50 +72,11 @@ export function BookingActionsToolbar({
<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"
className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
onClick={() =>
downloadBlob(
() => mutations.downloadContract(),
@@ -146,16 +105,10 @@ function PanelShell({
muted?: boolean;
}) {
return (
<div
className={
muted
? bookingSurface.sectionCard
: `${bookingSurface.sectionCard} ring-1 ring-primary/10`
}
>
<div className={cn(bookingSurface.sectionCard, !muted && "ring-0")}>
<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 className={bookingSurface.sectionIcon}>
<Zap className="size-4" strokeWidth={1.75} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">{title}</h2>

View File

@@ -0,0 +1,29 @@
import { formatApprovalProgress } from "@/features/bookings/approval-progress";
import type { BookingListRow } from "@/types/booking";
import { cn } from "@/lib/utils";
interface BookingApprovalProgressCellProps {
row: BookingListRow;
}
export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCellProps) {
const summary = formatApprovalProgress(row.status, row.approvalSteps);
return (
<div className="min-w-[8.5rem] py-1">
<p
className={cn(
"text-sm font-semibold",
summary.complete ? "text-emerald-700 dark:text-emerald-400" : "text-foreground",
)}
>
{summary.label}
</p>
{summary.detail ? (
<p className="mt-0.5 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
{summary.detail}
</p>
) : null}
</div>
);
}

View File

@@ -20,6 +20,8 @@ interface BookingConfirmDialogProps {
reference?: string;
inputValue: string;
onInputChange: (value: string) => void;
selectedFile?: File | null;
onFileChange?: (file: File | null) => void;
onConfirm: () => void;
isPending: boolean;
confirmDisabled?: boolean;
@@ -33,6 +35,8 @@ export function BookingConfirmDialog({
reference,
inputValue,
onInputChange,
selectedFile = null,
onFileChange,
onConfirm,
isPending,
confirmDisabled = false,
@@ -41,13 +45,25 @@ export function BookingConfirmDialog({
if (!action || !action.confirmTitle) return null;
const Icon = action.icon;
const needsInput = Boolean(action.input);
const inputMissing = needsInput && !inputValue.trim();
const needsTextInput =
action.input === "note" || action.input === "reason";
const needsFileInput = action.input === "file";
const inputMissing =
(needsTextInput && !inputValue.trim()) ||
(needsFileInput && !selectedFile);
const isDestructive = action.variant === "destructive";
const preventClickThrough = (event: React.MouseEvent) => {
event.preventDefault();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-md">
<DialogContent
className="gap-0 overflow-hidden p-0 sm:max-w-md"
showCloseButton={false}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<div
className={cn(
"border-b px-6 py-5",
@@ -86,7 +102,7 @@ export function BookingConfirmDialog({
</div>
<div className="space-y-4 px-6 py-5">
{needsInput && (
{needsTextInput && (
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{action.inputLabel}
@@ -101,6 +117,27 @@ export function BookingConfirmDialog({
/>
</div>
)}
{needsFileInput && (
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{action.inputLabel ?? "Bank slip file"}
<span className="text-red-600"> *</span>
</label>
<input
type="file"
accept=".pdf,.png,.jpg,.jpeg"
className="block w-full text-sm text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-2 file:text-xs file:font-semibold file:text-primary-foreground"
onChange={(e) =>
onFileChange?.(e.target.files?.[0] ?? null)
}
/>
{selectedFile && (
<p className="text-xs text-muted-foreground">
Selected: {selectedFile.name}
</p>
)}
</div>
)}
{extra}
</div>
@@ -109,6 +146,7 @@ export function BookingConfirmDialog({
type="button"
variant="outline"
disabled={isPending}
onMouseDown={preventClickThrough}
onClick={() => onOpenChange(false)}
>
Cancel
@@ -118,6 +156,7 @@ export function BookingConfirmDialog({
variant={isDestructive ? "destructive" : "default"}
disabled={isPending || inputMissing || confirmDisabled}
className="min-w-[7rem] gap-2"
onMouseDown={preventClickThrough}
onClick={onConfirm}
>
{isPending ? (

View File

@@ -2,7 +2,7 @@ import { Banknote, Receipt } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { Separator } from "@edr/ui-common";
import { bookingSurface } from "./booking-ui.styles";
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
const amount = Number(booking.totalAmount);
@@ -11,8 +11,8 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
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 className={bookingSurface.sectionIcon}>
<Banknote className="size-4" strokeWidth={1.75} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
@@ -22,11 +22,11 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
</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">
<div className={bookingSurface.valueCard}>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Total amount
</p>
<p className="mt-1 font-mono text-2xl font-bold tracking-tight text-foreground">
<p className="mt-1 font-mono text-2xl font-semibold tabular-nums tracking-tight text-foreground">
{booking.paymentCurrency}{" "}
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</p>
@@ -35,8 +35,8 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
{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">
<Separator className="opacity-50" />
<p className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<Receipt className="size-3" />
Surcharges applied
</p>
@@ -44,7 +44,7 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
{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"
className="flex justify-between rounded-lg border border-border/50 bg-muted/15 px-3 py-2 text-sm backdrop-blur-sm"
>
<span className="text-muted-foreground">Modifier</span>
<span className="font-mono font-semibold tabular-nums">
@@ -70,7 +70,7 @@ function Row({
mono?: boolean;
}) {
return (
<div className="flex items-center justify-between gap-2 text-sm">
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/40 bg-muted/10 px-3 py-2.5 text-sm backdrop-blur-sm">
<span className="text-muted-foreground">{label}</span>
<span
className={

View File

@@ -1,4 +1,5 @@
import type { LucideIcon } from "lucide-react";
import { bookingGlass } from "./booking-ui.styles";
import { cn } from "@/lib/utils";
export interface StatItem {
@@ -9,43 +10,49 @@ export interface StatItem {
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",
const iconAccentStyles = {
default: "text-foreground/70",
amber: "text-amber-600 dark:text-amber-400",
emerald: "text-emerald-600 dark:text-emerald-400",
rose: "text-rose-600 dark:text-rose-400",
};
export function BookingStatGrid({ items }: { items: StatItem[] }) {
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-3 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"
className={cn(
"group relative overflow-hidden rounded-xl p-5 transition-all duration-200 hover:shadow-md",
bookingGlass.card,
)}
>
<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">
<p className="text-[11px] 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">
<p className="mt-2 text-3xl font-semibold tabular-nums tracking-tight text-foreground">
{item.value}
</p>
{item.hint && (
<p className="mt-1 text-xs text-muted-foreground">{item.hint}</p>
<p className="mt-1 text-xs leading-relaxed 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],
"flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-[1.02]",
bookingGlass.iconWellGreen,
iconAccentStyles[accent],
)}
>
<Icon className="size-5" strokeWidth={2} />
<Icon className="size-[18px]" strokeWidth={1.75} />
</div>
</div>
</div>

View File

@@ -1,27 +1,34 @@
import {
CheckCircle,
ClipboardCheck,
FileSignature,
FileText,
Inbox,
LayoutGrid,
ShieldCheck,
Train,
Wallet,
XCircle,
} from "lucide-react";
import {
BOOKING_LIST_TABS,
type BookingStatusTabKey,
} from "@/features/bookings/booking-status.config";
import { bookingGlass } from "./booking-ui.styles";
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" />,
all: <LayoutGrid className="size-3.5" strokeWidth={1.75} />,
intake: <Inbox className="size-3.5" strokeWidth={1.75} />,
in_approval: <ClipboardCheck className="size-3.5" strokeWidth={1.75} />,
approved_contract: <FileSignature className="size-3.5" strokeWidth={1.75} />,
payment: <Wallet className="size-3.5" strokeWidth={1.75} />,
operations: <Train className="size-3.5" strokeWidth={1.75} />,
completed: <CheckCircle className="size-3.5" strokeWidth={1.75} />,
closed: <XCircle className="size-3.5" strokeWidth={1.75} />,
};
const activeTabText = "text-black";
interface BookingStatusTabsProps {
active: BookingStatusTabKey;
onChange: (tab: BookingStatusTabKey) => void;
@@ -34,9 +41,9 @@ export function BookingStatusTabs({
counts,
}: BookingStatusTabsProps) {
return (
<div className="rounded-xl border border-border bg-muted/30 p-1.5">
<div className={bookingGlass.tabRail}>
<div
className="flex gap-1 overflow-x-auto pb-0.5 scrollbar-thin"
className="flex flex-nowrap gap-1.5 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
aria-label="Booking status filters"
>
@@ -51,29 +58,38 @@ export function BookingStatusTabs({
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",
"flex min-w-[8rem] shrink-0 flex-col items-start gap-0.5 rounded-lg px-3 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",
? bookingGlass.activeTab
: "text-muted-foreground hover:bg-emerald-500/5 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",
"flex items-center gap-2 text-sm font-medium",
isActive ? activeTabText : "text-muted-foreground",
)}
>
{TAB_ICONS[tab.key]}
{tab.label}
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md",
isActive
? cn(bookingGlass.iconWellGreen, "text-black")
: "border border-transparent bg-muted/30",
)}
>
{TAB_ICONS[tab.key]}
</span>
<span className="whitespace-nowrap">{tab.label}</span>
</span>
{count !== undefined && count > 0 && (
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-bold tabular-nums",
"rounded-full px-2 py-0.5 text-[10px] font-semibold tabular-nums",
isActive
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground",
? cn("bg-emerald-500/15", activeTabText)
: "bg-muted/50 text-muted-foreground",
)}
>
{count}

View File

@@ -1,6 +1,7 @@
import { Package, Search } from "lucide-react";
import { Button } from "@edr/ui-common";
import { bookingSurface } from "./booking-ui.styles";
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
import { cn } from "@/lib/utils";
interface BookingTableEmptyProps {
isError?: boolean;
@@ -15,7 +16,12 @@ export function BookingTableEmpty({
}: BookingTableEmptyProps) {
return (
<div className={bookingSurface.emptyState}>
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<div
className={cn(
"flex size-14 items-center justify-center rounded-2xl text-muted-foreground",
bookingGlass.iconWellGreen,
)}
>
{hasSearch ? <Search className="size-6" /> : <Package className="size-6" />}
</div>
<div className="max-w-sm space-y-1">

View File

@@ -12,7 +12,7 @@ import {
getWorkflowStageIndex,
WORKFLOW_STAGES,
} from "@/features/bookings/booking-status.config";
import { bookingSurface } from "./booking-ui.styles";
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
const STAGE_ICONS = [FileText, FileSignature, FileSignature, Wallet, Train, Check];
@@ -35,8 +35,8 @@ export function BookingWorkflowStepper({
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 className={bookingSurface.sectionIcon}>
<Train className="size-4" strokeWidth={1.75} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
@@ -49,9 +49,9 @@ export function BookingWorkflowStepper({
</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 right-4 top-5 h-px bg-border/60" />
<div
className="absolute left-4 top-5 h-0.5 bg-primary transition-all duration-700 ease-out"
className="absolute left-4 top-5 h-px bg-emerald-500/40 transition-all duration-700 ease-out"
style={{
width:
!isTerminal && currentStage >= 0
@@ -71,14 +71,17 @@ export function BookingWorkflowStepper({
>
<div
className={cn(
"flex size-10 items-center justify-center rounded-full border-2 bg-card transition-all duration-300",
"flex size-10 items-center justify-center rounded-full border-2 bg-card/80 backdrop-blur-sm transition-all duration-300",
isCompleted &&
"border-primary bg-primary text-primary-foreground shadow-sm",
cn(bookingGlass.iconWellGreen, "border-emerald-500/30 text-black"),
isActive &&
"scale-110 border-primary bg-background text-primary shadow-md ring-4 ring-primary/15",
cn(
bookingGlass.activeTab,
"scale-105 border-emerald-500/30 text-black shadow-sm",
),
!isCompleted &&
!isActive &&
"border-border text-muted-foreground",
"border-border/60 text-muted-foreground",
)}
>
{isCompleted ? (
@@ -89,8 +92,8 @@ export function BookingWorkflowStepper({
</div>
<span
className={cn(
"text-center text-[10px] font-bold uppercase leading-tight tracking-wide",
isActive ? "text-primary" : "text-muted-foreground",
"text-center text-[10px] font-semibold uppercase leading-tight tracking-wide",
isActive ? "text-black" : "text-muted-foreground",
)}
>
{stage.label}
@@ -103,14 +106,17 @@ export function BookingWorkflowStepper({
<div
className={cn(
"rounded-xl border px-5 py-4",
"rounded-xl border px-5 py-4 backdrop-blur-sm",
isTerminal
? "border-destructive/20 bg-destructive/5"
: "border-primary/15 bg-primary/[0.04]",
: bookingGlass.activeTab,
)}
>
<h4
className={cn("text-sm font-bold tracking-tight", titleColor)}
className={cn(
"text-sm font-semibold tracking-tight",
isTerminal ? titleColor : "text-black",
)}
>
{title}
</h4>

View File

@@ -0,0 +1,32 @@
import { ArrowRight } from "lucide-react";
import type { BookingNextStep } from "@/types/booking";
import { bookingGlass } from "./booking-ui.styles";
import { cn } from "@/lib/utils";
interface NextStepBannerProps {
nextStep: BookingNextStep;
className?: string;
}
export function NextStepBanner({ nextStep, className }: NextStepBannerProps) {
return (
<div
className={cn(
"flex items-start gap-3 rounded-xl px-4 py-3 text-sm",
bookingGlass.activeTab,
className,
)}
role="status"
>
<ArrowRight className="mt-0.5 size-4 shrink-0 text-black" aria-hidden />
<div className="min-w-0 space-y-0.5">
<p className="font-semibold text-black">
Next: {nextStep.action.replace(/_/g, " ")}
{nextStep.requiredRole ? ` (${nextStep.requiredRole})` : ""}
</p>
<p className="text-muted-foreground">{nextStep.description}</p>
</div>
</div>
);
}

View File

@@ -1,32 +1,63 @@
/** Shared surfaces for booking list & detail — aligned with rule-engine polish. */
/** Shared surfaces for booking list & detail — frosted glass, neutral accents. */
export const bookingGlass = {
card:
"border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
panel:
"border border-border/50 bg-card/80 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/65",
rail:
"border border-border/40 bg-muted/20 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/15",
iconWell:
"border border-border/50 bg-background/70 text-foreground/75 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-background/50",
iconWellHero:
"border border-border/50 bg-background/60 text-foreground shadow-sm ring-1 ring-border/30 backdrop-blur-md supports-[backdrop-filter]:bg-background/45",
activeTab:
"border border-emerald-500/20 bg-emerald-500/10 shadow-sm backdrop-blur-md ring-1 ring-emerald-500/10 supports-[backdrop-filter]:bg-emerald-500/[0.08]",
iconWellGreen:
"border border-emerald-500/20 bg-emerald-500/15 text-emerald-700 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-emerald-500/10 dark:text-emerald-400",
tabRail:
"rounded-xl border border-border/60 bg-muted/10 p-2 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/5",
tableHeader:
"bg-muted/30 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/20",
} as const;
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",
page:
"min-h-screen bg-gradient-to-b from-muted/30 via-background to-background",
pageInner: "mx-auto max-w-[1600px] space-y-5 p-6 lg:p-8",
hero: `relative overflow-hidden rounded-2xl ${bookingGlass.card}`,
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",
"pointer-events-none absolute -right-24 -top-24 size-72 rounded-full bg-muted/40 blur-3xl",
heroSheen:
"pointer-events-none absolute inset-0 bg-gradient-to-br from-background/40 via-transparent to-muted/20",
panel: `overflow-hidden rounded-xl ${bookingGlass.panel}`,
panelToolbar: `flex flex-wrap items-center justify-between gap-3 border-b border-border/50 px-4 py-3.5 sm:px-5 ${bookingGlass.rail}`,
tableWrap: "px-0",
sectionCard:
"overflow-hidden rounded-xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md",
sectionCard: `overflow-hidden rounded-xl transition-shadow duration-200 hover:shadow-md ${bookingGlass.card}`,
sectionHeader:
"flex items-center gap-3 border-b border-border/60 bg-muted/20 px-5 py-4",
"flex items-center gap-3 border-b border-border/50 bg-muted/15 px-5 py-4 backdrop-blur-sm",
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",
detailHero: `relative overflow-hidden rounded-2xl ${bookingGlass.card}`,
sectionIcon: `flex size-9 shrink-0 items-center justify-center rounded-lg ${bookingGlass.iconWellGreen}`,
sectionIconLg: `flex size-11 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
valueCard:
"rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-emerald-500/[0.08]",
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",
"rounded-lg border border-border/50 bg-background/70 px-4 py-3 shadow-xs backdrop-blur-sm",
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",
"h-10 w-full rounded-lg border border-border/60 bg-background/80 pl-10 text-sm shadow-xs backdrop-blur-sm transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring/60 focus-visible:ring-[3px] focus-visible:ring-ring/20 sm:max-w-xs",
} as const;
export const bookingTable = {
headerCell:
"h-11 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground",
rowHover:
"transition-colors hover:bg-muted/25 data-[state=selected]:bg-muted/30",
rowIcon: `flex size-10 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
} as const;

View File

@@ -6,6 +6,7 @@ import {
type BookingActionContext,
type BookingActionDef,
} from "@/features/bookings/booking-actions.config";
import { useAuth } from "@/auth/useAuth";
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
export function useBookingActionDialog(
@@ -14,13 +15,18 @@ export function useBookingActionDialog(
) {
const [pendingAction, setPendingAction] = useState<BookingActionDef | null>(null);
const [inputValue, setInputValue] = useState("");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const needsApprovalSteps =
pendingAction?.id === "approve" || pendingAction?.id === "rejectApproval";
const needsApprovalContext =
context.status === "PENDING_APPROVAL" ||
context.status === "APPROVED_PENDING_SIGNATURE";
const { data: detail, isLoading: detailLoading } = useBookingDetail(
needsApprovalSteps ? bookingId : undefined,
needsApprovalSteps || needsApprovalContext ? bookingId : undefined,
);
const mergedContext: BookingActionContext = {
@@ -29,12 +35,14 @@ export function useBookingActionDialog(
reference: detail?.reference ?? context.reference,
};
const { user } = useAuth();
const mutations = useBookingMutations(bookingId);
const actions = getBookingActions(mergedContext);
const actions = getBookingActions(mergedContext, user);
const openAction = useCallback((action: BookingActionDef) => {
setPendingAction(action);
setInputValue("");
setSelectedFile(null);
setDialogOpen(true);
}, []);
@@ -42,6 +50,7 @@ export function useBookingActionDialog(
setDialogOpen(false);
setPendingAction(null);
setInputValue("");
setSelectedFile(null);
}, []);
const runAction = useCallback(() => {
@@ -82,11 +91,8 @@ export function useBookingActionDialog(
break;
case "viewContract":
break;
case "generatePnr":
mutations.generatePnr.mutate(undefined, { onSuccess });
break;
case "verifyPayment":
mutations.verifyPayment.mutate(undefined, { onSuccess });
case "payBooking":
mutations.payBooking.mutate(undefined, { onSuccess });
break;
case "startTransit":
mutations.startTransit.mutate(undefined, { onSuccess });
@@ -94,12 +100,16 @@ export function useBookingActionDialog(
case "complete":
mutations.complete.mutate(undefined, { onSuccess });
break;
case "cancel":
mutations.cancel.mutate(inputValue.trim(), { onSuccess });
break;
default:
break;
}
}, [
pendingAction,
inputValue,
selectedFile,
mergedContext.approvalSteps,
mutations,
closeDialog,
@@ -109,13 +119,18 @@ export function useBookingActionDialog(
mutations.isPending ||
(needsApprovalSteps && detailLoading) ||
(pendingAction?.id === "approve" &&
!getNextPendingApprovalStep(mergedContext.approvalSteps));
!getNextPendingApprovalStep(mergedContext.approvalSteps)) ||
(pendingAction?.input === "file" && !selectedFile) ||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
(pendingAction?.input === "note" && !inputValue.trim());
return {
actions,
pendingAction,
inputValue,
setInputValue,
selectedFile,
setSelectedFile,
dialogOpen,
setDialogOpen: (open: boolean) => {
if (!open) closeDialog();

View File

@@ -21,8 +21,9 @@ export interface RuleEngineCardGridProps {
pageCount: number;
totalCount: number;
};
onEdit: (record: RuleEngineRecord) => void;
onDelete: (record: RuleEngineRecord) => void;
onEdit?: (record: RuleEngineRecord) => void;
onDelete?: (record: RuleEngineRecord) => void;
readOnly?: boolean;
onViewChain?: () => void;
onSubmitRate?: (id: string) => void;
onApproveRate?: (record: RuleEngineRecord) => void;
@@ -41,6 +42,7 @@ const RuleEngineCardGrid = ({
onViewChain,
onSubmitRate,
onApproveRate,
readOnly = false,
}: RuleEngineCardGridProps) => {
const presentation = resolveCardPresentation(config);
@@ -153,8 +155,9 @@ const RuleEngineCardGrid = ({
record={record}
config={config}
layout="compact"
onEdit={onEdit}
onDelete={onDelete}
readOnly={readOnly}
onEdit={onEdit ?? (() => {})}
onDelete={onDelete ?? (() => {})}
onViewChain={onViewChain}
onSubmitRate={onSubmitRate}
onApproveRate={onApproveRate}

View File

@@ -26,6 +26,7 @@ export interface RuleEngineRecordActionsProps {
onSubmitRate?: (id: string) => void;
onApproveRate?: (record: RuleEngineRecord) => void;
layout?: "row" | "compact";
readOnly?: boolean;
}
const RuleEngineRecordActions = ({
@@ -37,6 +38,7 @@ const RuleEngineRecordActions = ({
onSubmitRate,
onApproveRate,
layout = "row",
readOnly = false,
}: RuleEngineRecordActionsProps) => {
const status = String(record.status ?? "");
const hasRateActions =
@@ -47,6 +49,21 @@ const RuleEngineRecordActions = ({
? "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
: "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground";
if (readOnly) {
return onViewChain ? (
<Button
type="button"
variant="ghost"
size="icon"
className={iconBtnClass}
onClick={onViewChain}
aria-label="View chain"
>
<Eye className="h-4 w-4" />
</Button>
) : null;
}
return (
<div className="flex items-center justify-end gap-0.5">
<Button

View File

@@ -10,7 +10,7 @@ export interface RuleEngineToolbarProps {
search: string;
onSearchChange: (value: string) => void;
searchPlaceholder: string;
onAdd: () => void;
onAdd?: () => void;
addLabel?: string;
viewMode: RuleEngineViewMode;
onViewModeChange: (mode: RuleEngineViewMode) => void;
@@ -81,10 +81,12 @@ const RuleEngineToolbar = ({
<Filter className="h-4 w-4" />
Filter
</Button>
<Button type="button" className={ruleEngineToolbar.primaryBtn} onClick={onAdd}>
<Plus className="h-4 w-4" />
{addLabel}
</Button>
{onAdd ? (
<Button type="button" className={ruleEngineToolbar.primaryBtn} onClick={onAdd}>
<Plus className="h-4 w-4" />
{addLabel}
</Button>
) : null}
</div>
</div>
);

View File

@@ -32,6 +32,8 @@ export const QUERY_KEYS = {
ROOT: ["bookings"] as const,
list: (filter?: BookingListFilter) =>
["bookings", "list", filter ?? {}] as const,
listSummary: (filter?: BookingListFilter) =>
["bookings", "list-summary", filter ?? {}] as const,
byId: (id: string) => ["bookings", "detail", id] as const,
},

View File

@@ -77,6 +77,7 @@ export const URL_CONSTANTS = {
BOOKINGS: {
BASE: "/bookings",
LIST_SUMMARY: "/bookings/list-summary",
BY_ID: (id: string) => `/bookings/${id}`,
QUEUE: (queue: string) => `/bookings/queues/${queue}`,
STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`,
@@ -95,11 +96,7 @@ export const URL_CONSTANTS = {
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`,
PAYMENT_PAY: (id: string) => `/bookings/${id}/payment/pay`,
START_TRANSIT: (id: string) => `/bookings/${id}/operations/start-transit`,
COMPLETE: (id: string) => `/bookings/${id}/operations/complete`,
CANCEL: (id: string) => `/bookings/${id}/cancel`,

View File

@@ -0,0 +1,79 @@
import type { BookingApprovalStep, BookingStatus } from "@/types/booking";
import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
export interface ApprovalProgressSummary {
label: string;
detail: string;
complete: boolean;
}
/** Compact approval chain summary for list rows and badges. */
export function formatApprovalProgress(
status: BookingStatus | string,
steps?: BookingApprovalStep[] | null,
): ApprovalProgressSummary {
const sorted = [...(steps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder);
if (sorted.length === 0) {
if (status === "SUBMITTED") {
return {
label: "Awaiting accept",
detail: "Staff must accept intake",
complete: false,
};
}
if (
status === "PENDING_APPROVAL" ||
status === "APPROVED_PENDING_SIGNATURE"
) {
return {
label: "No steps",
detail: "Approval chain not started",
complete: false,
};
}
if (
[
"APPROVED",
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
"PAID",
"COMPLETED",
].includes(status)
) {
return {
label: "Approved",
detail: "Internal approval complete",
complete: true,
};
}
return { label: "—", detail: "", complete: false };
}
const approved = sorted.filter((s) => s.status === "APPROVED").length;
const total = sorted.length;
const next = getNextPendingApprovalStep(sorted);
if (!next && approved === total) {
return {
label: `${approved}/${total} done`,
detail: sorted.map((s) => `${s.requiredRole}`).join(" · "),
complete: true,
};
}
if (next) {
return {
label: `${approved}/${total}`,
detail: `Next: ${next.requiredRole} (step ${next.stepOrder})`,
complete: false,
};
}
return {
label: `${approved}/${total}`,
detail: sorted.map((s) => `${s.requiredRole}: ${s.status}`).join(" · "),
complete: approved === total,
};
}

View File

@@ -12,6 +12,8 @@ import {
XCircle,
} from "lucide-react";
import type { AuthUser } from "@/auth/types";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import type {
BookingApprovalStep,
BookingDetail,
@@ -26,12 +28,13 @@ export type BookingActionId =
| "rejectApproval"
| "generateContract"
| "viewContract"
| "generatePnr"
| "verifyPayment"
| "signContractStaff"
| "payBooking"
| "startTransit"
| "complete";
| "complete"
| "cancel";
export type BookingActionInputKind = "note" | "reason";
export type BookingActionInputKind = "note" | "reason" | "file";
export interface BookingActionDef {
id: BookingActionId;
@@ -140,18 +143,118 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [
},
];
const CANCEL_ACTION: BookingActionDef = {
id: "cancel",
label: "Cancel booking",
shortLabel: "Cancel",
description: "Cancel this booking",
confirmTitle: "Cancel booking?",
confirmDescription:
"The booking will be marked cancelled. Provide a reason for the audit trail.",
variant: "destructive",
icon: Ban,
input: "reason",
inputLabel: "Cancellation reason",
inputPlaceholder: "Reason for cancellation…",
};
const VIEW_CONTRACT_ACTION: BookingActionDef = {
id: "viewContract",
label: "View contract",
shortLabel: "Contract",
description: "Open contract document and signatures",
confirmTitle: "",
confirmDescription: "",
variant: "outline",
icon: FileSignature,
};
const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = {
id: "signContractStaff",
label: "Sign contract",
shortLabel: "Sign",
description: "Open contract page and apply staff counter-signature",
confirmTitle: "",
confirmDescription: "",
variant: "default",
icon: FileSignature,
primary: true,
};
const PAY_BOOKING_ACTION: BookingActionDef = {
id: "payBooking",
label: "Pay",
shortLabel: "Pay",
description: "Complete in-app payment",
confirmTitle: "Complete payment?",
confirmDescription:
"This simulates an in-app payment (Telebirr for ETB, card for USD) and marks the booking as paid.",
variant: "default",
icon: Wallet,
primary: true,
};
function withCancel(actions: BookingActionDef[]): BookingActionDef[] {
return [...actions, CANCEL_ACTION];
}
const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
accept: FREIGHT_PERMS.bookings.staffAccept,
requestChanges: FREIGHT_PERMS.bookings.requestChanges,
reject: FREIGHT_PERMS.bookings.reject,
rejectApproval: FREIGHT_PERMS.bookings.rejectApproval,
generateContract: FREIGHT_PERMS.bookings.generateContract,
viewContract: FREIGHT_PERMS.bookings.view,
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
payBooking: FREIGHT_PERMS.bookings.view,
startTransit: FREIGHT_PERMS.bookings.operations,
complete: FREIGHT_PERMS.bookings.operations,
cancel: FREIGHT_PERMS.bookings.cancel,
};
const approvePermissionForRole = (role: string): string | undefined => {
if (role === "LINE_STAFF") return FREIGHT_PERMS.bookings.approveLineStaff;
if (role === "DIRECTOR") return FREIGHT_PERMS.bookings.approveDirector;
if (role === "CEO") return FREIGHT_PERMS.bookings.approveCeo;
return undefined;
};
function filterActionsByUser(
actions: BookingActionDef[],
user: AuthUser | null | undefined,
approvalSteps?: BookingApprovalStep[] | null,
): BookingActionDef[] {
if (!user) return [];
const next = getNextPendingApprovalStep(approvalSteps);
return actions.filter((action) => {
if (action.id === "approve" && next) {
const perm = approvePermissionForRole(next.requiredRole);
return perm ? hasPermission(user, perm) : false;
}
const perm = ACTION_PERMISSION[action.id];
return perm ? hasPermission(user, perm) : true;
});
}
/** Actions available for the current booking status (detail or list). */
export function getBookingActions(ctx: BookingActionContext): BookingActionDef[] {
const { status, paymentCurrency, approvalSteps } = ctx;
export function getBookingActions(
ctx: BookingActionContext,
user?: AuthUser | null,
): BookingActionDef[] {
const { status, approvalSteps } = ctx;
let actions: BookingActionDef[];
switch (status) {
case "SUBMITTED":
return SUBMITTED_ACTIONS;
actions = withCancel(SUBMITTED_ACTIONS);
break;
case "PENDING_APPROVAL":
case "APPROVED_PENDING_SIGNATURE":
return approvalActions(approvalSteps);
actions = withCancel(approvalActions(approvalSteps));
break;
case "APPROVED":
return [
actions = [
{
id: "generateContract",
label: "Generate contract",
@@ -164,64 +267,23 @@ export function getBookingActions(ctx: BookingActionContext): BookingActionDef[]
icon: FileText,
primary: true,
},
CANCEL_ACTION,
];
break;
case "CONTRACT_READY":
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }];
break;
case "SIGNED_CUSTOMER":
actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION];
break;
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,
},
actions = [
PAY_BOOKING_ACTION,
{ ...VIEW_CONTRACT_ACTION, label: "View executed contract" },
];
break;
case "PAID":
case "PNR_GENERATED":
return [
actions = [
{
id: "startTransit",
label: "Start transit",
@@ -234,8 +296,9 @@ export function getBookingActions(ctx: BookingActionContext): BookingActionDef[]
primary: true,
},
];
break;
case "IN_TRANSIT":
return [
actions = [
{
id: "complete",
label: "Complete booking",
@@ -249,20 +312,39 @@ export function getBookingActions(ctx: BookingActionContext): BookingActionDef[]
primary: true,
},
];
break;
case "CHANGES_REQUESTED":
actions = [CANCEL_ACTION];
break;
default:
return [];
actions = [];
}
if (user === undefined) return actions;
return filterActionsByUser(actions, user, approvalSteps);
}
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";
/** Opens contract page without confirmation dialog. */
export function isContractNavAction(id: BookingActionId): boolean {
return id === "viewContract" || id === "signContractStaff";
}
export function listRowHasActions(
row: {
status: BookingStatus;
paymentCurrency: string;
approvalSteps?: BookingApprovalStep[] | null;
},
user?: AuthUser | null,
): boolean {
const actions = getBookingActions(
{
status: row.status,
paymentCurrency: row.paymentCurrency,
reference: "",
approvalSteps: row.approvalSteps,
},
user,
);
return actions.length > 0;
}

View File

@@ -199,20 +199,31 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
};
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: "all", label: "All bookings", statuses: null as string[] | null },
{ key: "intake", label: "Submitted", statuses: ["SUBMITTED"] },
{
key: "APPROVED_PENDING_SIGNATURE",
label: "Pending Signature",
status: "APPROVED_PENDING_SIGNATURE",
key: "in_approval",
label: "In approval",
statuses: ["PENDING_APPROVAL", "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",
key: "approved_contract",
label: "Approved & contract",
statuses: [
"APPROVED",
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
],
},
{
key: "payment",
label: "Payment",
statuses: ["FULLY_EXECUTED", "PAID"],
},
{ key: "operations", label: "Operations", statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"] },
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
] as const;
export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"];
@@ -229,11 +240,7 @@ export const WORKFLOW_STAGES = [
},
{
label: "Payment",
statuses: [
"PNR_GENERATED",
"PAYMENT_VERIFICATION_IN_PROGRESS",
"PAID",
],
statuses: ["FULLY_EXECUTED", "PAID"],
},
{
label: "Operations",

View File

@@ -18,6 +18,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
return {
id: booking.id,
reference: booking.reference,
approvalSteps: booking.approvalSteps,
customerLabel: labelFromRef(booking.company, booking.companyId),
// customerLabel: labelFromRef(booking.customer, booking.customerId),
status: booking.status,

View File

@@ -17,6 +17,14 @@ export function useBookingList(filter?: BookingListFilter, enabled = true) {
});
}
export function useBookingListSummary(filter?: BookingListFilter, enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.BOOKINGS.listSummary(filter),
queryFn: () => bookingsService.getListSummary(filter),
enabled,
});
}
export function useBookingDetail(id: string | undefined) {
return useQuery({
queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? ""),
@@ -103,23 +111,10 @@ export function useBookingMutations(bookingId: string) {
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 payBooking = useMutation({
mutationFn: () => api.bookings.payBooking.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Payment completed"),
onError: () => toast.error("Failed to complete payment"),
});
const startTransit = useMutation({
@@ -149,9 +144,7 @@ export function useBookingMutations(bookingId: string) {
rejectStep.isPending ||
generateContract.isPending ||
signContract.isPending ||
generatePnr.isPending ||
submitPaymentProof.isPending ||
verifyPayment.isPending ||
payBooking.isPending ||
startTransit.isPending ||
complete.isPending ||
cancel.isPending;
@@ -164,15 +157,11 @@ export function useBookingMutations(bookingId: string) {
rejectStep,
generateContract,
signContract,
generatePnr,
submitPaymentProof,
verifyPayment,
payBooking,
startTransit,
complete,
cancel,
isPending,
downloadContract: () => bookingsService.downloadContract(bookingId),
downloadPaymentLetter: () =>
bookingsService.downloadPaymentRequestLetter(bookingId),
};
}

View File

@@ -0,0 +1,82 @@
import type { AuthUser } from "@/auth/types";
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
export const FREIGHT_PERMS = {
bookings: {
view: "edr_freight_app:bookings:view",
staffAccept: "edr_freight_app:bookings:staff_accept",
requestChanges: "edr_freight_app:bookings:request_changes",
reject: "edr_freight_app:bookings:reject",
approveLineStaff: "edr_freight_app:bookings:approve_line_staff",
approveDirector: "edr_freight_app:bookings:approve_director",
approveCeo: "edr_freight_app:bookings:approve_ceo",
rejectApproval: "edr_freight_app:bookings:reject_approval",
generateContract: "edr_freight_app:bookings:generate_contract",
signStaff: "edr_freight_app:bookings:sign_staff",
operations: "edr_freight_app:bookings:operations",
cancel: "edr_freight_app:bookings:cancel",
},
} as const;
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
slug.replace(/-/g, "_");
export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
if (!user) return [];
if (user.permissionKeys?.length) return user.permissionKeys;
const keys = new Set<string>();
for (const p of user.permissions ?? []) {
if (p.key) keys.add(p.key);
}
for (const emp of user.employee ?? []) {
for (const pos of emp.positions ?? []) {
for (const p of pos.permissions ?? []) {
if (p.key) keys.add(p.key);
}
}
}
return [...keys];
}
export function isSuperAdmin(user: AuthUser | null | undefined): boolean {
if (user?.isSuperAdmin) return true;
return Boolean(user?.roles?.some((r) => r.key === "super_admin"));
}
export function hasPermission(
user: AuthUser | null | undefined,
key: string,
): boolean {
if (!user) return false;
if (isSuperAdmin(user)) return true;
return getPermissionKeys(user).includes(key);
}
export function canAccessBookings(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.view);
}
export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`;
}
export function ruleEngineManageKey(slug: RuleEngineResourceSlug): string {
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`;
}
export function canAccessRuleEngineResource(
user: AuthUser | null | undefined,
slug: RuleEngineResourceSlug,
mode: "view" | "manage",
): boolean {
const key = mode === "manage" ? ruleEngineManageKey(slug) : ruleEngineViewKey(slug);
return hasPermission(user, key);
}
export function canAccessAnyRuleEngineView(
user: AuthUser | null | undefined,
slugs: RuleEngineResourceSlug[],
): boolean {
return slugs.some((slug) => canAccessRuleEngineResource(user, slug, "view"));
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -17,7 +17,6 @@ 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";
@@ -37,6 +36,7 @@ export default function BookingContractPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const iframeRef = useRef<HTMLIFrameElement>(null);
const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
@@ -82,7 +82,10 @@ export default function BookingContractPage() {
}
}, [id, data?.reference]);
const handlePrint = () => window.print();
const handlePrint = () => {
iframeRef.current?.contentWindow?.focus();
iframeRef.current?.contentWindow?.print();
};
const openSign = () => {
setSignerName("");
@@ -135,7 +138,7 @@ export default function BookingContractPage() {
/>
</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">
<div style={{boxShadow:"3px 3px 20px 1px lightgrey"}} 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
@@ -158,9 +161,19 @@ export default function BookingContractPage() {
</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) }}
{!data.hasContractDocument && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 print:hidden">
A PDF has not been stored yet. Download will generate the latest
contract document automatically.
</div>
)}
<iframe
ref={iframeRef}
title={`Contract ${data.reference}`}
srcDoc={data.html}
sandbox="allow-same-origin"
className="mx-auto block min-h-[297mm] w-full max-w-[210mm] rounded-xl border bg-white shadow-sm print:h-[297mm] print:border-0 print:shadow-none"
/>
<Dialog open={signOpen} onOpenChange={setSignOpen}>
@@ -210,9 +223,3 @@ export default function BookingContractPage() {
</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

@@ -13,17 +13,20 @@ import {
RefreshCw,
Train,
Truck,
Weight,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
import { bookingSurface } from "@/components/bookings/booking-ui.styles";
import {
bookingGlass,
bookingSurface,
} from "@/components/bookings/booking-ui.styles";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import {
@@ -49,7 +52,7 @@ export default function BookingRequestDetailPage() {
return (
<div className={bookingSurface.page}>
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-8">
<Loader2 className="size-10 animate-spin text-primary" />
<Loader2 className="size-10 animate-spin text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">
Loading booking
</p>
@@ -68,8 +71,13 @@ export default function BookingRequestDetailPage() {
"mx-auto max-w-md p-12 text-center",
)}
>
<div className="mx-auto flex size-16 items-center justify-center rounded-2xl bg-muted">
<Package className="size-8 text-muted-foreground" />
<div
className={cn(
"mx-auto flex size-16 items-center justify-center rounded-2xl",
bookingGlass.iconWellGreen,
)}
>
<Package className="size-8" />
</div>
<h1 className="mt-6 text-xl font-bold text-foreground">
Booking not found
@@ -107,31 +115,54 @@ export default function BookingRequestDetailPage() {
<div className={bookingSurface.detailHero}>
<div className={bookingSurface.heroGlow} />
<div className={bookingSurface.heroSheen} />
<div className="relative p-6 sm:p-8">
<div className="mb-4">
<Button
variant="ghost"
size="sm"
className="-ml-2 gap-2 text-muted-foreground hover:text-foreground"
onClick={() => navigate("/dashboard/booking-requests")}
>
<ArrowLeft className="size-4" />
Back to list
</Button>
</div>
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div className="flex gap-4">
<div className="flex size-16 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg shadow-primary/20">
<Package className="size-7" />
<div
className={cn(
"flex size-16 shrink-0 items-center justify-center rounded-2xl",
bookingGlass.iconWellGreen,
)}
>
<Package className="size-7" strokeWidth={1.75} />
</div>
<div className="min-w-0 space-y-3">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Booking reference
</p>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
<h1 className="text-2xl font-semibold tracking-tight text-foreground sm:text-[1.75rem]">
{booking.reference}
</h1>
<BookingStatusBadge status={booking.status} />
<BookingPriorityBadge score={booking.priorityScore} />
</div>
{booking.nextStep && (
<NextStepBanner nextStep={booking.nextStep} className="max-w-xl" />
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1.5 font-medium text-foreground">
<Building2 className="size-4 text-primary" />
<Building2 className="size-4 opacity-70" />
{row.customerLabel}
</span>
<span className="inline-flex items-center gap-1.5">
<Calendar className="size-4" />
<Calendar className="size-4 opacity-70" />
Scheduled {booking.scheduledDate}
</span>
<span className="inline-flex items-center gap-1.5">
<Clock className="size-4" />
<Clock className="size-4 opacity-70" />
Created{" "}
{new Date(booking.createdAt).toLocaleDateString(undefined, {
dateStyle: "medium",
@@ -142,11 +173,11 @@ export default function BookingRequestDetailPage() {
</div>
<div className="flex flex-col items-stretch gap-3 sm:items-end">
<div className="rounded-xl border border-primary/20 bg-background/80 px-5 py-4 text-right shadow-sm backdrop-blur-sm">
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<div className={cn(bookingSurface.valueCard, "min-w-[12rem] text-right")}>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Total value
</p>
<p className="mt-1 font-mono text-2xl font-bold tabular-nums text-foreground">
<p className="mt-1 font-mono text-2xl font-semibold tabular-nums text-foreground">
{booking.paymentCurrency}{" "}
{amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
@@ -159,7 +190,7 @@ export default function BookingRequestDetailPage() {
<Button
variant="outline"
size="sm"
className="gap-2 self-end"
className="gap-2 self-end border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
disabled={isFetching}
onClick={() => refetch()}
>
@@ -191,7 +222,7 @@ export default function BookingRequestDetailPage() {
title="Contract summary"
subtitle="Generated terms"
>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-lg border border-border/60 bg-muted/20 p-4 font-mono text-xs leading-relaxed text-muted-foreground">
<pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-lg border border-border/50 bg-muted/10 p-4 font-mono text-xs leading-relaxed text-muted-foreground backdrop-blur-sm">
{booking.contractSummary}
</pre>
</SectionShell>
@@ -213,7 +244,8 @@ export default function BookingRequestDetailPage() {
<div className={bookingSurface.sectionCard}>
<div className="px-5 py-4">
<Button
className="w-full gap-2"
className="w-full gap-2 shadow-sm"
variant="default"
onClick={() =>
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
}
@@ -249,9 +281,7 @@ function SectionShell({
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">
{icon}
</div>
<div className={bookingSurface.sectionIcon}>{icon}</div>
<div>
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
{subtitle && (
@@ -277,14 +307,22 @@ function RouteCard({
title="Route & service"
subtitle="Corridor and service level"
>
<div className="flex flex-col items-stretch gap-6 rounded-xl border border-dashed border-border bg-muted/15 p-5 md:flex-row md:items-center md:justify-between">
<div
className={cn(
"flex flex-col items-stretch gap-6 rounded-xl border border-dashed border-emerald-500/20 p-5 backdrop-blur-sm md:flex-row md:items-center md:justify-between",
bookingGlass.activeTab,
)}
>
<RouteEndpoint label="Origin" station={row.originLabel} />
<div className="flex flex-col items-center gap-2 px-4">
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<Train className="size-5" />
<div className={cn("flex size-10 items-center justify-center rounded-full", bookingGlass.iconWellGreen)}>
<Train className="size-5 text-black" strokeWidth={1.75} />
</div>
<ArrowRight className="size-5 rotate-90 text-muted-foreground md:rotate-0" />
<Badge variant="outline" className="text-[10px] font-semibold uppercase">
<Badge
variant="outline"
className="border-border/50 bg-background/50 text-[10px] font-medium uppercase backdrop-blur-sm"
>
{booking.serviceType?.label ??
booking.serviceType?.code ??
"Rail service"}
@@ -369,10 +407,10 @@ function CargoCard({ booking }: { booking: BookingDetail }) {
{containers.length > 0 && (
<>
<Separator className="my-5" />
<div className="overflow-hidden rounded-lg border border-border">
<div className="overflow-hidden rounded-lg border border-border/50 backdrop-blur-sm">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30 text-left text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<tr className="border-b border-border/50 bg-muted/20 text-left text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<th className="px-4 py-3">Container type</th>
<th className="px-4 py-3">Qty</th>
<th className="px-4 py-3">VGM / unit</th>
@@ -415,14 +453,14 @@ function RouteEndpoint({
}) {
return (
<div className="flex min-w-0 items-center gap-3 md:max-w-[14rem]">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm">
<MapPin className="size-5" />
<div className={bookingSurface.sectionIconLg}>
<MapPin className="size-5" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</p>
<p className="truncate text-sm font-bold text-foreground">{station}</p>
<p className="truncate text-sm font-semibold text-foreground">{station}</p>
</div>
</div>
);
@@ -444,10 +482,10 @@ function MetricTile({
highlight && "border-amber-300/50 bg-amber-50/50 dark:bg-amber-950/20",
)}
>
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</p>
<p className="mt-1.5 text-sm font-semibold leading-snug text-foreground">
<p className="mt-1.5 text-sm font-medium leading-snug text-foreground">
{value}
</p>
</div>

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
AlertCircle,
@@ -23,12 +23,18 @@ import {
} from "@/components/bookings/BookingStatusTabs";
import { BookingStatGrid } from "@/components/bookings/BookingStatGrid";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
import { bookingInput, bookingSurface } from "@/components/bookings/booking-ui.styles";
import {
bookingGlass,
bookingInput,
bookingSurface,
bookingTable,
} 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 { useBookingList, useBookingListSummary } from "@/hooks/bookings/useBookings";
import type { BookingListFilter } from "@/services/bookings.service";
import type { BookingListRow } from "@/types/booking";
import { cn } from "@/lib/utils";
@@ -42,16 +48,26 @@ import {
Input,
} from "@edr/ui-common";
function getStatusForTab(tab: BookingStatusTabKey): string | undefined {
function getStatusesForTab(tab: BookingStatusTabKey): string | undefined {
const match = BOOKING_LIST_TABS.find((t) => t.key === tab);
return match?.status ?? undefined;
if (!match?.statuses?.length) return undefined;
return match.statuses.join(",");
}
export default function BookingRequestsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("SUBMITTED");
const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("in_approval");
const suppressRowClickRef = useRef(false);
const suppressRowClick = useCallback(() => {
suppressRowClickRef.current = true;
window.setTimeout(() => {
suppressRowClickRef.current = false;
}, 400);
}, []);
const tabStatuses = getStatusesForTab(activeTab);
const filter: BookingListFilter = useMemo(
() => ({
@@ -59,12 +75,18 @@ export default function BookingRequestsPage() {
pageSize: pagination.pageSize,
sortBy: "createdAt",
sortOrder: "DESC",
...(getStatusForTab(activeTab) ? { status: getStatusForTab(activeTab) } : {}),
tab: activeTab,
...(tabStatuses ? { statuses: tabStatuses } : {}),
}),
[pagination.pageIndex, pagination.pageSize, activeTab],
[pagination.pageIndex, pagination.pageSize, activeTab, tabStatuses],
);
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
const {
data: summary,
isLoading: summaryLoading,
refetch: refetchSummary,
} = useBookingListSummary(filter);
const rows = useMemo(() => {
const items = (data?.items ?? []).map(toBookingListRow);
@@ -82,26 +104,39 @@ export default function BookingRequestsPage() {
const hasSearch = query.trim().length > 0;
const showEmpty = !isLoading && !isError && rows.length === 0;
const pendingCount = rows.filter(
(b) => b.status === "SUBMITTED" || b.status === "PENDING_APPROVAL",
).length;
const urgentCount = rows.filter((b) => b.priorityScore >= 1000).length;
const metrics = summary?.metrics;
const tabCounts = summary?.tabs;
const statValue = (value: number | undefined) =>
summaryLoading ? "—" : (value ?? 0);
const handleRefresh = useCallback(() => {
void refetch();
void refetchSummary();
}, [refetch, refetchSummary]);
const handleRowClick = useCallback(
(row: BookingListRow) => {
if (suppressRowClickRef.current) return;
navigate(`/dashboard/booking-requests/${row.id}`);
},
[navigate],
);
const columns: ColumnDef<BookingListRow>[] = [
{
id: "booking",
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Booking</span>,
header: () => <span className={bookingTable.headerCell}>Booking</span>,
cell: ({ row }) => {
const b = row.original;
return (
<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 className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<Package className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{b.reference}</p>
<p className="truncate font-medium 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" />
<User className="size-3 shrink-0 opacity-70" />
{b.customerLabel}
</p>
</div>
@@ -111,7 +146,7 @@ export default function BookingRequestsPage() {
},
{
id: "route",
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Route</span>,
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => {
const b = row.original;
return (
@@ -122,10 +157,16 @@ export default function BookingRequestsPage() {
<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">
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
>
{b.tradeDirection}
</Badge>
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-medium">
<Badge
variant="secondary"
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
>
{b.freightType}
</Badge>
</div>
@@ -135,12 +176,19 @@ export default function BookingRequestsPage() {
},
{
id: "status",
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Status</span>,
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => <BookingStatusBadge status={row.original.status} />,
},
{
id: "approval",
header: () => (
<span className={bookingTable.headerCell}>Approval</span>
),
cell: ({ row }) => <BookingApprovalProgressCell row={row.original} />,
},
{
id: "scheduled",
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Scheduled</span>,
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" />
@@ -150,7 +198,7 @@ export default function BookingRequestsPage() {
},
{
id: "priority",
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Priority</span>,
header: () => <span className={bookingTable.headerCell}>Priority</span>,
cell: ({ row }) => (
<BookingPriorityBadge score={row.original.priorityScore} />
),
@@ -158,7 +206,7 @@ export default function BookingRequestsPage() {
{
id: "amount",
header: () => (
<span className="text-xs font-semibold uppercase tracking-wider">Amount</span>
<span className={bookingTable.headerCell}>Amount</span>
),
cell: ({ row }) => {
const b = row.original;
@@ -176,12 +224,14 @@ export default function BookingRequestsPage() {
id: "actions",
size: 140,
header: () => (
<span className="text-xs font-semibold uppercase tracking-wider">
Actions
</span>
<span className={bookingTable.headerCell}>Actions</span>
),
cell: ({ row }) => (
<BookingActionsMenu row={row.original} variant="table" />
<BookingActionsMenu
row={row.original}
variant="table"
onSuppressRowClick={suppressRowClick}
/>
),
},
];
@@ -193,13 +243,22 @@ export default function BookingRequestsPage() {
<div className={bookingSurface.hero}>
<div className={bookingSurface.heroGlow} />
<div className={bookingSurface.heroSheen} />
<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
className={cn(
"flex size-14 shrink-0 items-center justify-center rounded-2xl",
bookingGlass.iconWellGreen,
)}
>
<Inbox className="size-6" strokeWidth={1.75} />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Operations
</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-foreground sm:text-[1.75rem]">
Booking requests
</h1>
<p className="mt-1.5 max-w-xl text-sm leading-relaxed text-muted-foreground">
@@ -211,9 +270,9 @@ export default function BookingRequestsPage() {
<Button
variant="outline"
size="sm"
className="gap-2"
className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
disabled={isFetching}
onClick={() => refetch()}
onClick={handleRefresh}
>
<RefreshCw
className={cn("size-4", isFetching && "animate-spin")}
@@ -228,29 +287,33 @@ export default function BookingRequestsPage() {
items={[
{
label: "In queue",
value: total,
value: statValue(metrics?.inQueue),
hint: "Total matching filter",
icon: LayoutList,
},
{
label: "On this page",
value: rows.length,
hint: "Current view",
value: statValue(metrics?.onThisPage),
hint: "Current page",
icon: FileText,
},
{
label: "Needs action",
value: pendingCount,
value: statValue(metrics?.needsAction),
hint: "Submitted or pending approval",
icon: Clock,
accent: "amber",
accent:
!summaryLoading && (metrics?.needsAction ?? 0) > 0
? "amber"
: "default",
},
{
label: "Urgent",
value: urgentCount,
value: statValue(metrics?.urgent),
hint: "High priority score",
icon: AlertCircle,
accent: urgentCount > 0 ? "rose" : "default",
accent:
!summaryLoading && (metrics?.urgent ?? 0) > 0 ? "rose" : "default",
},
]}
/>
@@ -261,9 +324,7 @@ export default function BookingRequestsPage() {
setActiveTab(tab);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
counts={{
[activeTab]: total,
}}
counts={tabCounts}
/>
<div className={bookingSurface.panel}>
@@ -290,7 +351,11 @@ export default function BookingRequestsPage() {
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="hidden text-xs text-muted-foreground sm:inline">
<span
className={cn(
"hidden rounded-md border border-border/50 bg-background/50 px-2.5 py-1 text-xs text-muted-foreground backdrop-blur-sm sm:inline",
)}
>
{total} record{total !== 1 ? "s" : ""}
</span>
</div>
@@ -300,7 +365,7 @@ export default function BookingRequestsPage() {
<BookingTableEmpty
isError={isError}
hasSearch={hasSearch}
onRetry={() => refetch()}
onRetry={handleRefresh}
/>
) : (
<div className={bookingSurface.tableWrap}>
@@ -308,9 +373,7 @@ export default function BookingRequestsPage() {
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) =>
navigate(`/dashboard/booking-requests/${row.id}`)
}
onRowClick={handleRowClick}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
@@ -323,7 +386,13 @@ export default function BookingRequestsPage() {
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"
containerClassName={cn(
"border-0 shadow-none",
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
)}
footer={DataTableFooter}
/>
</div>

View File

@@ -1,5 +1,7 @@
import { useCallback, useMemo, useState } from "react";
import { Navigate, useLocation, useParams } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { canAccessRuleEngineResource } from "@/lib/permissions";
import type { ColumnDef } from "@tanstack/react-table";
import { Loader2 } from "lucide-react";
@@ -54,6 +56,7 @@ const pathCategory = (pathname: string): RuleEngineNavCategory | undefined => {
};
const RuleEngineResourcePage = () => {
const { user } = useAuth();
const { resource: resourceSlug } = useParams<{ resource: string }>();
const location = useLocation();
const category = pathCategory(location.pathname);
@@ -75,6 +78,13 @@ const RuleEngineResourcePage = () => {
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
);
const canView = Boolean(
config && canAccessRuleEngineResource(user, config.slug, "view"),
);
const canManage = Boolean(
config && canAccessRuleEngineResource(user, config.slug, "manage"),
);
const listParams = useMemo(
() => ({
search: config?.supportsSearch ? search.trim() || undefined : undefined,
@@ -207,6 +217,7 @@ const RuleEngineResourcePage = () => {
<RuleEngineRecordActions
record={row.original}
config={config}
readOnly={!canManage}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
@@ -215,15 +226,15 @@ const RuleEngineResourcePage = () => {
onViewChain={
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
}
onSubmitRate={(id) => submit.mutate(id)}
onApproveRate={handleApproveRate}
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
onApproveRate={canManage ? handleApproveRate : undefined}
/>
</div>
),
});
return base;
}, [config, submit, handleApproveRate]);
}, [canManage, config, submit, handleApproveRate]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
@@ -235,6 +246,10 @@ const RuleEngineResourcePage = () => {
return <Navigate to={defaultPath} replace />;
}
if (!canView) {
return <Navigate to="/dashboard/overview" replace />;
}
const openCreate = () => {
setEditing(null);
setFormOpen(true);
@@ -279,7 +294,7 @@ const RuleEngineResourcePage = () => {
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
searchPlaceholder={config.searchPlaceholder}
onAdd={openCreate}
onAdd={canManage ? openCreate : undefined}
addLabel={`Add ${config.label.replace(/s$/, "")}`}
viewMode={viewMode}
onViewModeChange={setViewMode}
@@ -333,13 +348,14 @@ const RuleEngineResourcePage = () => {
itemLabel={itemLabel}
table={cardTable}
pagination={paginationState}
onEdit={openEdit}
onDelete={setDeleteTarget}
readOnly={!canManage}
onEdit={canManage ? openEdit : undefined}
onDelete={canManage ? setDeleteTarget : undefined}
onViewChain={
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
}
onSubmitRate={(id) => submit.mutate(id)}
onApproveRate={handleApproveRate}
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
onApproveRate={canManage ? handleApproveRate : undefined}
/>
)}
</Card>

View File

@@ -305,16 +305,10 @@ export const api = {
bookingsService.signContract(id, payload),
),
generatePnr: endpoint<{ id: string }, BookingDetail>(
payBooking: endpoint<{ id: string }, BookingDetail>(
"bookings",
"generatePnr",
({ id }) => bookingsService.generatePnr(id),
),
verifyPayment: endpoint<{ id: string }, BookingDetail>(
"bookings",
"verifyPayment",
({ id }) => bookingsService.verifyPayment(id),
"payBooking",
({ id }) => bookingsService.payBooking(id),
),
startTransit: endpoint<{ id: string }, BookingDetail>(

View File

@@ -7,6 +7,10 @@ const B = URL_CONSTANTS.BOOKINGS;
export interface BookingListFilter {
status?: string;
/** Comma-separated statuses for grouped tabs */
statuses?: string;
/** Tab key for React Query cache (not sent to API) */
tab?: string;
// customerId?: string;
companyId?: string;
freightType?: string;
@@ -23,6 +27,29 @@ export interface PaginatedBookings {
total: number;
}
export interface BookingListSummaryMetrics {
inQueue: number;
onThisPage: number;
needsAction: number;
urgent: number;
}
export interface BookingListSummaryTabs {
all: number;
intake: number;
in_approval: number;
approved_contract: number;
payment: number;
operations: number;
completed: number;
closed: number;
}
export interface BookingListSummary {
metrics: BookingListSummaryMetrics;
tabs: BookingListSummaryTabs;
}
export interface ApproveStepPayload {
id: string;
stepId: string;
@@ -66,9 +93,41 @@ async function postBooking<T>(url: string, body?: unknown): Promise<T> {
}
export const bookingsService = {
getListSummary: async (filter?: BookingListFilter): Promise<BookingListSummary> => {
const params: Record<string, string | number | boolean | undefined> = {};
if (filter) {
if (filter.statuses) params.statuses = filter.statuses;
else if (filter.status) params.status = filter.status;
if (filter.page != null) params.page = filter.page;
if (filter.pageSize != null) params.pageSize = filter.pageSize;
if (filter.companyId) params.companyId = filter.companyId;
if (filter.freightType) params.freightType = filter.freightType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
}
const response = await client.get<BookingListSummary>(B.LIST_SUMMARY, {
params,
});
return unwrap(response.data) as BookingListSummary;
},
list: async (filter?: BookingListFilter): Promise<PaginatedBookings> => {
const params: Record<string, string | number | boolean | undefined> = {};
if (filter) {
if (filter.statuses) params.statuses = filter.statuses;
else if (filter.status) params.status = filter.status;
// filter.tab is intentionally omitted from API params
if (filter.page != null) params.page = filter.page;
if (filter.pageSize != null) params.pageSize = filter.pageSize;
if (filter.sortBy) params.sortBy = filter.sortBy;
if (filter.sortOrder) params.sortOrder = filter.sortOrder;
if (filter.companyId) params.companyId = filter.companyId;
if (filter.freightType) params.freightType = filter.freightType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
}
const response = await client.get<PaginatedBookings>(B.BASE, {
params: filter,
params,
});
const data = unwrap(response.data);
return {
@@ -112,14 +171,14 @@ export const bookingsService = {
const response = await client.get(B.CONTRACT_DOWNLOAD(id), {
responseType: "blob",
});
return response.data as Blob;
return ensurePdfBlob(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;
return ensurePdfBlob(response.data as Blob);
},
signContract: (id: string, payload: SignContractPayload) =>
@@ -142,26 +201,7 @@ export const bookingsService = {
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;
},
payBooking: (id: string) => postBooking<BookingDetail>(B.PAYMENT_PAY(id)),
startTransit: (id: string) =>
postBooking<BookingDetail>(B.START_TRANSIT(id)),
@@ -171,3 +211,11 @@ export const bookingsService = {
cancel: (id: string, reason: string) =>
postBooking<BookingDetail>(B.CANCEL(id), { reason }),
};
async function ensurePdfBlob(blob: Blob): Promise<Blob> {
if (blob.type.includes("application/json")) {
const body = JSON.parse(await blob.text()) as { message?: string };
throw new Error(body.message ?? "Contract PDF download failed");
}
return blob;
}

View File

@@ -48,11 +48,27 @@ export interface BookingApprovalStep {
id: string;
stepOrder: number;
requiredRole: string;
blocksRole?: string | null;
status: "PENDING" | "APPROVED" | "REJECTED" | "SKIPPED";
actionedAt?: string | null;
remarks?: string | null;
}
export interface BookingNextStep {
action: string;
description: string;
requiredRole?: string;
}
export interface InAppPaymentReceipt {
success: boolean;
provider: string;
providerRef: string;
amount: number;
currency: string;
paidAt: string;
}
export interface BookingReviewNote {
id: string;
note: string;
@@ -90,6 +106,8 @@ export interface BookingDetail {
equipmentReturn?: string;
contractSummary?: string | null;
latestChangeRequestNote?: string | null;
nextStep?: BookingNextStep | null;
paymentReceipt?: InAppPaymentReceipt;
createdAt: string;
updatedAt: string;
// customer?: BookingNamedRef & { companyName?: string };
@@ -114,6 +132,7 @@ export interface BookingListRow {
id: string;
reference: string;
customerLabel: string;
approvalSteps?: BookingApprovalStep[];
status: BookingStatus;
scheduledDate: string;
totalAmount: number;

View File

@@ -3,7 +3,9 @@ import {
ExecutionContext,
Injectable,
NestInterceptor,
StreamableFile,
} from "@nestjs/common";
import { Readable } from "stream";
import { Observable } from "rxjs";
import { map } from "rxjs/operators";
@@ -16,18 +18,28 @@ export interface StandardResponse<T> {
@Injectable()
export class ResponseTransformInterceptor<T> implements NestInterceptor<
T,
StandardResponse<T>
StandardResponse<T> | T
> {
intercept(
_context: ExecutionContext,
next: CallHandler<T>,
): Observable<StandardResponse<T>> {
): Observable<StandardResponse<T> | T> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
map((data) => {
if (
data instanceof StreamableFile ||
data instanceof Buffer ||
data instanceof Readable
) {
return data;
}
return {
success: true,
data,
timestamp: new Date().toISOString(),
};
}),
);
}
}

View File

@@ -97,7 +97,30 @@ export function DataTable<TData, TValue>({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={() => onRowClick?.(row.original)}
onClick={(event) => {
if (!onRowClick) return;
const target = event.target as HTMLElement;
if (
target.closest(
[
"button",
"a",
"input",
"textarea",
"select",
"[role='menu']",
"[role='menuitem']",
"[data-slot='dialog-content']",
"[data-slot='dialog-overlay']",
"[data-slot='dropdown-menu-content']",
"[data-stop-row-click]",
].join(","),
)
) {
return;
}
onRowClick(row.original);
}}
role={onRowClick ? "button" : ""}
className={
onRowClick