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= SUPER_ADMIN_PHONE=
DEFAULT_PASSWORD=password@tria 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 (used by @tria-plc/iamapi-common for file storage)
MINIO_ENDPOINT=localhost MINIO_ENDPOINT=localhost
MINIO_PORT=9000 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 { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
import { BackofficeModule } from "./modules/backoffice/backoffice.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module";
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module"; import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
import { FreightAuthModule } from "./modules/auth/freight-auth.module";
import { import {
EDR_FREIGHT_APPLICATION, EDR_FREIGHT_APPLICATION,
EDR_FREIGHT_PERMISSIONS, EDR_FREIGHT_PERMISSIONS,
} from "./seed/edr-freight.seed"; } from "./seed/edr-freight.seed";
import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
@Module({ @Module({
imports: [ imports: [
@@ -70,19 +72,22 @@ import { DemoUsersSeeder } from "./seed/demo-users.seeder";
RuleEngineModule, RuleEngineModule,
BackofficeModule, BackofficeModule,
DemoPermissionsModule, DemoPermissionsModule,
FreightAuthModule,
], ],
providers: [EdrOrgSeeder, DemoUsersSeeder], providers: [EdrOrgSeeder, DemoUsersSeeder, FreightStaffUsersSeeder],
}) })
export class AppModule implements OnApplicationBootstrap { export class AppModule implements OnApplicationBootstrap {
constructor( constructor(
private readonly seeder: DataSeeder, private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder, private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder, private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
) { } ) { }
async onApplicationBootstrap() { async onApplicationBootstrap() {
await this.seeder.run(); await this.seeder.run();
await this.edrOrgSeeder.run(); await this.edrOrgSeeder.run();
await this.demoUsersSeeder.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() @Injectable()
export class ContractPdfService { export class ContractPdfService {
private readonly logger = new Logger(ContractPdfService.name); private readonly logger = new Logger(ContractPdfService.name);
async htmlToPdfBuffer(html: string): Promise<Buffer> { async htmlToPdfBuffer(html: string): Promise<Buffer> {
const preparedHtml = this.injectPdfPrintStyles(html);
const executablePath = this.resolveExecutablePath();
try { try {
const puppeteer = await import('puppeteer'); const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({ const launchOptions: import('puppeteer').LaunchOptions = {
headless: true, 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 { try {
const page = await browser.newPage(); 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({ const pdf = await page.pdf({
format: 'A4', format: 'A4',
printBackground: true, 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 { } finally {
await browser.close(); await browser.close();
} }
} catch (err) { } catch (err) {
this.logger.warn( this.logger.error(
`Puppeteer PDF failed, falling back to minimal PDF stub: ${err}`, `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 injectPdfPrintStyles(html: string): string {
private fallbackPdfBuffer(html: string): Buffer { if (html.includes('contract-pdf-print-fix')) return html;
const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').slice(0, 2000); if (html.includes('</head>')) {
const escaped = text.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); return html.replace('</head>', `${PDF_PRINT_STYLES}</head>`);
const stream = `BT /F1 10 Tf 50 750 Td (${escaped}) Tj ET`; }
const len = stream.length; return `${PDF_PRINT_STYLES}${html}`;
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 private resolveExecutablePath(): string | undefined {
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 const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
4 0 obj<< /Length ${len} >>stream if (fromEnv && existsSync(fromEnv)) return fromEnv;
${stream}
endstream endobj const candidates = [
5 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>endobj '/usr/bin/chromium',
xref '/usr/bin/chromium-browser',
0 6 '/usr/bin/google-chrome-stable',
0000000000 65535 f '/usr/bin/google-chrome',
trailer<< /Size 6 /Root 1 0 R >> ];
startxref return candidates.find((p) => existsSync(p));
0 }
%%EOF`;
return Buffer.from(pdf, 'utf-8'); 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, totalAmount,
currency, currency,
equipmentReturn: booking.equipmentReturn ?? undefined, equipmentReturn: booking.equipmentReturn ?? '—',
originLabel: booking.originYard?.label ?? booking.originYard?.code ?? '—', originLabel: booking.originYard?.label ?? booking.originYard?.code ?? '—',
destinationLabel: destinationLabel:
booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—', booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—',

View File

@@ -23,6 +23,31 @@ describe('ContractRendererService', () => {
phone: '+251900000000', phone: '+251900000000',
email: 'test@example.com', email: 'test@example.com',
tinNumber: '1234567890', 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: { pricing: {
lineItems: [{ label: 'RAIL', description: 'Rail transport', amount: 1000, currency: 'ETB' }], lineItems: [{ label: 'RAIL', description: 'Rail transport', amount: 1000, currency: 'ETB' }],

View File

@@ -32,6 +32,31 @@ export interface ContractViewModel {
phone: string; phone: string;
email: string; email: string;
tinNumber: 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; pricing: PricingSchedule;
signatures: ContractSignatureView[]; signatures: ContractSignatureView[];
@@ -81,19 +106,24 @@ export class ContractViewModelBuilder {
}), }),
contractYear: new Date().getFullYear(), contractYear: new Date().getFullYear(),
client: { 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', companyName: booking.company?.name ?? 'Client',
companyAddress: booking.company?.address ?? '—', companyAddress: this.valueOrDash(booking.company?.address),
companyLocation: booking.company?.country ?? '—', companyLocation: this.valueOrDash(booking.company?.country),
phone: booking.company?.phone ?? '—', phone: this.valueOrDash(booking.company?.phone),
email: booking.company?.email ?? '—', email: this.valueOrDash(booking.company?.email),
tinNumber: booking.company?.tin ?? '—', 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, pricing,
signatures, signatures,
canSignCustomer: canSignCustomer:
@@ -117,8 +147,61 @@ export class ContractViewModelBuilder {
return { return {
role: row.signerRole, role: row.signerRole,
signerDisplayName: row.signerDisplayName, signerDisplayName: row.signerDisplayName,
signedAt: row.signedAt.toISOString(), signedAt: this.formatDate(row.signedAt),
signatureImageUrl: row.signatureFile?.url ?? null, 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"> <div class="article">
<h2>Article 1: Objective and Scope of Services</h2> <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}} {{#if template.article1.scope.length}}
<p><strong>Scope:</strong></p> <p><strong>1.2 Scope of Services.</strong></p>
<ol> <ol>
{{#each template.article1.scope}} {{#each template.article1.scope}}
<li>{{this}}</li> <li>{{this}}</li>

View File

@@ -1,22 +1,31 @@
<h2>Article 5: Contract Price and Terms of Payment</h2> <h2>Article 5: Contract Price and Terms of Payment</h2>
<div class="article"> <div class="article">
<h3>Contract Price</h3> <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}} {{#if pricing.equipmentReturn}}
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p> <p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
{{/if}} {{/if}}
{{#if pricing.containerLines.length}}
<table class="schedule"> <h3>Charges</h3>
<thead>
<tr><th>Container type</th><th>Quantity</th><th>VGM / unit (t)</th></tr>
</thead>
<tbody>
{{#each pricing.containerLines}}
<tr><td>{{label}}</td><td>{{quantity}}</td><td>{{vgmPerUnitTons}}</td></tr>
{{/each}}
</tbody>
</table>
{{/if}}
<table class="schedule"> <table class="schedule">
<thead> <thead>
<tr><th>Item</th><th>Description</th><th>Amount</th></tr> <tr><th>Item</th><th>Description</th><th>Amount</th></tr>
@@ -29,6 +38,10 @@
<td>{{currency}} {{amount}}</td> <td>{{currency}} {{amount}}</td>
</tr> </tr>
{{/each}} {{/each}}
{{#if pricing.surcharges.length}}
<tr>
<th colspan="3">Surcharges and Adjustments</th>
</tr>
{{#each pricing.surcharges}} {{#each pricing.surcharges}}
<tr> <tr>
<td>{{label}}</td> <td>{{label}}</td>
@@ -36,12 +49,18 @@
<td>{{currency}} {{amount}}</td> <td>{{currency}} {{amount}}</td>
</tr> </tr>
{{/each}} {{/each}}
<tr> {{/if}}
<tr class="total-row">
<td colspan="2"><strong>Total contract value</strong></td> <td colspan="2"><strong>Total contract value</strong></td>
<td><strong>{{pricing.currency}} {{pricing.totalAmount}}</strong></td> <td><strong>{{pricing.currency}} {{pricing.totalAmount}}</strong></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<h3>Terms of payment</h3> <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> </div>

View File

@@ -1,5 +1,6 @@
<div class="article"> <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> <ol>
{{#each template.clientObligations}} {{#each template.clientObligations}}
<li>{{this}}</li> <li>{{this}}</li>
@@ -8,7 +9,8 @@
</div> </div>
<div class="article"> <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> <ol>
{{#each template.providerObligations}} {{#each template.providerObligations}}
<li>{{this}}</li> <li>{{this}}</li>

View File

@@ -1,5 +1,6 @@
<div class="article"> <div class="article">
<h2>Article 6: Contract Documents</h2> <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> <ol>
{{#each template.contractDocuments}} {{#each template.contractDocuments}}
<li>{{this}}</li> <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"> <div class="article">
<h2>Article 4: Force Majeure</h2> <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> </div>

View File

@@ -1,30 +1,44 @@
<div class="signatures"> <div class="signatures">
<div class="sig-block"> <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}} {{#if hasStaffSignature}}
{{#each signatures}} {{#each signatures}}
{{#if (eq role "STAFF")}} {{#if (eq role "STAFF")}}
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Staff signature" />{{/if}} <div class="sig-image-box">
<p>{{signerDisplayName}}</p> {{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Staff signature" />{{/if}}
<p class="sig-line">Signed: {{signedAt}}</p> </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}} {{/if}}
{{/each}} {{/each}}
{{else}} {{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}} {{/if}}
</div> </div>
<div class="sig-block"> <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}} {{#if hasCustomerSignature}}
{{#each signatures}} {{#each signatures}}
{{#if (eq role "CUSTOMER")}} {{#if (eq role "CUSTOMER")}}
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Customer signature" />{{/if}} <div class="sig-image-box">
<p>{{signerDisplayName}}</p> {{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Customer signature" />{{/if}}
<p class="sig-line">Signed: {{signedAt}}</p> </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}} {{/if}}
{{/each}} {{/each}}
{{else}} {{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}} {{/if}}
</div> </div>
</div> </div>

View File

@@ -1,22 +1,258 @@
<style> <style>
* { box-sizing: border-box; } * { 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; } @page { size: A4; margin: 18mm 14mm; }
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; } body {
h3 { font-size: 11pt; margin: 14px 0 6px; } margin: 0;
.cover { text-align: center; margin-bottom: 32px; border-bottom: 2px solid #1e3a5f; padding-bottom: 24px; } background: #f5f7fb;
.meta { margin: 12px 0; } color: #111827;
.meta strong { display: inline-block; min-width: 140px; } font-family: "Times New Roman", Times, serif;
.article { margin-bottom: 16px; } font-size: 10.5pt;
.article ol { padding-left: 20px; } line-height: 1.48;
.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; } .contract {
table.schedule th { background: #f0f4f8; } width: 210mm;
.signatures { display: flex; gap: 40px; margin-top: 40px; page-break-inside: avoid; } min-height: 297mm;
.sig-block { flex: 1; } margin: 0 auto;
.sig-block img { max-height: 64px; max-width: 200px; display: block; margin: 8px 0; } background: #fff;
.sig-line { border-top: 1px solid #000; margin-top: 48px; padding-top: 4px; font-size: 10pt; } padding: 18mm 15mm;
.pending-banner { background: #fff8e6; border: 1px solid #e6c200; padding: 8px 12px; margin-bottom: 16px; font-size: 10pt; } }
@media print { body { padding: 0; } }
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> </style>

View File

@@ -6,25 +6,86 @@
{{> styles}} {{> styles}}
</head> </head>
<body> <body>
<div class="cover"> <main class="contract">
<h1>Contract Agreement</h1> <section class="cover page-section">
<h1>{{template.title}}</h1> <div class="brand-row">
<p class="meta"><strong>Contract Ref No:</strong> {{reference}}</p> <div class="logo-mark">EDR</div>
<p class="meta"><strong>Year:</strong> {{contractYear}}</p> <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> <div class="cover-title">
<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> <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> <table class="meta-grid">
<p>{{template.whereas}}</p> <tr>
<p>Now therefore, the parties agree as follows:</p> <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}} <section class="page-section">
{{> articles_obligations}} <h2>Parties to the Agreement</h2>
{{> article5_pricing}} <p class="lead">
{{> force_majeure}} This Contract Agreement is made on <strong>{{contractDate}}</strong> between the Service Provider and the Client named below.
{{> contract_documents}} </p>
{{> signatures_block}}
<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> </body>
</html> </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 { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { MinioService } from '../minio/minio.service'; import { MinioService } from '../minio/minio.service';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { FileRecord } from '../files/entities/file.entity';
import { BookingsRepository } from './bookings.repository'; import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity'; import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util'; import { assertBookingStatus } from './booking-status.util';
@@ -68,7 +69,7 @@ export class BookingContractService {
async getContractView(bookingId: string): Promise<ContractViewDto> { async getContractView(bookingId: string): Promise<ContractViewDto> {
const { view } = await this.viewModelBuilder.build(bookingId); const { view } = await this.viewModelBuilder.build(bookingId);
await this.enrichSignatureUrls(view.signatures); await this.inlineSignatureImages(view.signatures);
const html = this.renderer.render(view); const html = this.renderer.render(view);
return { return {
bookingId: view.bookingId, bookingId: view.bookingId,
@@ -90,33 +91,8 @@ export class BookingContractService {
assertBookingStatus(booking, ['APPROVED']); assertBookingStatus(booking, ['APPROVED']);
const templateKey = this.templateResolver.resolve(booking); 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 summary = this.buildContractSummary(booking);
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
const file: Express.Multer.File = {
fieldname: 'contract',
originalname: `contract-${booking.reference}.pdf`,
encoding: '7bit',
mimetype: 'application/pdf',
size: pdfBuffer.length,
buffer: pdfBuffer,
stream: Readable.from(pdfBuffer),
destination: '',
filename: '',
path: '',
};
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'contract',
file,
});
const now = new Date(); const now = new Date();
const updated = await this.bookingsRepository.update(bookingId, { const updated = await this.bookingsRepository.update(bookingId, {
@@ -129,18 +105,15 @@ export class BookingContractService {
} }
async streamContract(bookingId: string) { async streamContract(bookingId: string) {
try { const booking = await this.requireBooking(bookingId);
const record = await this.filesService.findByCode( const templateKey =
bookingId, booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
'bookings', const record = await this.upsertContractPdf(
'contract', bookingId,
); booking.reference,
return this.filesService.streamById(record.id); templateKey,
} catch { );
throw new NotFoundException( return this.filesService.streamById(record.id);
'Contract document not found. Generate the contract first.',
);
}
} }
async signContract( async signContract(
@@ -218,33 +191,84 @@ export class BookingContractService {
} }
const updated = await this.bookingsRepository.update(bookingId, updates as never); const updated = await this.bookingsRepository.update(bookingId, updates as never);
await this.upsertContractPdf(
bookingId,
booking.reference,
booking.contractTemplateKey ?? this.templateResolver.resolve(booking),
);
return updated!; return updated!;
} }
async getSignatures(bookingId: string) { async getSignatures(bookingId: string) {
const rows = await this.bookingsRepository.findContractSignatures(bookingId); const rows = await this.bookingsRepository.findContractSignatures(bookingId);
const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r)); const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r));
await this.enrichSignatureUrls(views); await this.inlineSignatureImages(views);
return { signatures: 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 }>, signatures: Array<{ signatureImageUrl?: string | null }>,
): Promise<void> { ): Promise<void> {
for (const sig of signatures) { for (const sig of signatures) {
if (!sig.signatureImageUrl) continue; if (!sig.signatureImageUrl) continue;
try { try {
const objectName = this.extractObjectName(sig.signatureImageUrl); if (sig.signatureImageUrl.startsWith('data:')) continue;
sig.signatureImageUrl = await this.minioService.getSignedUrl(objectName, 3600); 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 { } catch {
/* keep original url */ /* keep original url */
} }
} }
} }
private extractObjectName(url: string): string { private streamToBuffer(stream: Readable): Promise<Buffer> {
const parts = url.split('/'); return new Promise((resolve, reject) => {
return parts.slice(4).join('/'); 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 { 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 { import { Injectable, NotFoundException } from '@nestjs/common';
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { FilesService } from '../files/files.service';
import { BookingsRepository } from './bookings.repository'; import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity'; import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util'; import { assertBookingStatus } from './booking-status.util';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
const PROOF_MAX_BYTES = 5 * 1024 * 1024; export interface InAppPaymentReceipt extends InAppPaymentReceiptDto {}
const PROOF_MIMES = ['application/pdf', 'image/jpeg', 'image/png'];
@Injectable() @Injectable()
export class BookingPaymentService { export class BookingPaymentService {
constructor( constructor(private readonly bookingsRepository: BookingsRepository) {}
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
) {}
async generatePnr(bookingId: string): Promise<Booking> { async pay(
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED']);
if (booking.paymentCurrency !== 'ETB') {
throw new BadRequestException('PNR generation is only for ETB payers');
}
const year = new Date().getFullYear();
const pnrCode = `PNR-${year}-${Math.random().toString(36).slice(2, 10).toUpperCase()}`;
const updated = await this.bookingsRepository.update(bookingId, {
status: 'PNR_GENERATED',
pnrCode,
paymentStatus: 'PNR_GENERATED',
} as never);
return updated!;
}
async submitPaymentProof(
bookingId: string, bookingId: string,
file: Express.Multer.File, ): Promise<{ booking: Booking; receipt: InAppPaymentReceipt }> {
): Promise<Booking> {
const booking = await this.requireBooking(bookingId); const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED']); assertBookingStatus(booking, ['FULLY_EXECUTED']);
if (booking.paymentCurrency !== 'USD') { const receipt = this.buildMockReceipt(booking);
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 updated = await this.bookingsRepository.update(bookingId, { const updated = await this.bookingsRepository.update(bookingId, {
status: 'PAID', status: 'PAID',
paymentStatus: 'PAID', paymentStatus: 'PAID',
} as never); } as never);
return updated!;
return { booking: updated!, receipt };
} }
async handleBankCallback(pnrCode: string): Promise<Booking> { private buildMockReceipt(booking: Booking): InAppPaymentReceipt {
const booking = await this.bookingsRepository.findByPnrCode(pnrCode); const timestamp = Date.now();
if (!booking) { const isEtb = booking.paymentCurrency === 'ETB';
throw new NotFoundException(`No booking found for PNR ${pnrCode}`); 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 { return {
buffer: Buffer.from(body, 'utf-8'), success: true,
filename: `payment-request-${booking.reference}.txt`, 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> { private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findById(id); const booking = await this.bookingsRepository.findById(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`); if (!booking) throw new NotFoundException(`Booking ${id} not found`);

View File

@@ -1,10 +1,13 @@
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common'; 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 { RuleEngineService } from '../rule-engine/rule-engine.service';
import { BookingContractService } from './booking-contract.service'; import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service'; import { BookingPricingService } from './booking-pricing.service';
import { BookingsRepository } from './bookings.repository'; import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util'; import { assertBookingStatus } from './booking-status.util';
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
import { Booking } from './entities/booking.entity'; import { Booking } from './entities/booking.entity';
import { BookingsService } from './bookings.service'; import { BookingsService } from './bookings.service';
@@ -60,6 +63,16 @@ export class BookingTransitionService {
return this.bookingsService.findById(updated!.id); 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> { async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']); assertBookingStatus(booking, ['SUBMITTED']);
@@ -103,13 +116,23 @@ export class BookingTransitionService {
stepId: string, stepId: string,
actorId: string, actorId: string,
requiredRole: string, requiredRole: string,
authUser?: TCurrentUser,
): Promise<Booking> { ): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); if (authUser) {
assertCanApproveBookingStep(authUser, requiredRole);
}
let booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [ assertBookingStatus(booking, [
'PENDING_APPROVAL', 'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE', '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( const step = await this.bookingsRepository.findApprovalStepById(
bookingId, bookingId,
stepId, stepId,
@@ -131,7 +154,7 @@ export class BookingTransitionService {
); );
} }
const blocksRole = step.approvalRule?.blocksRole; const blocksRole = step.blocksRole;
if (blocksRole && blocksRole === requiredRole) { if (blocksRole && blocksRole === requiredRole) {
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`); throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
} }
@@ -271,6 +294,7 @@ export class BookingTransitionService {
async enrichBookingResponse(booking: Booking): Promise<Booking & { async enrichBookingResponse(booking: Booking): Promise<Booking & {
latestChangeRequestNote?: string | null; latestChangeRequestNote?: string | null;
contractSummary?: string | null; contractSummary?: string | null;
nextStep: BookingNextStep | null;
}> { }> {
const note = await this.bookingsRepository.findLatestReviewNote( const note = await this.bookingsRepository.findLatestReviewNote(
booking.id, booking.id,
@@ -279,10 +303,17 @@ export class BookingTransitionService {
const summary = const summary =
booking.contractSummary ?? booking.contractSummary ??
this.contractService.buildContractSummary(booking); 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 { return {
...booking, ...booking,
latestChangeRequestNote: note?.note ?? null, latestChangeRequestNote: note?.note ?? null,
contractSummary: summary, contractSummary: summary,
nextStep,
}; };
} }
} }

View File

@@ -3,7 +3,6 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
Header,
HttpCode, HttpCode,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
@@ -12,13 +11,13 @@ import {
Query, Query,
Request, Request,
Res, Res,
StreamableFile,
UploadedFiles, UploadedFiles,
UseGuards,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { CurrentUser } from '@edr/api-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 { AnyFilesInterceptor } from '@nestjs/platform-express';
import { import {
ApiBearerAuth, ApiBearerAuth,
@@ -31,13 +30,13 @@ import {
import type { Response } from 'express'; import type { Response } from 'express';
import { BookingContractService } from './booking-contract.service'; import { BookingContractService } from './booking-contract.service';
import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service'; import { BookingPricingService } from './booking-pricing.service';
import { BookingTransitionService } from './booking-transition.service'; import { BookingTransitionService } from './booking-transition.service';
import { BookingReferenceDataService } from './booking-reference-data.service'; import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingsService } from './bookings.service'; import { BookingsService } from './bookings.service';
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
import { CreateBookingDto } from './dto/create-booking.dto'; import { CreateBookingDto } from './dto/create-booking.dto';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
import { FilterBookingDto } from './dto/filter-booking.dto'; import { FilterBookingDto } from './dto/filter-booking.dto';
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { import {
@@ -65,7 +64,6 @@ export class BookingsController {
private readonly pricingService: BookingPricingService, private readonly pricingService: BookingPricingService,
private readonly transitionService: BookingTransitionService, private readonly transitionService: BookingTransitionService,
private readonly contractService: BookingContractService, private readonly contractService: BookingContractService,
private readonly paymentService: BookingPaymentService,
) {} ) {}
@Post() @Post()
@@ -104,6 +102,13 @@ export class BookingsController {
return this.bookingsService.findAll(filter); 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') @Get('queues/:queue')
@ApiOperation({ @ApiOperation({
summary: 'List bookings for a dashboard queue', summary: 'List bookings for a dashboard queue',
@@ -162,7 +167,7 @@ export class BookingsController {
} }
@Post(':id/staff/request-changes') @Post(':id/staff/request-changes')
@UseGuards(JwtGuard) @BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
@ApiOperation({ summary: 'Staff return booking for customer updates' }) @ApiOperation({ summary: 'Staff return booking for customer updates' })
async requestChanges( async requestChanges(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -178,7 +183,7 @@ export class BookingsController {
} }
@Post(':id/staff/accept') @Post(':id/staff/accept')
@UseGuards(JwtGuard) @BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Staff accept intake → start approval chain' }) @ApiOperation({ summary: 'Staff accept intake → start approval chain' })
async acceptIntake( async acceptIntake(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -192,7 +197,7 @@ export class BookingsController {
} }
@Post(':id/staff/reject') @Post(':id/staff/reject')
@UseGuards(JwtGuard) @BookingStaff(FREIGHT_PERMS.bookings.reject)
@ApiOperation({ summary: 'Staff final reject' }) @ApiOperation({ summary: 'Staff final reject' })
async staffReject( async staffReject(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -208,25 +213,30 @@ export class BookingsController {
} }
@Post(':id/approval-steps/:stepId/approve') @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' }) @ApiOperation({ summary: 'Approve one approval step in sequence' })
async approveStep( async approveStep(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string, @Param('stepId', ParseUUIDPipe) stepId: string,
@Body() dto: ApproveStepDto, @Body() dto: ApproveStepDto,
@CurrentUser() user: AuthUserPayload, @CurrentUser() user: TCurrentUser,
) { ) {
const booking = await this.transitionService.approveStep( const booking = await this.transitionService.approveStep(
id, id,
stepId, stepId,
resolveAuthUserId(user), resolveAuthUserId(user),
dto.requiredRole, dto.requiredRole,
user,
); );
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/approval-steps/:stepId/reject') @Post(':id/approval-steps/:stepId/reject')
@UseGuards(JwtGuard) @BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
@ApiOperation({ summary: 'Reject at approval step' }) @ApiOperation({ summary: 'Reject at approval step' })
async rejectStep( async rejectStep(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -244,7 +254,7 @@ export class BookingsController {
} }
@Post(':id/contract/generate') @Post(':id/contract/generate')
@UseGuards(JwtGuard) @BookingStaff(FREIGHT_PERMS.bookings.generateContract)
@ApiOperation({ summary: 'Generate contract PDF from template' }) @ApiOperation({ summary: 'Generate contract PDF from template' })
async generateContract(@Param('id', ParseUUIDPipe) id: string) { async generateContract(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.contractService.generateContract(id); const booking = await this.contractService.generateContract(id);
@@ -262,22 +272,23 @@ export class BookingsController {
@ApiOperation({ summary: 'Download contract PDF' }) @ApiOperation({ summary: 'Download contract PDF' })
async downloadContractDocument( async downloadContractDocument(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Res({ passthrough: true }) res: Response, @Res() res: Response,
) { ): Promise<void> {
const { stream, record } = await this.contractService.streamContract(id); const { stream, record } = await this.contractService.streamContract(id);
res.set({ res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
'Content-Type': record.mimeType ?? 'application/pdf', res.setHeader(
'Content-Disposition': `attachment; filename="${record.name}"`, 'Content-Disposition',
}); `attachment; filename="${record.name}"`,
return new StreamableFile(stream); );
stream.pipe(res);
} }
@Get(':id/contract') @Get(':id/contract')
@ApiOperation({ summary: 'Download contract file (alias)' }) @ApiOperation({ summary: 'Download contract file (alias)' })
async downloadContract( async downloadContract(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Res({ passthrough: true }) res: Response, @Res() res: Response,
) { ): Promise<void> {
return this.downloadContractDocument(id, res); return this.downloadContractDocument(id, res);
} }
@@ -326,7 +337,7 @@ export class BookingsController {
} }
@Post(':id/marketing/approve') @Post(':id/marketing/approve')
@UseGuards(JwtGuard) @BookingStaff(FREIGHT_PERMS.bookings.signStaff)
@ApiOperation({ @ApiOperation({
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)', summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
}) })
@@ -347,47 +358,8 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); 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') @Post(':id/operations/start-transit')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark in transit' }) @ApiOperation({ summary: 'Mark in transit' })
async startTransit(@Param('id', ParseUUIDPipe) id: string) { async startTransit(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.transitionService.startTransit(id); const booking = await this.transitionService.startTransit(id);
@@ -395,6 +367,7 @@ export class BookingsController {
} }
@Post(':id/operations/complete') @Post(':id/operations/complete')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark completed' }) @ApiOperation({ summary: 'Mark completed' })
async complete(@Param('id', ParseUUIDPipe) id: string) { async complete(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.transitionService.complete(id); const booking = await this.transitionService.complete(id);
@@ -402,6 +375,7 @@ export class BookingsController {
} }
@Post(':id/cancel') @Post(':id/cancel')
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
@ApiOperation({ summary: 'Cancel booking' }) @ApiOperation({ summary: 'Cancel booking' })
async cancel( async cancel(
@Param('id', ParseUUIDPipe) id: string, @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 { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingTransitionService } from './booking-transition.service'; import { BookingTransitionService } from './booking-transition.service';
import { BookingsController } from './bookings.controller'; import { BookingsController } from './bookings.controller';
import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository'; import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service'; import { ConsolidationService } from './consolidation.service';
import { BookingsService } from './bookings.service'; import { BookingsService } from './bookings.service';
import { PaymentsWebhookController } from './payments-webhook.controller';
import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingContainer } from './entities/booking-container.entity'; import { BookingContainer } from './entities/booking-container.entity';
@@ -46,7 +46,7 @@ import { ContractViewModelBuilder } from '../../contracts/contract-view-model.bu
// CustomersModule, // CustomersModule,
RuleEngineModule, RuleEngineModule,
], ],
controllers: [BookingsController, PaymentsWebhookController], controllers: [BookingsController, PayController],
providers: [ providers: [
BookingsService, BookingsService,
BookingsRepository, BookingsRepository,

View File

@@ -1,7 +1,7 @@
import { BaseRepository } from '@edr/api-common'; import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; 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 { ContainerType } from '../rule-engine/entities/container-type.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingApprovalStep } from './entities/booking-approval-step.entity';
@@ -17,6 +17,20 @@ import {
import { FileRecord } from '../files/entities/file.entity'; import { FileRecord } from '../files/entities/file.entity';
import { ContainerWeightResult } from '../rule-engine/rule-engine.service'; 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() @Injectable()
export class BookingsRepository extends BaseRepository<Booking> { export class BookingsRepository extends BaseRepository<Booking> {
constructor( constructor(
@@ -230,7 +244,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({ return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, status: 'PENDING' }, where: { bookingId, status: 'PENDING' },
order: { stepOrder: 'ASC' }, order: { stepOrder: 'ASC' },
relations: ['approvalRule'],
}); });
} }
@@ -240,7 +253,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
): Promise<BookingApprovalStep | null> { ): Promise<BookingApprovalStep | null> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({ return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, id: stepId }, where: { bookingId, id: stepId },
relations: ['approvalRule'],
}); });
} }
@@ -333,10 +345,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId }); 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. */ /** Queue listing with optional bulk exclusion for LINE_STAFF. */
async findQueue(options: { async findQueue(options: {
status: string | string[]; status: string | string[];
@@ -353,9 +361,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
const qb = this.repository const qb = this.repository
.createQueryBuilder('booking') .createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.company', 'company')
// .leftJoinAndSelect('booking.customer', 'customer') .leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.cargoType', 'cargo') .leftJoinAndSelect('booking.cargoType', 'cargo')
.leftJoinAndSelect('booking.serviceType', 'serviceType') .leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.where('booking.status IN (:...statuses)', { statuses }); .where('booking.status IN (:...statuses)', { statuses });
if (options.excludeBulk) { if (options.excludeBulk) {
@@ -363,7 +373,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
} }
const sortField = const sortField =
options.sortBy === 'priorityScore' ? 'booking.priority_score' : 'booking.created_at'; options.sortBy === 'priorityScore'
? 'booking.priorityScore'
: 'booking.createdAt';
qb.orderBy(sortField, options.sortOrder ?? 'DESC'); qb.orderBy(sortField, options.sortOrder ?? 'DESC');
const [items, total] = await qb const [items, total] = await qb
@@ -374,6 +386,158 @@ export class BookingsRepository extends BaseRepository<Booking> {
return { items, total }; 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: { async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
skip: number; skip: number;
take: number; take: number;

View File

@@ -4,8 +4,6 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
// import { CustomersService } from '../customers/customers.service'; // import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service'; import { CompaniesService } from '../companies/companies.service';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
@@ -19,12 +17,25 @@ import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service'; import { ConsolidationService } from './consolidation.service';
import { assertFreightShape } from './booking-freight.util'; import { assertFreightShape } from './booking-freight.util';
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto'; 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 { FilterBookingDto } from './dto/filter-booking.dto';
import { UpdateBookingDto } from './dto/update-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 { Booking } from './entities/booking.entity';
import { FileRecord } from '../files/entities/file.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() @Injectable()
export class BookingsService { export class BookingsService {
constructor( constructor(
@@ -379,44 +390,88 @@ export class BookingsService {
return { booking, warnings }; 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. */ /** Return a paginated list of bookings matching the filter. */
async findAll( async findAll(
filter: FilterBookingDto, filter: FilterBookingDto,
): Promise<{ items: Booking[]; total: number }> { ): Promise<{ items: Booking[]; total: number }> {
const page = filter.page ?? 1; const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20; const pageSize = filter.pageSize ?? 20;
const statusFilter = this.parseStatusFilter(filter);
const where: Record<string, unknown> = {}; return this.bookingsRepository.findAllPaginated({
if (filter.status) where.status = filter.status; page,
// if (filter.customerId) where.customerId = filter.customerId; pageSize,
if (filter.companyId) where.companyId = filter.companyId; ...statusFilter,
if (filter.contractType) where.contractType = filter.contractType; companyId: filter.companyId,
if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId; contractType: filter.contractType,
if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId; serviceTypeId: filter.serviceTypeId,
if (filter.freightType) where.freightType = filter.freightType; cargoTypeId: filter.cargoTypeId,
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection; freightType: filter.freightType,
if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency; tradeDirection: filter.tradeDirection,
if (filter.allowConsolidation !== undefined) { paymentCurrency: filter.paymentCurrency,
where.allowConsolidation = filter.allowConsolidation; allowConsolidation: filter.allowConsolidation,
} consolidationPaired: filter.consolidationPaired,
if (filter.consolidationPaired === 'true') { sortBy: filter.sortBy,
where.consolidationPartnerId = Not(IsNull()); sortOrder: filter.sortOrder,
} 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 { 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. */ /** Get a single booking by ID with files. */
@@ -429,7 +484,7 @@ export class BookingsService {
if (booking.files && booking.files.length > 0) { if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all( booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => { 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); const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
return { ...file, signedUrl }; return { ...file, signedUrl };
}), }),
@@ -439,11 +494,6 @@ export class BookingsService {
return booking; return booking;
} }
private extractObjectName(url: string): string {
const parts = url.split('/');
return parts.slice(4).join('/');
}
async findByReference(reference: string): Promise<Booking> { async findByReference(reference: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByReferenceWithFiles(reference); const booking = await this.bookingsRepository.findByReferenceWithFiles(reference);
if (!booking) { if (!booking) {
@@ -467,10 +517,11 @@ export class BookingsService {
): Promise<{ items: Booking[]; total: number }> { ): Promise<{ items: Booking[]; total: number }> {
const statusMap: Record<string, string | string[]> = { const statusMap: Record<string, string | string[]> = {
intake: 'SUBMITTED', intake: 'SUBMITTED',
approval: 'PENDING_APPROVAL', approval: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'], signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'],
contract: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
marketing: 'SIGNED_CUSTOMER', marketing: 'SIGNED_CUSTOMER',
finance: 'PAYMENT_VERIFICATION_IN_PROGRESS', finance: 'FULLY_EXECUTED',
}; };
const status = statusMap[queue]; 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]) @IsIn([...BOOKING_STATUSES])
status?: string; 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' }) // @ApiPropertyOptional({ format: 'uuid' })
// @IsOptional() // @IsOptional()
// @IsUUID() // @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) @MinLength(1)
reason!: string; 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 }) @Column({ name: 'required_role', type: 'varchar', length: 30 })
requiredRole!: string; requiredRole!: string;
@Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true })
blocksRole?: string | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' }) @Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
status!: ApprovalStepStatus; 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 }> { async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> {
const record = await this.findById(id); 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); const stream = await this.minioService.getFileStream(objectName);
return { stream, record }; 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 { ConfigType } from "@nestjs/config";
import { Client } from "minio"; import { Client } from "minio";
import { Readable } from "stream"; import { Readable } from "stream";
@@ -54,6 +54,30 @@ export class MinioService {
return `${protocol}://${this.config.endPoint}:${this.config.port}/${this.bucket}/${objectName}`; 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> { async deleteFile(objectName: string): Promise<void> {
try { try {
await this.client.removeObject(this.bucket, objectName); 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, Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query, Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
import { UpdateApprovalRuleDto } from '../dto/update-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') @ApiTags('approval-rules')
@Controller('approval-rules') @Controller('approval-rules')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth() @ApiBearerAuth()
export class ApprovalRulesController { export class ApprovalRulesController {
constructor(private readonly service: ApprovalRulesService) {} constructor(private readonly service: ApprovalRulesService) {}
@Get() @Get()
@RuleEngineView('approval-rules')
@ApiOperation({ summary: 'List approval rules' }) @ApiOperation({ summary: 'List approval rules' })
findAll(@Query() query: Record<string, string>) { findAll(@Query() query: Record<string, string>) {
return this.service.findAll({ return this.service.findAll({
@@ -28,30 +29,35 @@ export class ApprovalRulesController {
} }
@Get('chain') @Get('chain')
@RuleEngineView('approval-rules')
@ApiOperation({ summary: 'Get approval chain for cargo routing flag' }) @ApiOperation({ summary: 'Get approval chain for cargo routing flag' })
findChain(@Query('requiresDirectorApproval') flag: string) { findChain(@Query('requiresDirectorApproval') flag: string) {
return this.service.findChain(flag === 'true'); return this.service.findChain(flag === 'true');
} }
@Get(':id') @Get(':id')
@RuleEngineView('approval-rules')
@ApiOperation({ summary: 'Get an approval rule by ID' }) @ApiOperation({ summary: 'Get an approval rule by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) { findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id); return this.service.findById(id);
} }
@Post() @Post()
@RuleEngineManage('approval-rules')
@ApiOperation({ summary: 'Create an approval rule step' }) @ApiOperation({ summary: 'Create an approval rule step' })
create(@Body() dto: CreateApprovalRuleDto) { create(@Body() dto: CreateApprovalRuleDto) {
return this.service.create(dto); return this.service.create(dto);
} }
@Patch(':id') @Patch(':id')
@RuleEngineManage('approval-rules')
@ApiOperation({ summary: 'Update an approval rule' }) @ApiOperation({ summary: 'Update an approval rule' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) { update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) {
return this.service.update(id, dto); return this.service.update(id, dto);
} }
@Delete(':id') @Delete(':id')
@RuleEngineManage('approval-rules')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete an approval rule' }) @ApiOperation({ summary: 'Soft-delete an approval rule' })
remove(@Param('id', ParseUUIDPipe) id: string) { remove(@Param('id', ParseUUIDPipe) id: string) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,7 @@ import {
IShippingLinesRepository, IShippingLinesRepository,
SHIPPING_LINES_REPOSITORY, SHIPPING_LINES_REPOSITORY,
} from './interfaces/shipping-lines.repository.interface'; } from './interfaces/shipping-lines.repository.interface';
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
export interface BookingContainerEvalInput { export interface BookingContainerEvalInput {
containerTypeId: string; 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. * Instantiate booking_approval_step rows from approval_rules by freight type.
*/ */
@@ -248,6 +272,8 @@ export class RuleEngineService {
cargoTypeId?: string | null; cargoTypeId?: string | null;
}, },
): Promise<BookingApprovalStep[]> { ): Promise<BookingApprovalStep[]> {
await this.ensureDefaultApprovalRules();
let requiresDirectorApproval = options.freightType === 'BULK'; let requiresDirectorApproval = options.freightType === 'BULK';
if (options.cargoTypeId) { if (options.cargoTypeId) {
@@ -264,6 +290,12 @@ export class RuleEngineService {
requiresDirectorApproval, requiresDirectorApproval,
); );
if (chain.length === 0) {
throw new BadRequestException(
`Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
);
}
const stepRepo = this.dataSource.getRepository(BookingApprovalStep); const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
const steps: BookingApprovalStep[] = []; const steps: BookingApprovalStep[] = [];
@@ -273,6 +305,7 @@ export class RuleEngineService {
approvalRuleId: rule.id, approvalRuleId: rule.id,
stepOrder: rule.stepOrder, stepOrder: rule.stepOrder,
requiredRole: rule.requiredRole, requiredRole: rule.requiredRole,
blocksRole: rule.blocksRole ?? null,
status: 'PENDING', status: 'PENDING',
}); });
steps.push(await stepRepo.save(step)); 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 = { export type FreightSeedRole = {
key: string; key: string;
name: { en: string }; name: { en: string };
@@ -183,8 +189,11 @@ export const EDR_FREIGHT_PERMISSIONS = [
...HIERARCHY_POSITION_PERMISSIONS, ...HIERARCHY_POSITION_PERMISSIONS,
...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS, ...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS,
...POSITION_TYPE_PERMISSIONS, ...POSITION_TYPE_PERMISSIONS,
...BOOKING_RULE_ENGINE_PERMISSIONS,
]; ];
export { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from './freight-permissions.registry';
export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
{ {
key: "edr_employee", key: "edr_employee",
@@ -198,11 +207,42 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
"edr_freight_app:position_types:view", "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", key: "edr_org_manager",
name: { en: "EDR Org Manager" }, name: { en: "EDR Org Manager" },
permissionKeys: [ 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.createEmployee,
IAM_PERMISSION_KEYS.deactivateEmployee, IAM_PERMISSION_KEYS.deactivateEmployee,
IAM_PERMISSION_KEYS.activateEmployee, IAM_PERMISSION_KEYS.activateEmployee,

View File

@@ -8,6 +8,8 @@ import {
} from "@tria-plc/iamapi-common"; } from "@tria-plc/iamapi-common";
import { DataSource, EntityManager, In } from "typeorm"; 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"; import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed";
const EDR_ORG_KEY = "edr_freight"; const EDR_ORG_KEY = "edr_freight";
@@ -37,6 +39,7 @@ export class EdrOrgSeeder {
await this.ensureOrganizationConfiguration(manager, organization.id); await this.ensureOrganizationConfiguration(manager, organization.id);
await this.ensureRoles(manager, EDR_FREIGHT_ROLES); await this.ensureRoles(manager, EDR_FREIGHT_ROLES);
await this.ensureRolePermissions(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}'`); 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`); 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 { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { import {
Boxes, Boxes,
@@ -12,6 +13,11 @@ import {
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
import LoadingScreen from "./components/LoadingScreen"; import LoadingScreen from "./components/LoadingScreen";
import { useAuth } from "./auth/useAuth"; import { useAuth } from "./auth/useAuth";
import {
canAccessBookings,
canAccessRuleEngineResource,
hasPermission,
} from "@/lib/permissions";
import LoginPage from "./pages/auth/LoginPage"; import LoginPage from "./pages/auth/LoginPage";
import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
@@ -19,7 +25,6 @@ import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
import OverviewPage from "./pages/dashboard/OverviewPage"; import OverviewPage from "./pages/dashboard/OverviewPage";
import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
import RolesPage from "./pages/dashboard/user-management/RolesPage"; 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 FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; 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[] => [ const filterRuleEngineChildren = (
{
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 = (
user: ReturnType<typeof useAuth>["user"], user: ReturnType<typeof useAuth>["user"],
key: string, category: RuleEngineNavCategory,
) => { ): SidebarItem[] =>
if (!user) return false; getCategorySidebarChildren(category).filter((item) => {
if (user.permissions?.some((p) => p.key === key)) return true; const slug = item.href.split("/").pop() as RuleEngineResourceSlug;
return canAccessRuleEngineResource(user, slug, "view");
});
return (user.employee ?? []).some((emp) => const buildSidebarSections = (
(emp.positions ?? []).some((pos) => user: ReturnType<typeof useAuth>["user"],
(pos.permissions ?? []).some((p) => p.key === key), 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 DashboardShell = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -132,26 +149,14 @@ const DashboardShell = () => {
const demoItems: SidebarItem[] = [ const demoItems: SidebarItem[] = [
...(hasPermission(user, "can:demo:user1") ...(hasPermission(user, "can:demo:user1")
? [ ? [{ label: "User1", href: "/dashboard/user1", icon: <Settings /> }]
{
label: "User1",
href: "/dashboard/user1",
icon: <Settings />,
},
]
: []), : []),
...(hasPermission(user, "can:demo:user2") ...(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"; const displayName = user?.name?.en || user?.username || user?.email || "User";
return ( return (
@@ -169,6 +174,8 @@ const DashboardShell = () => {
); );
}; };
const ruleEngineSlugs = RULE_ENGINE_RESOURCES.map((r) => r.slug);
const App = () => { const App = () => {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
@@ -185,63 +192,104 @@ const App = () => {
); );
} }
const canBookings = canAccessBookings(user);
return ( return (
<Routes> <Routes>
<Route path="/" 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={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}> <Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} /> <Route path="overview" element={<OverviewPage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} /> <Route
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} /> path="booking-requests"
<Route element={
path="booking-requests/:id/contract" <PermissionRoute allow={canBookings}>
element={<BookingContractPage />} <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" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} /> <Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} /> <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/permissions" element={<PermissionsPage />} /> <Route path="user-management/roles" element={<RolesPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
<Route path="file-settings" element={<FileUploadSettingsPage />} /> <Route path="file-settings" element={<FileUploadSettingsPage />} />
<Route path="dropdown-settings" element={<DropdownSettingsPage />} /> <Route path="dropdown-settings" element={<DropdownSettingsPage />} />
<Route <Route
path="configuration" path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />} element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/> />
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} /> <Route
path="configuration/:resource"
element={
<PermissionRoute
allow={ruleEngineSlugs.some((slug) =>
canAccessRuleEngineResource(user, slug, "view"),
)}
>
<RuleEngineResourcePage />
</PermissionRoute>
}
/>
<Route <Route
path="rules" path="rules"
element={<Navigate to="/dashboard/rules/priority-rules" replace />} element={<Navigate to="/dashboard/rules/priority-rules" replace />}
/> />
<Route path="rules/:resource" element={<RuleEngineResourcePage />} /> <Route
path="rules/:resource"
element={
<PermissionRoute
allow={ruleEngineSlugs.some((slug) =>
canAccessRuleEngineResource(user, slug, "view"),
)}
>
<RuleEngineResourcePage />
</PermissionRoute>
}
/>
<Route <Route
path="rule-engine" path="rule-engine"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />} element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/> />
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} /> <Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
<Route path="user1" element={<DemoUser1Page />} /> <Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} /> <Route path="user2" element={<DemoUser2Page />} />
<Route <Route
path="org-structure" path="org-structure"
element={<Navigate to="/dashboard/user-management" replace />} element={<Navigate to="/dashboard/user-management" replace />}
/> />
<Route <Route
path="org-structure/*" path="org-structure/*"
element={<Navigate to="/dashboard/user-management" replace />} element={<Navigate to="/dashboard/user-management" replace />}
/> />
</Route> </Route>
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} /> <Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
</Routes> </Routes>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import { useRef } from "react"; import { Download, Zap } from "lucide-react";
import { Download, Upload, Zap } from "lucide-react";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { BookingActionsMenu } from "./BookingActionsMenu"; import { BookingActionsMenu } from "./BookingActionsMenu";
@@ -7,6 +6,7 @@ import { bookingSurface } from "./booking-ui.styles";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import type { useBookingMutations } from "@/hooks/bookings/useBookings"; import type { useBookingMutations } from "@/hooks/bookings/useBookings";
import { Button } from "@edr/ui-common"; import { Button } from "@edr/ui-common";
import { cn } from "@/lib/utils";
type Mutations = ReturnType<typeof useBookingMutations>; type Mutations = ReturnType<typeof useBookingMutations>;
@@ -15,15 +15,13 @@ interface BookingActionsToolbarProps {
mutations: Mutations; mutations: Mutations;
} }
/** Detail-page actions: primary toolbar + payment uploads + downloads. */ /** Detail-page actions: primary toolbar + downloads. */
export function BookingActionsToolbar({ export function BookingActionsToolbar({
booking, booking,
mutations, mutations,
}: BookingActionsToolbarProps) { }: BookingActionsToolbarProps) {
const fileRef = useRef<HTMLInputElement>(null);
const row = toBookingListRow(booking); const row = toBookingListRow(booking);
const { status, paymentCurrency } = booking; const { status } = booking;
const pending = mutations.isPending;
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => { const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
const blob = await fn(); const blob = await fn();
@@ -47,7 +45,7 @@ export function BookingActionsToolbar({
return ( return (
<PanelShell title="Awaiting customer" description="No staff actions until resubmit."> <PanelShell title="Awaiting customer" description="No staff actions until resubmit.">
{booking.latestChangeRequestNote && ( {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} {booking.latestChangeRequestNote}
</p> </p>
)} )}
@@ -74,50 +72,11 @@ export function BookingActionsToolbar({
<BookingActionsMenu row={row} variant="toolbar" /> <BookingActionsMenu row={row} variant="toolbar" />
</PanelShell> </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" && ( {status === "CONTRACT_READY" && (
<PanelShell title="Documents" description="Download generated contract."> <PanelShell title="Documents" description="Download generated contract.">
<Button <Button
variant="outline" variant="outline"
className="gap-2" className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
onClick={() => onClick={() =>
downloadBlob( downloadBlob(
() => mutations.downloadContract(), () => mutations.downloadContract(),
@@ -146,16 +105,10 @@ function PanelShell({
muted?: boolean; muted?: boolean;
}) { }) {
return ( return (
<div <div className={cn(bookingSurface.sectionCard, !muted && "ring-0")}>
className={
muted
? bookingSurface.sectionCard
: `${bookingSurface.sectionCard} ring-1 ring-primary/10`
}
>
<div className={bookingSurface.sectionHeader}> <div className={bookingSurface.sectionHeader}>
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary"> <div className={bookingSurface.sectionIcon}>
<Zap className="size-4" /> <Zap className="size-4" strokeWidth={1.75} />
</div> </div>
<div> <div>
<h2 className="text-sm font-semibold text-foreground">{title}</h2> <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; reference?: string;
inputValue: string; inputValue: string;
onInputChange: (value: string) => void; onInputChange: (value: string) => void;
selectedFile?: File | null;
onFileChange?: (file: File | null) => void;
onConfirm: () => void; onConfirm: () => void;
isPending: boolean; isPending: boolean;
confirmDisabled?: boolean; confirmDisabled?: boolean;
@@ -33,6 +35,8 @@ export function BookingConfirmDialog({
reference, reference,
inputValue, inputValue,
onInputChange, onInputChange,
selectedFile = null,
onFileChange,
onConfirm, onConfirm,
isPending, isPending,
confirmDisabled = false, confirmDisabled = false,
@@ -41,13 +45,25 @@ export function BookingConfirmDialog({
if (!action || !action.confirmTitle) return null; if (!action || !action.confirmTitle) return null;
const Icon = action.icon; const Icon = action.icon;
const needsInput = Boolean(action.input); const needsTextInput =
const inputMissing = needsInput && !inputValue.trim(); action.input === "note" || action.input === "reason";
const needsFileInput = action.input === "file";
const inputMissing =
(needsTextInput && !inputValue.trim()) ||
(needsFileInput && !selectedFile);
const isDestructive = action.variant === "destructive"; const isDestructive = action.variant === "destructive";
const preventClickThrough = (event: React.MouseEvent) => {
event.preventDefault();
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <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 <div
className={cn( className={cn(
"border-b px-6 py-5", "border-b px-6 py-5",
@@ -86,7 +102,7 @@ export function BookingConfirmDialog({
</div> </div>
<div className="space-y-4 px-6 py-5"> <div className="space-y-4 px-6 py-5">
{needsInput && ( {needsTextInput && (
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{action.inputLabel} {action.inputLabel}
@@ -101,6 +117,27 @@ export function BookingConfirmDialog({
/> />
</div> </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} {extra}
</div> </div>
@@ -109,6 +146,7 @@ export function BookingConfirmDialog({
type="button" type="button"
variant="outline" variant="outline"
disabled={isPending} disabled={isPending}
onMouseDown={preventClickThrough}
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
Cancel Cancel
@@ -118,6 +156,7 @@ export function BookingConfirmDialog({
variant={isDestructive ? "destructive" : "default"} variant={isDestructive ? "destructive" : "default"}
disabled={isPending || inputMissing || confirmDisabled} disabled={isPending || inputMissing || confirmDisabled}
className="min-w-[7rem] gap-2" className="min-w-[7rem] gap-2"
onMouseDown={preventClickThrough}
onClick={onConfirm} onClick={onConfirm}
> >
{isPending ? ( {isPending ? (

View File

@@ -2,7 +2,7 @@ import { Banknote, Receipt } from "lucide-react";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { Separator } from "@edr/ui-common"; 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 }) { export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
const amount = Number(booking.totalAmount); const amount = Number(booking.totalAmount);
@@ -11,8 +11,8 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
return ( return (
<div className={bookingSurface.sectionCard}> <div className={bookingSurface.sectionCard}>
<div className={bookingSurface.sectionHeader}> <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"> <div className={bookingSurface.sectionIcon}>
<Banknote className="size-4" /> <Banknote className="size-4" strokeWidth={1.75} />
</div> </div>
<div> <div>
<h2 className="text-sm font-semibold text-foreground"> <h2 className="text-sm font-semibold text-foreground">
@@ -22,11 +22,11 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
</div> </div>
</div> </div>
<div className="space-y-4 px-5 py-5"> <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"> <div className={bookingSurface.valueCard}>
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground"> <p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Total amount Total amount
</p> </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}{" "} {booking.paymentCurrency}{" "}
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} {amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</p> </p>
@@ -35,8 +35,8 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />} {booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
{modifiers.length > 0 && ( {modifiers.length > 0 && (
<> <>
<Separator /> <Separator className="opacity-50" />
<p className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground"> <p className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<Receipt className="size-3" /> <Receipt className="size-3" />
Surcharges applied Surcharges applied
</p> </p>
@@ -44,7 +44,7 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
{modifiers.map((m) => ( {modifiers.map((m) => (
<li <li
key={m.id} 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="text-muted-foreground">Modifier</span>
<span className="font-mono font-semibold tabular-nums"> <span className="font-mono font-semibold tabular-nums">
@@ -70,7 +70,7 @@ function Row({
mono?: boolean; mono?: boolean;
}) { }) {
return ( 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="text-muted-foreground">{label}</span>
<span <span
className={ className={

View File

@@ -1,4 +1,5 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { bookingGlass } from "./booking-ui.styles";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface StatItem { export interface StatItem {
@@ -9,43 +10,49 @@ export interface StatItem {
accent?: "default" | "amber" | "emerald" | "rose"; accent?: "default" | "amber" | "emerald" | "rose";
} }
const accentStyles = { const iconAccentStyles = {
default: "bg-primary/10 text-primary", default: "text-foreground/70",
amber: "bg-amber-500/10 text-amber-700 dark:text-amber-400", amber: "text-amber-600 dark:text-amber-400",
emerald: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400", emerald: "text-emerald-600 dark:text-emerald-400",
rose: "bg-rose-500/10 text-rose-700 dark:text-rose-400", rose: "text-rose-600 dark:text-rose-400",
}; };
export function BookingStatGrid({ items }: { items: StatItem[] }) { export function BookingStatGrid({ items }: { items: StatItem[] }) {
return ( 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) => { {items.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const accent = item.accent ?? "default"; const accent = item.accent ?? "default";
return ( return (
<div <div
key={item.label} 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="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <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} {item.label}
</p> </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} {item.value}
</p> </p>
{item.hint && ( {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>
<div <div
className={cn( className={cn(
"flex size-11 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-105", "flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-[1.02]",
accentStyles[accent], bookingGlass.iconWellGreen,
iconAccentStyles[accent],
)} )}
> >
<Icon className="size-5" strokeWidth={2} /> <Icon className="size-[18px]" strokeWidth={1.75} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,27 +1,34 @@
import { import {
CheckCircle,
ClipboardCheck, ClipboardCheck,
FileSignature, FileSignature,
FileText,
Inbox, Inbox,
LayoutGrid, LayoutGrid,
ShieldCheck, Train,
Wallet,
XCircle,
} from "lucide-react"; } from "lucide-react";
import { import {
BOOKING_LIST_TABS, BOOKING_LIST_TABS,
type BookingStatusTabKey, type BookingStatusTabKey,
} from "@/features/bookings/booking-status.config"; } from "@/features/bookings/booking-status.config";
import { bookingGlass } from "./booking-ui.styles";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = { const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
all: <LayoutGrid className="size-4" />, all: <LayoutGrid className="size-3.5" strokeWidth={1.75} />,
SUBMITTED: <Inbox className="size-4" />, intake: <Inbox className="size-3.5" strokeWidth={1.75} />,
PENDING_APPROVAL: <ClipboardCheck className="size-4" />, in_approval: <ClipboardCheck className="size-3.5" strokeWidth={1.75} />,
APPROVED_PENDING_SIGNATURE: <FileSignature className="size-4" />, approved_contract: <FileSignature className="size-3.5" strokeWidth={1.75} />,
SIGNED_CUSTOMER: <FileText className="size-4" />, payment: <Wallet className="size-3.5" strokeWidth={1.75} />,
PAYMENT_VERIFICATION_IN_PROGRESS: <ShieldCheck className="size-4" />, 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 { interface BookingStatusTabsProps {
active: BookingStatusTabKey; active: BookingStatusTabKey;
onChange: (tab: BookingStatusTabKey) => void; onChange: (tab: BookingStatusTabKey) => void;
@@ -34,9 +41,9 @@ export function BookingStatusTabs({
counts, counts,
}: BookingStatusTabsProps) { }: BookingStatusTabsProps) {
return ( return (
<div className="rounded-xl border border-border bg-muted/30 p-1.5"> <div className={bookingGlass.tabRail}>
<div <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" role="tablist"
aria-label="Booking status filters" aria-label="Booking status filters"
> >
@@ -51,29 +58,38 @@ export function BookingStatusTabs({
aria-selected={isActive} aria-selected={isActive}
onClick={() => onChange(tab.key)} onClick={() => onChange(tab.key)}
className={cn( 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 isActive
? "bg-background text-foreground shadow-sm ring-1 ring-border/80" ? bookingGlass.activeTab
: "text-muted-foreground hover:bg-background/60 hover:text-foreground", : "text-muted-foreground hover:bg-emerald-500/5 hover:text-foreground",
)} )}
> >
<span className="flex w-full items-center justify-between gap-2"> <span className="flex w-full items-center justify-between gap-2">
<span <span
className={cn( className={cn(
"flex items-center gap-2 text-sm font-semibold", "flex items-center gap-2 text-sm font-medium",
isActive && "text-primary", isActive ? activeTabText : "text-muted-foreground",
)} )}
> >
{TAB_ICONS[tab.key]} <span
{tab.label} 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> </span>
{count !== undefined && count > 0 && ( {count !== undefined && count > 0 && (
<span <span
className={cn( 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 isActive
? "bg-primary/15 text-primary" ? cn("bg-emerald-500/15", activeTabText)
: "bg-muted text-muted-foreground", : "bg-muted/50 text-muted-foreground",
)} )}
> >
{count} {count}

View File

@@ -1,6 +1,7 @@
import { Package, Search } from "lucide-react"; import { Package, Search } from "lucide-react";
import { Button } from "@edr/ui-common"; 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 { interface BookingTableEmptyProps {
isError?: boolean; isError?: boolean;
@@ -15,7 +16,12 @@ export function BookingTableEmpty({
}: BookingTableEmptyProps) { }: BookingTableEmptyProps) {
return ( return (
<div className={bookingSurface.emptyState}> <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" />} {hasSearch ? <Search className="size-6" /> : <Package className="size-6" />}
</div> </div>
<div className="max-w-sm space-y-1"> <div className="max-w-sm space-y-1">

View File

@@ -12,7 +12,7 @@ import {
getWorkflowStageIndex, getWorkflowStageIndex,
WORKFLOW_STAGES, WORKFLOW_STAGES,
} from "@/features/bookings/booking-status.config"; } 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]; const STAGE_ICONS = [FileText, FileSignature, FileSignature, Wallet, Train, Check];
@@ -35,8 +35,8 @@ export function BookingWorkflowStepper({
return ( return (
<div className={bookingSurface.sectionCard}> <div className={bookingSurface.sectionCard}>
<div className={bookingSurface.sectionHeader}> <div className={bookingSurface.sectionHeader}>
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary"> <div className={bookingSurface.sectionIcon}>
<Train className="size-4" /> <Train className="size-4" strokeWidth={1.75} />
</div> </div>
<div> <div>
<h2 className="text-sm font-semibold text-foreground"> <h2 className="text-sm font-semibold text-foreground">
@@ -49,9 +49,9 @@ export function BookingWorkflowStepper({
</div> </div>
<div className="space-y-8 px-5 py-6"> <div className="space-y-8 px-5 py-6">
<div className="relative px-2"> <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 <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={{ style={{
width: width:
!isTerminal && currentStage >= 0 !isTerminal && currentStage >= 0
@@ -71,14 +71,17 @@ export function BookingWorkflowStepper({
> >
<div <div
className={cn( 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 && isCompleted &&
"border-primary bg-primary text-primary-foreground shadow-sm", cn(bookingGlass.iconWellGreen, "border-emerald-500/30 text-black"),
isActive && 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 && !isCompleted &&
!isActive && !isActive &&
"border-border text-muted-foreground", "border-border/60 text-muted-foreground",
)} )}
> >
{isCompleted ? ( {isCompleted ? (
@@ -89,8 +92,8 @@ export function BookingWorkflowStepper({
</div> </div>
<span <span
className={cn( className={cn(
"text-center text-[10px] font-bold uppercase leading-tight tracking-wide", "text-center text-[10px] font-semibold uppercase leading-tight tracking-wide",
isActive ? "text-primary" : "text-muted-foreground", isActive ? "text-black" : "text-muted-foreground",
)} )}
> >
{stage.label} {stage.label}
@@ -103,14 +106,17 @@ export function BookingWorkflowStepper({
<div <div
className={cn( className={cn(
"rounded-xl border px-5 py-4", "rounded-xl border px-5 py-4 backdrop-blur-sm",
isTerminal isTerminal
? "border-destructive/20 bg-destructive/5" ? "border-destructive/20 bg-destructive/5"
: "border-primary/15 bg-primary/[0.04]", : bookingGlass.activeTab,
)} )}
> >
<h4 <h4
className={cn("text-sm font-bold tracking-tight", titleColor)} className={cn(
"text-sm font-semibold tracking-tight",
isTerminal ? titleColor : "text-black",
)}
> >
{title} {title}
</h4> </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 = { export const bookingSurface = {
page: "min-h-screen bg-gradient-to-b from-muted/40 via-background to-background", page:
pageInner: "mx-auto max-w-[1600px] space-y-6 p-6 lg:p-8", "min-h-screen bg-gradient-to-b from-muted/30 via-background to-background",
hero: pageInner: "mx-auto max-w-[1600px] space-y-5 p-6 lg:p-8",
"relative overflow-hidden rounded-2xl border border-border/80 bg-card shadow-sm", hero: `relative overflow-hidden rounded-2xl ${bookingGlass.card}`,
heroGlow: heroGlow:
"pointer-events-none absolute -right-20 -top-20 size-64 rounded-full bg-primary/10 blur-3xl", "pointer-events-none absolute -right-24 -top-24 size-72 rounded-full bg-muted/40 blur-3xl",
panel: heroSheen:
"overflow-hidden rounded-xl border border-border bg-card shadow-sm", "pointer-events-none absolute inset-0 bg-gradient-to-br from-background/40 via-transparent to-muted/20",
panelToolbar: panel: `overflow-hidden rounded-xl ${bookingGlass.panel}`,
"flex flex-wrap items-center justify-between gap-3 border-b border-border bg-muted/25 px-4 py-3.5 sm:px-5", 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", tableWrap: "px-0",
sectionCard: sectionCard: `overflow-hidden rounded-xl transition-shadow duration-200 hover:shadow-md ${bookingGlass.card}`,
"overflow-hidden rounded-xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md",
sectionHeader: 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", sectionBody: "px-5 py-5",
detailHero: detailHero: `relative overflow-hidden rounded-2xl ${bookingGlass.card}`,
"relative overflow-hidden rounded-2xl border border-border bg-gradient-to-br from-card via-card to-primary/[0.04] shadow-sm", 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", stickySidebar: "lg:sticky lg:top-6 lg:self-start",
metricTile: 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: emptyState:
"flex flex-col items-center justify-center gap-3 px-6 py-16 text-center", "flex flex-col items-center justify-center gap-3 px-6 py-16 text-center",
} as const; } as const;
export const bookingInput = { export const bookingInput = {
search: 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; } as const;

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ export interface RuleEngineRecordActionsProps {
onSubmitRate?: (id: string) => void; onSubmitRate?: (id: string) => void;
onApproveRate?: (record: RuleEngineRecord) => void; onApproveRate?: (record: RuleEngineRecord) => void;
layout?: "row" | "compact"; layout?: "row" | "compact";
readOnly?: boolean;
} }
const RuleEngineRecordActions = ({ const RuleEngineRecordActions = ({
@@ -37,6 +38,7 @@ const RuleEngineRecordActions = ({
onSubmitRate, onSubmitRate,
onApproveRate, onApproveRate,
layout = "row", layout = "row",
readOnly = false,
}: RuleEngineRecordActionsProps) => { }: RuleEngineRecordActionsProps) => {
const status = String(record.status ?? ""); const status = String(record.status ?? "");
const hasRateActions = 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"
: "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 ( return (
<div className="flex items-center justify-end gap-0.5"> <div className="flex items-center justify-end gap-0.5">
<Button <Button

View File

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

View File

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

View File

@@ -77,6 +77,7 @@ export const URL_CONSTANTS = {
BOOKINGS: { BOOKINGS: {
BASE: "/bookings", BASE: "/bookings",
LIST_SUMMARY: "/bookings/list-summary",
BY_ID: (id: string) => `/bookings/${id}`, BY_ID: (id: string) => `/bookings/${id}`,
QUEUE: (queue: string) => `/bookings/queues/${queue}`, QUEUE: (queue: string) => `/bookings/queues/${queue}`,
STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`, STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`,
@@ -95,11 +96,7 @@ export const URL_CONSTANTS = {
SUMMARY: (id: string) => `/bookings/${id}/summary`, SUMMARY: (id: string) => `/bookings/${id}/summary`,
CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`, CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`,
MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`, MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`,
PAYMENT_PNR: (id: string) => `/bookings/${id}/payment/pnr`, PAYMENT_PAY: (id: string) => `/bookings/${id}/payment/pay`,
PAYMENT_PROOF: (id: string) => `/bookings/${id}/payment/proof`,
PAYMENT_VERIFY: (id: string) => `/bookings/${id}/payment/verify`,
PAYMENT_REQUEST_LETTER: (id: string) =>
`/bookings/${id}/payment/request-letter`,
START_TRANSIT: (id: string) => `/bookings/${id}/operations/start-transit`, START_TRANSIT: (id: string) => `/bookings/${id}/operations/start-transit`,
COMPLETE: (id: string) => `/bookings/${id}/operations/complete`, COMPLETE: (id: string) => `/bookings/${id}/operations/complete`,
CANCEL: (id: string) => `/bookings/${id}/cancel`, 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, XCircle,
} from "lucide-react"; } from "lucide-react";
import type { AuthUser } from "@/auth/types";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import type { import type {
BookingApprovalStep, BookingApprovalStep,
BookingDetail, BookingDetail,
@@ -26,12 +28,13 @@ export type BookingActionId =
| "rejectApproval" | "rejectApproval"
| "generateContract" | "generateContract"
| "viewContract" | "viewContract"
| "generatePnr" | "signContractStaff"
| "verifyPayment" | "payBooking"
| "startTransit" | "startTransit"
| "complete"; | "complete"
| "cancel";
export type BookingActionInputKind = "note" | "reason"; export type BookingActionInputKind = "note" | "reason" | "file";
export interface BookingActionDef { export interface BookingActionDef {
id: BookingActionId; 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). */ /** Actions available for the current booking status (detail or list). */
export function getBookingActions(ctx: BookingActionContext): BookingActionDef[] { export function getBookingActions(
const { status, paymentCurrency, approvalSteps } = ctx; ctx: BookingActionContext,
user?: AuthUser | null,
): BookingActionDef[] {
const { status, approvalSteps } = ctx;
let actions: BookingActionDef[];
switch (status) { switch (status) {
case "SUBMITTED": case "SUBMITTED":
return SUBMITTED_ACTIONS; actions = withCancel(SUBMITTED_ACTIONS);
break;
case "PENDING_APPROVAL": case "PENDING_APPROVAL":
case "APPROVED_PENDING_SIGNATURE": case "APPROVED_PENDING_SIGNATURE":
return approvalActions(approvalSteps); actions = withCancel(approvalActions(approvalSteps));
break;
case "APPROVED": case "APPROVED":
return [ actions = [
{ {
id: "generateContract", id: "generateContract",
label: "Generate contract", label: "Generate contract",
@@ -164,64 +267,23 @@ export function getBookingActions(ctx: BookingActionContext): BookingActionDef[]
icon: FileText, icon: FileText,
primary: true, primary: true,
}, },
CANCEL_ACTION,
]; ];
break;
case "CONTRACT_READY": case "CONTRACT_READY":
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }];
break;
case "SIGNED_CUSTOMER": case "SIGNED_CUSTOMER":
actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION];
break;
case "FULLY_EXECUTED": case "FULLY_EXECUTED":
return [ actions = [
{ PAY_BOOKING_ACTION,
id: "viewContract", { ...VIEW_CONTRACT_ACTION, label: "View executed contract" },
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,
},
]; ];
break;
case "PAID": case "PAID":
case "PNR_GENERATED": actions = [
return [
{ {
id: "startTransit", id: "startTransit",
label: "Start transit", label: "Start transit",
@@ -234,8 +296,9 @@ export function getBookingActions(ctx: BookingActionContext): BookingActionDef[]
primary: true, primary: true,
}, },
]; ];
break;
case "IN_TRANSIT": case "IN_TRANSIT":
return [ actions = [
{ {
id: "complete", id: "complete",
label: "Complete booking", label: "Complete booking",
@@ -249,20 +312,39 @@ export function getBookingActions(ctx: BookingActionContext): BookingActionDef[]
primary: true, primary: true,
}, },
]; ];
break;
case "CHANGES_REQUESTED":
actions = [CANCEL_ACTION];
break;
default: default:
return []; actions = [];
} }
if (user === undefined) return actions;
return filterActionsByUser(actions, user, approvalSteps);
} }
export function listRowHasActions(row: { /** Opens contract page without confirmation dialog. */
status: BookingStatus; export function isContractNavAction(id: BookingActionId): boolean {
paymentCurrency: string; return id === "viewContract" || id === "signContractStaff";
}): boolean { }
const actions = getBookingActions({
status: row.status, export function listRowHasActions(
paymentCurrency: row.paymentCurrency, row: {
reference: "", status: BookingStatus;
}); paymentCurrency: string;
if (actions.length > 0) return true; approvalSteps?: BookingApprovalStep[] | null;
return row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD"; },
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 = [ export const BOOKING_LIST_TABS = [
{ key: "all", label: "All bookings", status: null }, { key: "all", label: "All bookings", statuses: null as string[] | null },
{ key: "SUBMITTED", label: "Submitted", status: "SUBMITTED" }, { key: "intake", label: "Submitted", statuses: ["SUBMITTED"] },
{ key: "PENDING_APPROVAL", label: "Pending Approval", status: "PENDING_APPROVAL" },
{ {
key: "APPROVED_PENDING_SIGNATURE", key: "in_approval",
label: "Pending Signature", label: "In approval",
status: "APPROVED_PENDING_SIGNATURE", statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE"],
}, },
{ key: "SIGNED_CUSTOMER", label: "Customer Signed", status: "SIGNED_CUSTOMER" },
{ {
key: "PAYMENT_VERIFICATION_IN_PROGRESS", key: "approved_contract",
label: "Payment Verification", label: "Approved & contract",
status: "PAYMENT_VERIFICATION_IN_PROGRESS", 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; ] as const;
export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"]; export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"];
@@ -229,11 +240,7 @@ export const WORKFLOW_STAGES = [
}, },
{ {
label: "Payment", label: "Payment",
statuses: [ statuses: ["FULLY_EXECUTED", "PAID"],
"PNR_GENERATED",
"PAYMENT_VERIFICATION_IN_PROGRESS",
"PAID",
],
}, },
{ {
label: "Operations", label: "Operations",

View File

@@ -18,6 +18,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
return { return {
id: booking.id, id: booking.id,
reference: booking.reference, reference: booking.reference,
approvalSteps: booking.approvalSteps,
customerLabel: labelFromRef(booking.company, booking.companyId), customerLabel: labelFromRef(booking.company, booking.companyId),
// customerLabel: labelFromRef(booking.customer, booking.customerId), // customerLabel: labelFromRef(booking.customer, booking.customerId),
status: booking.status, 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) { export function useBookingDetail(id: string | undefined) {
return useQuery({ return useQuery({
queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? ""), queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? ""),
@@ -103,23 +111,10 @@ export function useBookingMutations(bookingId: string) {
onError: () => toast.error("Failed to sign contract"), onError: () => toast.error("Failed to sign contract"),
}); });
const generatePnr = useMutation({ const payBooking = useMutation({
mutationFn: () => api.bookings.generatePnr.call({ id: bookingId }), mutationFn: () => api.bookings.payBooking.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "PNR generated"), onSuccess: (data) => onSuccess(data, "Payment completed"),
onError: () => toast.error("Failed to generate PNR"), onError: () => toast.error("Failed to complete payment"),
});
const submitPaymentProof = useMutation({
mutationFn: (file: File) =>
bookingsService.submitPaymentProof(bookingId, file),
onSuccess: (data) => onSuccess(data, "Payment proof uploaded"),
onError: () => toast.error("Failed to upload payment proof"),
});
const verifyPayment = useMutation({
mutationFn: () => api.bookings.verifyPayment.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Payment verified"),
onError: () => toast.error("Failed to verify payment"),
}); });
const startTransit = useMutation({ const startTransit = useMutation({
@@ -149,9 +144,7 @@ export function useBookingMutations(bookingId: string) {
rejectStep.isPending || rejectStep.isPending ||
generateContract.isPending || generateContract.isPending ||
signContract.isPending || signContract.isPending ||
generatePnr.isPending || payBooking.isPending ||
submitPaymentProof.isPending ||
verifyPayment.isPending ||
startTransit.isPending || startTransit.isPending ||
complete.isPending || complete.isPending ||
cancel.isPending; cancel.isPending;
@@ -164,15 +157,11 @@ export function useBookingMutations(bookingId: string) {
rejectStep, rejectStep,
generateContract, generateContract,
signContract, signContract,
generatePnr, payBooking,
submitPaymentProof,
verifyPayment,
startTransit, startTransit,
complete, complete,
cancel, cancel,
isPending, isPending,
downloadContract: () => bookingsService.downloadContract(bookingId), 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 { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@@ -17,7 +17,6 @@ import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { invalidateBookingDetail } from "@/utils/queryInvalidation"; import { invalidateBookingDetail } from "@/utils/queryInvalidation";
import { import {
bookingsService, bookingsService,
type ContractView,
type SignContractPayload, type SignContractPayload,
} from "@/services/bookings.service"; } from "@/services/bookings.service";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -37,6 +36,7 @@ export default function BookingContractPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
const iframeRef = useRef<HTMLIFrameElement>(null);
const [signOpen, setSignOpen] = useState(false); const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState(""); const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null); const [signatureData, setSignatureData] = useState<string | null>(null);
@@ -82,7 +82,10 @@ export default function BookingContractPage() {
} }
}, [id, data?.reference]); }, [id, data?.reference]);
const handlePrint = () => window.print(); const handlePrint = () => {
iframeRef.current?.contentWindow?.focus();
iframeRef.current?.contentWindow?.print();
};
const openSign = () => { const openSign = () => {
setSignerName(""); setSignerName("");
@@ -135,7 +138,7 @@ export default function BookingContractPage() {
/> />
</div> </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)}> <Button variant="ghost" size="sm" className="gap-2" onClick={() => navigate(-1)}>
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
Back Back
@@ -158,9 +161,19 @@ export default function BookingContractPage() {
</div> </div>
</div> </div>
<article {!data.hasContractDocument && (
className="contract-document mx-auto max-w-[210mm] rounded-xl border bg-white p-8 shadow-sm print:border-0 print:shadow-none" <div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 print:hidden">
dangerouslySetInnerHTML={{ __html: extractBodyHtml(data.html) }} 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}> <Dialog open={signOpen} onOpenChange={setSignOpen}>
@@ -210,9 +223,3 @@ export default function BookingContractPage() {
</div> </div>
); );
} }
/** Render server HTML body content inside our layout wrapper. */
function extractBodyHtml(fullHtml: string): string {
const match = fullHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
return match ? match[1] : fullHtml;
}

View File

@@ -13,17 +13,20 @@ import {
RefreshCw, RefreshCw,
Train, Train,
Truck, Truck,
Weight,
} from "lucide-react"; } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs"; import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard"; import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar"; import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary"; import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper"; 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 { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { import {
@@ -49,7 +52,7 @@ export default function BookingRequestDetailPage() {
return ( return (
<div className={bookingSurface.page}> <div className={bookingSurface.page}>
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-8"> <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"> <p className="text-sm font-medium text-muted-foreground">
Loading booking Loading booking
</p> </p>
@@ -68,8 +71,13 @@ export default function BookingRequestDetailPage() {
"mx-auto max-w-md p-12 text-center", "mx-auto max-w-md p-12 text-center",
)} )}
> >
<div className="mx-auto flex size-16 items-center justify-center rounded-2xl bg-muted"> <div
<Package className="size-8 text-muted-foreground" /> className={cn(
"mx-auto flex size-16 items-center justify-center rounded-2xl",
bookingGlass.iconWellGreen,
)}
>
<Package className="size-8" />
</div> </div>
<h1 className="mt-6 text-xl font-bold text-foreground"> <h1 className="mt-6 text-xl font-bold text-foreground">
Booking not found Booking not found
@@ -107,31 +115,54 @@ export default function BookingRequestDetailPage() {
<div className={bookingSurface.detailHero}> <div className={bookingSurface.detailHero}>
<div className={bookingSurface.heroGlow} /> <div className={bookingSurface.heroGlow} />
<div className={bookingSurface.heroSheen} />
<div className="relative p-6 sm:p-8"> <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 flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div className="flex gap-4"> <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"> <div
<Package className="size-7" /> className={cn(
"flex size-16 shrink-0 items-center justify-center rounded-2xl",
bookingGlass.iconWellGreen,
)}
>
<Package className="size-7" strokeWidth={1.75} />
</div> </div>
<div className="min-w-0 space-y-3"> <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"> <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} {booking.reference}
</h1> </h1>
<BookingStatusBadge status={booking.status} /> <BookingStatusBadge status={booking.status} />
<BookingPriorityBadge score={booking.priorityScore} /> <BookingPriorityBadge score={booking.priorityScore} />
</div> </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"> <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"> <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} {row.customerLabel}
</span> </span>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<Calendar className="size-4" /> <Calendar className="size-4 opacity-70" />
Scheduled {booking.scheduledDate} Scheduled {booking.scheduledDate}
</span> </span>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<Clock className="size-4" /> <Clock className="size-4 opacity-70" />
Created{" "} Created{" "}
{new Date(booking.createdAt).toLocaleDateString(undefined, { {new Date(booking.createdAt).toLocaleDateString(undefined, {
dateStyle: "medium", dateStyle: "medium",
@@ -142,11 +173,11 @@ export default function BookingRequestDetailPage() {
</div> </div>
<div className="flex flex-col items-stretch gap-3 sm:items-end"> <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"> <div className={cn(bookingSurface.valueCard, "min-w-[12rem] text-right")}>
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground"> <p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Total value Total value
</p> </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}{" "} {booking.paymentCurrency}{" "}
{amount.toLocaleString(undefined, { {amount.toLocaleString(undefined, {
minimumFractionDigits: 2, minimumFractionDigits: 2,
@@ -159,7 +190,7 @@ export default function BookingRequestDetailPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" 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} disabled={isFetching}
onClick={() => refetch()} onClick={() => refetch()}
> >
@@ -191,7 +222,7 @@ export default function BookingRequestDetailPage() {
title="Contract summary" title="Contract summary"
subtitle="Generated terms" 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} {booking.contractSummary}
</pre> </pre>
</SectionShell> </SectionShell>
@@ -213,7 +244,8 @@ export default function BookingRequestDetailPage() {
<div className={bookingSurface.sectionCard}> <div className={bookingSurface.sectionCard}>
<div className="px-5 py-4"> <div className="px-5 py-4">
<Button <Button
className="w-full gap-2" className="w-full gap-2 shadow-sm"
variant="default"
onClick={() => onClick={() =>
navigate(`/dashboard/booking-requests/${booking.id}/contract`) navigate(`/dashboard/booking-requests/${booking.id}/contract`)
} }
@@ -249,9 +281,7 @@ function SectionShell({
return ( return (
<div className={bookingSurface.sectionCard}> <div className={bookingSurface.sectionCard}>
<div className={bookingSurface.sectionHeader}> <div className={bookingSurface.sectionHeader}>
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary"> <div className={bookingSurface.sectionIcon}>{icon}</div>
{icon}
</div>
<div> <div>
<h2 className="text-sm font-semibold text-foreground">{title}</h2> <h2 className="text-sm font-semibold text-foreground">{title}</h2>
{subtitle && ( {subtitle && (
@@ -277,14 +307,22 @@ function RouteCard({
title="Route & service" title="Route & service"
subtitle="Corridor and service level" 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} /> <RouteEndpoint label="Origin" station={row.originLabel} />
<div className="flex flex-col items-center gap-2 px-4"> <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"> <div className={cn("flex size-10 items-center justify-center rounded-full", bookingGlass.iconWellGreen)}>
<Train className="size-5" /> <Train className="size-5 text-black" strokeWidth={1.75} />
</div> </div>
<ArrowRight className="size-5 rotate-90 text-muted-foreground md:rotate-0" /> <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?.label ??
booking.serviceType?.code ?? booking.serviceType?.code ??
"Rail service"} "Rail service"}
@@ -369,10 +407,10 @@ function CargoCard({ booking }: { booking: BookingDetail }) {
{containers.length > 0 && ( {containers.length > 0 && (
<> <>
<Separator className="my-5" /> <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"> <table className="w-full text-sm">
<thead> <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">Container type</th>
<th className="px-4 py-3">Qty</th> <th className="px-4 py-3">Qty</th>
<th className="px-4 py-3">VGM / unit</th> <th className="px-4 py-3">VGM / unit</th>
@@ -415,14 +453,14 @@ function RouteEndpoint({
}) { }) {
return ( return (
<div className="flex min-w-0 items-center gap-3 md:max-w-[14rem]"> <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"> <div className={bookingSurface.sectionIconLg}>
<MapPin className="size-5" /> <MapPin className="size-5" strokeWidth={1.75} />
</div> </div>
<div className="min-w-0"> <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} {label}
</p> </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>
</div> </div>
); );
@@ -444,10 +482,10 @@ function MetricTile({
highlight && "border-amber-300/50 bg-amber-50/50 dark:bg-amber-950/20", 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} {label}
</p> </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} {value}
</p> </p>
</div> </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 { useNavigate } from "react-router-dom";
import { import {
AlertCircle, AlertCircle,
@@ -23,12 +23,18 @@ import {
} from "@/components/bookings/BookingStatusTabs"; } from "@/components/bookings/BookingStatusTabs";
import { BookingStatGrid } from "@/components/bookings/BookingStatGrid"; import { BookingStatGrid } from "@/components/bookings/BookingStatGrid";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty"; 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 { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; 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 { BookingListFilter } from "@/services/bookings.service";
import type { BookingListRow } from "@/types/booking"; import type { BookingListRow } from "@/types/booking";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -42,16 +48,26 @@ import {
Input, Input,
} from "@edr/ui-common"; } 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); 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() { export default function BookingRequestsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState(""); 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( const filter: BookingListFilter = useMemo(
() => ({ () => ({
@@ -59,12 +75,18 @@ export default function BookingRequestsPage() {
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
sortBy: "createdAt", sortBy: "createdAt",
sortOrder: "DESC", 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, isLoading, isError, refetch, isFetching } = useBookingList(filter);
const {
data: summary,
isLoading: summaryLoading,
refetch: refetchSummary,
} = useBookingListSummary(filter);
const rows = useMemo(() => { const rows = useMemo(() => {
const items = (data?.items ?? []).map(toBookingListRow); const items = (data?.items ?? []).map(toBookingListRow);
@@ -82,26 +104,39 @@ export default function BookingRequestsPage() {
const hasSearch = query.trim().length > 0; const hasSearch = query.trim().length > 0;
const showEmpty = !isLoading && !isError && rows.length === 0; const showEmpty = !isLoading && !isError && rows.length === 0;
const pendingCount = rows.filter( const metrics = summary?.metrics;
(b) => b.status === "SUBMITTED" || b.status === "PENDING_APPROVAL", const tabCounts = summary?.tabs;
).length; const statValue = (value: number | undefined) =>
const urgentCount = rows.filter((b) => b.priorityScore >= 1000).length; 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>[] = [ const columns: ColumnDef<BookingListRow>[] = [
{ {
id: "booking", id: "booking",
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Booking</span>, header: () => <span className={bookingTable.headerCell}>Booking</span>,
cell: ({ row }) => { cell: ({ row }) => {
const b = row.original; const b = row.original;
return ( return (
<div className="flex items-center gap-3 py-1"> <div className="flex items-center gap-3 py-1.5">
<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"> <div className={bookingTable.rowIcon}>
<Package className="size-4" /> <Package className="size-4" strokeWidth={1.75} />
</div> </div>
<div className="min-w-0"> <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"> <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} {b.customerLabel}
</p> </p>
</div> </div>
@@ -111,7 +146,7 @@ export default function BookingRequestsPage() {
}, },
{ {
id: "route", id: "route",
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Route</span>, header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => { cell: ({ row }) => {
const b = row.original; const b = row.original;
return ( return (
@@ -122,10 +157,16 @@ export default function BookingRequestsPage() {
<span className="max-w-[8rem] truncate">{b.destinationLabel}</span> <span className="max-w-[8rem] truncate">{b.destinationLabel}</span>
</div> </div>
<div className="flex gap-1.5"> <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} {b.tradeDirection}
</Badge> </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} {b.freightType}
</Badge> </Badge>
</div> </div>
@@ -135,12 +176,19 @@ export default function BookingRequestsPage() {
}, },
{ {
id: "status", 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} />, cell: ({ row }) => <BookingStatusBadge status={row.original.status} />,
}, },
{
id: "approval",
header: () => (
<span className={bookingTable.headerCell}>Approval</span>
),
cell: ({ row }) => <BookingApprovalProgressCell row={row.original} />,
},
{ {
id: "scheduled", id: "scheduled",
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Scheduled</span>, header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground"> <span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" /> <Calendar className="size-3.5" />
@@ -150,7 +198,7 @@ export default function BookingRequestsPage() {
}, },
{ {
id: "priority", id: "priority",
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Priority</span>, header: () => <span className={bookingTable.headerCell}>Priority</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<BookingPriorityBadge score={row.original.priorityScore} /> <BookingPriorityBadge score={row.original.priorityScore} />
), ),
@@ -158,7 +206,7 @@ export default function BookingRequestsPage() {
{ {
id: "amount", id: "amount",
header: () => ( header: () => (
<span className="text-xs font-semibold uppercase tracking-wider">Amount</span> <span className={bookingTable.headerCell}>Amount</span>
), ),
cell: ({ row }) => { cell: ({ row }) => {
const b = row.original; const b = row.original;
@@ -176,12 +224,14 @@ export default function BookingRequestsPage() {
id: "actions", id: "actions",
size: 140, size: 140,
header: () => ( header: () => (
<span className="text-xs font-semibold uppercase tracking-wider"> <span className={bookingTable.headerCell}>Actions</span>
Actions
</span>
), ),
cell: ({ row }) => ( 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.hero}>
<div className={bookingSurface.heroGlow} /> <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="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 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"> <div
<Inbox className="size-7" /> className={cn(
"flex size-14 shrink-0 items-center justify-center rounded-2xl",
bookingGlass.iconWellGreen,
)}
>
<Inbox className="size-6" strokeWidth={1.75} />
</div> </div>
<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 Booking requests
</h1> </h1>
<p className="mt-1.5 max-w-xl text-sm leading-relaxed text-muted-foreground"> <p className="mt-1.5 max-w-xl text-sm leading-relaxed text-muted-foreground">
@@ -211,9 +270,9 @@ export default function BookingRequestsPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="gap-2" className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
disabled={isFetching} disabled={isFetching}
onClick={() => refetch()} onClick={handleRefresh}
> >
<RefreshCw <RefreshCw
className={cn("size-4", isFetching && "animate-spin")} className={cn("size-4", isFetching && "animate-spin")}
@@ -228,29 +287,33 @@ export default function BookingRequestsPage() {
items={[ items={[
{ {
label: "In queue", label: "In queue",
value: total, value: statValue(metrics?.inQueue),
hint: "Total matching filter", hint: "Total matching filter",
icon: LayoutList, icon: LayoutList,
}, },
{ {
label: "On this page", label: "On this page",
value: rows.length, value: statValue(metrics?.onThisPage),
hint: "Current view", hint: "Current page",
icon: FileText, icon: FileText,
}, },
{ {
label: "Needs action", label: "Needs action",
value: pendingCount, value: statValue(metrics?.needsAction),
hint: "Submitted or pending approval", hint: "Submitted or pending approval",
icon: Clock, icon: Clock,
accent: "amber", accent:
!summaryLoading && (metrics?.needsAction ?? 0) > 0
? "amber"
: "default",
}, },
{ {
label: "Urgent", label: "Urgent",
value: urgentCount, value: statValue(metrics?.urgent),
hint: "High priority score", hint: "High priority score",
icon: AlertCircle, 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); setActiveTab(tab);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}} }}
counts={{ counts={tabCounts}
[activeTab]: total,
}}
/> />
<div className={bookingSurface.panel}> <div className={bookingSurface.panel}>
@@ -290,7 +351,11 @@ export default function BookingRequestsPage() {
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <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" : ""} {total} record{total !== 1 ? "s" : ""}
</span> </span>
</div> </div>
@@ -300,7 +365,7 @@ export default function BookingRequestsPage() {
<BookingTableEmpty <BookingTableEmpty
isError={isError} isError={isError}
hasSearch={hasSearch} hasSearch={hasSearch}
onRetry={() => refetch()} onRetry={handleRefresh}
/> />
) : ( ) : (
<div className={bookingSurface.tableWrap}> <div className={bookingSurface.tableWrap}>
@@ -308,9 +373,7 @@ export default function BookingRequestsPage() {
columns={columns} columns={columns}
data={rows} data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"} status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => onRowClick={handleRowClick}
navigate(`/dashboard/booking-requests/${row.id}`)
}
pagination={{ pagination={{
pageIndex: pagination.pageIndex, pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
@@ -323,7 +386,13 @@ export default function BookingRequestsPage() {
manualPagination: true, manualPagination: true,
pageCount, 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} footer={DataTableFooter}
/> />
</div> </div>

View File

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

View File

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

View File

@@ -7,6 +7,10 @@ const B = URL_CONSTANTS.BOOKINGS;
export interface BookingListFilter { export interface BookingListFilter {
status?: string; status?: string;
/** Comma-separated statuses for grouped tabs */
statuses?: string;
/** Tab key for React Query cache (not sent to API) */
tab?: string;
// customerId?: string; // customerId?: string;
companyId?: string; companyId?: string;
freightType?: string; freightType?: string;
@@ -23,6 +27,29 @@ export interface PaginatedBookings {
total: number; 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 { export interface ApproveStepPayload {
id: string; id: string;
stepId: string; stepId: string;
@@ -66,9 +93,41 @@ async function postBooking<T>(url: string, body?: unknown): Promise<T> {
} }
export const bookingsService = { 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> => { 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, { const response = await client.get<PaginatedBookings>(B.BASE, {
params: filter, params,
}); });
const data = unwrap(response.data); const data = unwrap(response.data);
return { return {
@@ -112,14 +171,14 @@ export const bookingsService = {
const response = await client.get(B.CONTRACT_DOWNLOAD(id), { const response = await client.get(B.CONTRACT_DOWNLOAD(id), {
responseType: "blob", responseType: "blob",
}); });
return response.data as Blob; return ensurePdfBlob(response.data as Blob);
}, },
downloadContractDocument: async (id: string): Promise<Blob> => { downloadContractDocument: async (id: string): Promise<Blob> => {
const response = await client.get(B.CONTRACT_DOCUMENT(id), { const response = await client.get(B.CONTRACT_DOCUMENT(id), {
responseType: "blob", responseType: "blob",
}); });
return response.data as Blob; return ensurePdfBlob(response.data as Blob);
}, },
signContract: (id: string, payload: SignContractPayload) => signContract: (id: string, payload: SignContractPayload) =>
@@ -142,26 +201,7 @@ export const bookingsService = {
role: "STAFF", role: "STAFF",
}), }),
generatePnr: (id: string) => postBooking<BookingDetail>(B.PAYMENT_PNR(id)), payBooking: (id: string) => postBooking<BookingDetail>(B.PAYMENT_PAY(id)),
submitPaymentProof: async (id: string, file: File): Promise<BookingDetail> => {
const form = new FormData();
form.append("file", file);
const response = await client.post<BookingDetail>(B.PAYMENT_PROOF(id), form, {
headers: { "Content-Type": "multipart/form-data" },
});
return unwrap(response.data) as BookingDetail;
},
verifyPayment: (id: string) =>
postBooking<BookingDetail>(B.PAYMENT_VERIFY(id)),
downloadPaymentRequestLetter: async (id: string): Promise<Blob> => {
const response = await client.get(B.PAYMENT_REQUEST_LETTER(id), {
responseType: "blob",
});
return response.data as Blob;
},
startTransit: (id: string) => startTransit: (id: string) =>
postBooking<BookingDetail>(B.START_TRANSIT(id)), postBooking<BookingDetail>(B.START_TRANSIT(id)),
@@ -171,3 +211,11 @@ export const bookingsService = {
cancel: (id: string, reason: string) => cancel: (id: string, reason: string) =>
postBooking<BookingDetail>(B.CANCEL(id), { reason }), 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; id: string;
stepOrder: number; stepOrder: number;
requiredRole: string; requiredRole: string;
blocksRole?: string | null;
status: "PENDING" | "APPROVED" | "REJECTED" | "SKIPPED"; status: "PENDING" | "APPROVED" | "REJECTED" | "SKIPPED";
actionedAt?: string | null; actionedAt?: string | null;
remarks?: 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 { export interface BookingReviewNote {
id: string; id: string;
note: string; note: string;
@@ -90,6 +106,8 @@ export interface BookingDetail {
equipmentReturn?: string; equipmentReturn?: string;
contractSummary?: string | null; contractSummary?: string | null;
latestChangeRequestNote?: string | null; latestChangeRequestNote?: string | null;
nextStep?: BookingNextStep | null;
paymentReceipt?: InAppPaymentReceipt;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
// customer?: BookingNamedRef & { companyName?: string }; // customer?: BookingNamedRef & { companyName?: string };
@@ -114,6 +132,7 @@ export interface BookingListRow {
id: string; id: string;
reference: string; reference: string;
customerLabel: string; customerLabel: string;
approvalSteps?: BookingApprovalStep[];
status: BookingStatus; status: BookingStatus;
scheduledDate: string; scheduledDate: string;
totalAmount: number; totalAmount: number;

View File

@@ -3,7 +3,9 @@ import {
ExecutionContext, ExecutionContext,
Injectable, Injectable,
NestInterceptor, NestInterceptor,
StreamableFile,
} from "@nestjs/common"; } from "@nestjs/common";
import { Readable } from "stream";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { map } from "rxjs/operators"; import { map } from "rxjs/operators";
@@ -16,18 +18,28 @@ export interface StandardResponse<T> {
@Injectable() @Injectable()
export class ResponseTransformInterceptor<T> implements NestInterceptor< export class ResponseTransformInterceptor<T> implements NestInterceptor<
T, T,
StandardResponse<T> StandardResponse<T> | T
> { > {
intercept( intercept(
_context: ExecutionContext, _context: ExecutionContext,
next: CallHandler<T>, next: CallHandler<T>,
): Observable<StandardResponse<T>> { ): Observable<StandardResponse<T> | T> {
return next.handle().pipe( return next.handle().pipe(
map((data) => ({ map((data) => {
success: true, if (
data, data instanceof StreamableFile ||
timestamp: new Date().toISOString(), 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 <TableRow
key={row.id} key={row.id}
data-state={row.getIsSelected() && "selected"} 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" : ""} role={onRowClick ? "button" : ""}
className={ className={
onRowClick onRowClick