resolve conflict

This commit is contained in:
hagiye
2026-06-05 14:17:48 +03:00
126 changed files with 7123 additions and 1388 deletions

View File

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

View File

@@ -30,12 +30,15 @@ import { OtpModule } from './modules/otp/otp.module';
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
import { FreightAuthModule } from "./modules/auth/freight-auth.module";
import {
EDR_FREIGHT_APPLICATION,
EDR_FREIGHT_PERMISSIONS,
} from "./seed/edr-freight.seed";
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
@@ -89,19 +92,22 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
RuleEngineModule,
BackofficeModule,
DemoPermissionsModule,
FreightAuthModule,
PaymentModule,
//New Modules
TrainsModule,
WagonsModule,
ContainersModule,
CargoesModule,
],
providers: [EdrOrgSeeder, DemoUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
})
export class AppModule implements OnApplicationBootstrap {
constructor(
private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
private readonly demoBookingsSeeder: DemoBookingsSeeder,
private readonly pricingDataSeeder: PricingDataSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
@@ -111,6 +117,7 @@ export class AppModule implements OnApplicationBootstrap {
await this.seeder.run();
await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run();
await this.freightStaffUsersSeeder.run();
await this.demoBookingsSeeder.run();
await this.pricingDataSeeder.run();
await this.fileUploadSettingsSeeder.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,109 @@
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';
const ORGANIZATION_ADMIN_ROLE = 'organization_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);
}
export function isOrganizationAdmin(user: MeLikeUser | null | undefined): boolean {
if (!user?.roles?.length) return false;
return user.roles.some((r) => r.key === ORGANIZATION_ADMIN_ROLE);
}
export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boolean {
return isSuperAdmin(user) || isOrganizationAdmin(user);
}
/** 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 (isFreightApprovalAdmin(user)) return;
const perm = APPROVE_ROLE_PERMISSION[requiredRole];
if (!perm) {
throw new ForbiddenException(`Unknown approval role: ${requiredRole}`);
}
assertFreightPermission(user, perm);
}

View File

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

View File

@@ -1,57 +1,116 @@
import { Injectable, Logger } from '@nestjs/common';
import { existsSync } from 'fs';
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
const MIN_VALID_PDF_BYTES = 2_000;
const PDF_PRINT_STYLES = `
<style id="contract-pdf-print-fix">
@media print {
html, body {
background: #fff !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.cover {
min-height: auto !important;
page-break-after: always;
}
.cover-title {
margin: 24mm 0 20mm !important;
}
}
</style>`;
@Injectable()
export class ContractPdfService {
private readonly logger = new Logger(ContractPdfService.name);
async htmlToPdfBuffer(html: string): Promise<Buffer> {
const preparedHtml = this.injectPdfPrintStyles(html);
const executablePath = this.resolveExecutablePath();
try {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
const launchOptions: import('puppeteer').LaunchOptions = {
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
],
...(executablePath ? { executablePath } : {}),
};
const browser = await puppeteer.default.launch(launchOptions);
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'load' });
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
await page.setContent(preparedHtml, {
waitUntil: 'load',
timeout: 60_000,
});
await page.emulateMediaType('print');
await new Promise((resolve) => setTimeout(resolve, 400));
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
displayHeaderFooter: true,
headerTemplate: '<span></span>',
footerTemplate:
'<div style="width:100%;font-size:8px;color:#64748b;text-align:center;font-family:Arial,sans-serif;">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
margin: { top: '18mm', bottom: '22mm', left: '14mm', right: '14mm' },
});
return Buffer.from(pdf);
const buffer = Buffer.from(pdf);
if (!this.isValidPdf(buffer)) {
throw new Error(
`Puppeteer produced invalid PDF (${buffer.length} bytes)`,
);
}
this.logger.log(
`Contract PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`,
);
return buffer;
} finally {
await browser.close();
}
} catch (err) {
this.logger.warn(
`Puppeteer PDF failed, falling back to minimal PDF stub: ${err}`,
this.logger.error(
`Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`,
);
throw new InternalServerErrorException(
'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
);
return this.fallbackPdfBuffer(html);
}
}
/** Minimal valid PDF when Chromium is unavailable. */
private fallbackPdfBuffer(html: string): Buffer {
const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').slice(0, 2000);
const escaped = text.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
const stream = `BT /F1 10 Tf 50 750 Td (${escaped}) Tj ET`;
const len = stream.length;
const pdf = `%PDF-1.4
1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj
2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj
3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>endobj
4 0 obj<< /Length ${len} >>stream
${stream}
endstream endobj
5 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>endobj
xref
0 6
0000000000 65535 f
trailer<< /Size 6 /Root 1 0 R >>
startxref
0
%%EOF`;
return Buffer.from(pdf, 'utf-8');
private injectPdfPrintStyles(html: string): string {
if (html.includes('contract-pdf-print-fix')) return html;
if (html.includes('</head>')) {
return html.replace('</head>', `${PDF_PRINT_STYLES}</head>`);
}
return `${PDF_PRINT_STYLES}${html}`;
}
private resolveExecutablePath(): string | undefined {
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
if (fromEnv && existsSync(fromEnv)) return fromEnv;
const candidates = [
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/google-chrome-stable',
'/usr/bin/google-chrome',
];
return candidates.find((p) => existsSync(p));
}
private isValidPdf(buffer: Buffer): boolean {
return (
buffer.length >= MIN_VALID_PDF_BYTES &&
buffer.subarray(0, 5).toString('ascii') === '%PDF-'
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,31 @@
<h2>Article 5: Contract Price and Terms of Payment</h2>
<div class="article">
<h3>Contract Price</h3>
<p><strong>Corridor:</strong> {{pricing.originLabel}}{{pricing.destinationLabel}}</p>
<p>
The contract price is calculated based on the agreed railway corridor, cargo details, applicable rate
schedule, and any approved operational surcharges.
</p>
<table class="details-table">
<tbody>
<tr>
<th>Corridor</th>
<td>{{pricing.originLabel}}{{pricing.destinationLabel}}</td>
<th>Currency</th>
<td>{{pricing.currency}}</td>
</tr>
<tr>
<th>Payment currency</th>
<td>{{paymentArticle}}</td>
<th>Equipment return</th>
<td>{{pricing.equipmentReturn}}</td>
</tr>
</tbody>
</table>
{{#if pricing.equipmentReturn}}
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
{{/if}}
{{#if pricing.containerLines.length}}
<table class="schedule">
<thead>
<tr><th>Container type</th><th>Quantity</th><th>VGM / unit (t)</th></tr>
</thead>
<tbody>
{{#each pricing.containerLines}}
<tr><td>{{label}}</td><td>{{quantity}}</td><td>{{vgmPerUnitTons}}</td></tr>
{{/each}}
</tbody>
</table>
{{/if}}
<h3>Charges</h3>
<table class="schedule">
<thead>
<tr><th>Item</th><th>Description</th><th>Amount</th></tr>
@@ -29,6 +38,10 @@
<td>{{currency}} {{amount}}</td>
</tr>
{{/each}}
{{#if pricing.surcharges.length}}
<tr>
<th colspan="3">Surcharges and Adjustments</th>
</tr>
{{#each pricing.surcharges}}
<tr>
<td>{{label}}</td>
@@ -36,12 +49,18 @@
<td>{{currency}} {{amount}}</td>
</tr>
{{/each}}
<tr>
{{/if}}
<tr class="total-row">
<td colspan="2"><strong>Total contract value</strong></td>
<td><strong>{{pricing.currency}} {{pricing.totalAmount}}</strong></td>
</tr>
</tbody>
</table>
<h3>Terms of payment</h3>
<p>All payments shall be made in accordance with EDR policy in <strong>{{paymentArticle}}</strong>, unless otherwise agreed in writing.</p>
<p>
Unless otherwise agreed in writing, the Client shall settle the contract value in
<strong>{{paymentArticle}}</strong> before the service is performed and in accordance with EDR payment
instructions. Bank charges, penalties, demurrage, storage, and third-party charges remain the
responsibility of the Client where applicable.
</p>
</div>

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,12 @@
<div class="article">
<h2>Article 4: Force Majeure</h2>
<p>Neither party is liable for delays due to force majeure interpreted under the Ethiopian Civil Code.</p>
<p>
Neither party shall be liable for delay or non-performance caused by events beyond its reasonable control,
including natural disaster, war, civil unrest, government restriction, railway interruption, port closure,
or other force majeure events interpreted under the Ethiopian Civil Code.
</p>
<p>
The affected party shall notify the other party promptly and shall use reasonable efforts to reduce the
effect of the force majeure event on the performance of this Agreement.
</p>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner, TableIndex } from 'typeorm';
/**
* shipping_lines was created without a unique index on code; seeder upserts require it.
*/
export class AddShippingLinesCodeUniqueIndex1749800000000 implements MigrationInterface {
name = 'AddShippingLinesCodeUniqueIndex1749800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
const existing = await queryRunner.query(
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND tablename = 'shipping_lines' AND indexdef ILIKE '%UNIQUE%code%' LIMIT 1`,
);
if (existing.length === 0) {
await queryRunner.createIndex(
'freight.shipping_lines',
new TableIndex({
name: 'UQ_shipping_lines_code',
columnNames: ['code'],
isUnique: true,
}),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropIndex('freight.shipping_lines', 'UQ_shipping_lines_code');
}
}

View File

@@ -0,0 +1,63 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* file_upload_settings / file_upload_fields entities had no migration; seeder requires both tables.
*/
export class CreateFileUploadSettingsTables1749900000000 implements MigrationInterface {
name = 'CreateFileUploadSettingsTables1749900000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.file_upload_settings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code VARCHAR(128) NOT NULL,
label VARCHAR(256) NOT NULL,
description TEXT,
entity VARCHAR(32) NOT NULL DEFAULT 'other',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_file_upload_settings_code"
ON freight.file_upload_settings (code);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.file_upload_fields (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
setting_id UUID NOT NULL,
file_key VARCHAR(128) NOT NULL,
file_label VARCHAR(256) NOT NULL,
help_text TEXT,
is_required BOOLEAN NOT NULL DEFAULT false,
is_multiple BOOLEAN NOT NULL DEFAULT false,
max_files INTEGER NOT NULL DEFAULT 1,
allowed_extensions TEXT[] NOT NULL DEFAULT '{}'::text[],
max_size_mb INTEGER NOT NULL DEFAULT 10,
display_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
CONSTRAINT "CHK_file_upload_fields_max_files" CHECK (max_files > 0),
CONSTRAINT "CHK_file_upload_fields_max_size_mb" CHECK (max_size_mb > 0),
CONSTRAINT "FK_file_upload_fields_setting"
FOREIGN KEY (setting_id)
REFERENCES freight.file_upload_settings(id)
ON DELETE CASCADE
);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_file_upload_fields_setting_file_key"
ON freight.file_upload_fields (setting_id, file_key);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_fields CASCADE`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_settings CASCADE`);
}
}

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Train entity gained extended fields; baseline trains table only had code/capacity/status/notes.
*/
export class AddTrainExtendedColumns1750000000000 implements MigrationInterface {
name = 'AddTrainExtendedColumns1750000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.trains
ADD COLUMN IF NOT EXISTS train_number VARCHAR(20),
ADD COLUMN IF NOT EXISTS train_name VARCHAR(100),
ADD COLUMN IF NOT EXISTS route_id UUID,
ADD COLUMN IF NOT EXISTS origin_station_id UUID,
ADD COLUMN IF NOT EXISTS destination_station_id UUID,
ADD COLUMN IF NOT EXISTS departure_time TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS arrival_time TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS locomotive_number VARCHAR(50),
ADD COLUMN IF NOT EXISTS remarks TEXT;
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_train_number"
ON freight.trains (train_number)
WHERE train_number IS NOT NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_train_number"`);
await queryRunner.query(`
ALTER TABLE freight.trains
DROP COLUMN IF EXISTS remarks,
DROP COLUMN IF EXISTS locomotive_number,
DROP COLUMN IF EXISTS arrival_time,
DROP COLUMN IF EXISTS departure_time,
DROP COLUMN IF EXISTS destination_station_id,
DROP COLUMN IF EXISTS origin_station_id,
DROP COLUMN IF EXISTS route_id,
DROP COLUMN IF EXISTS train_name,
DROP COLUMN IF EXISTS train_number;
`);
}
}

View File

@@ -0,0 +1,96 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreatePaymentTable1780639311366 implements MigrationInterface {
name = "CreatePaymentTable1780639311366";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE freight.payments_type_enum AS ENUM ('booking');
`);
await queryRunner.query(`
CREATE TYPE freight.payments_method_enum AS ENUM ('telebirr', 'cbe-birr', 'ebirr');
`);
await queryRunner.query(`
CREATE TYPE freight.payments_currency_enum AS ENUM ('ETB', 'USD');
`);
await queryRunner.query(`
CREATE TYPE freight.payments_status_enum AS ENUM (
'action-required',
'processing',
'success',
'failed',
'canceled',
'refunded'
);
`);
await queryRunner.query(`
CREATE TABLE freight.payments (
id uuid NOT NULL DEFAULT uuid_generate_v4(),
ref_id varchar(255) NOT NULL,
type freight.payments_type_enum NOT NULL,
method freight.payments_method_enum NOT NULL,
currency freight.payments_currency_enum NOT NULL,
amount numeric NOT NULL,
raw_initiation jsonb NOT NULL DEFAULT '{}'::jsonb,
client_action json,
merchant_order_id varchar(255) NOT NULL,
transaction_id varchar(255),
status freight.payments_status_enum NOT NULL DEFAULT 'action-required',
paid_at date,
refunded_at date,
expires_at date,
failer_code varchar(30),
failer_message varchar(255),
created_at TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT PK_payments PRIMARY KEY (id),
CONSTRAINT UQ_payments_merchant_order_id UNIQUE (merchant_order_id),
CONSTRAINT UQ_payments_transaction_id UNIQUE (transaction_id)
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP TABLE IF EXISTS freight.payments;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_status_enum;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_currency_enum;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_method_enum;
`);
await queryRunner.query(`
DROP TYPE IF EXISTS freight.payments_type_enum;
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AlterClientActionToJsonb1780639978834 implements MigrationInterface {
name = "AlterClientActionToJsonb1780639978834";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN client_action TYPE jsonb
USING client_action::jsonb;
`);
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN client_action DROP DEFAULT;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN client_action TYPE json
USING client_action::json;
`);
}
}

View File

@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UpdatePaymentTimestamp1780644945086 implements MigrationInterface {
name = "UpdatePaymentTimestamp1780644945086";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN refunded_at TYPE timestamp
USING refunded_at::timestamp;
`);
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN expires_at TYPE timestamp
USING expires_at::timestamp;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN refunded_at TYPE timestamptz
USING refunded_at::timestamptz;
`);
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN expires_at TYPE timestamptz
USING expires_at::timestamptz;
`);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
import { IsEnum, IsOptional, IsString } from "class-validator";
export enum PaymentStatus {
REQUIRES_ACTION,
PROCESSING,
SUCCEEDED,
FAILED,
CANCELLED,
REFUNDED,
}
export class UpdatePaymentStatusDto {
@IsString()
orderId!: string;
@IsEnum(PaymentStatus)
status!: PaymentStatus
@IsOptional()
@IsString()
failureMessage?: string
}

View File

@@ -0,0 +1,62 @@
import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";
type PaymentType = "booking"
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr"
type Currency = "ETB" | "USD"
type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
@Entity({ schema: 'freight', name: 'payments' })
export class PaymentEntity extends BaseEntity {
@PrimaryGeneratedColumn("uuid")
id!: string
@Column({ type: 'varchar', length: 255, name: "ref_id" })
refId!: string
@Column({ type: "enum", enum: ["booking"] })
type!: PaymentType;
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr"] })
method!: PaymentMethod
@Column({ type: "enum", enum: ["ETB", "USD"] })
currency!: Currency
@Column({ type: "numeric" })
amount!: number
@Column({ type: "jsonb", default: {}, name: "raw_initiation" })
rawInitiation?: Record<string, unknown>
@Column({ type: "jsonb", name: "client_action" })
clientAction?: Record<string, unknown>;
@Column({ type: "varchar", length: 255, unique: true, name: "merchant_order_id", })
merchantOrderId!: string
@Column({ type: "varchar", length: 255, unique: true, name: "transaction_id", })
transactionId?: string
@Column({ type: "enum", enum: ["action-required", "processing", "success", "failed", "canceled", "refunded"], default: "action-required" })
status!: PaymentStatus
@Column({ type: "date", nullable: true, name: "paid_at" })
paidAt?: Date
@Column({ type: "timestamp", nullable: true, name: "refunded_at" })
refundedAt?: Date
@Column({ type: "timestamp", nullable: true, name: "expires_at" })
expiresAt?: Date
@Column({ type: "varchar", length: 30, nullable: true, name: "failer_code" })
failerCode?: string
@Column({ type: "varchar", length: 255, nullable: true, name: "failer_message" })
failureMessage?: string
@CreateDateColumn({ name: "created_at" })
createdAt!: Date
}

View File

@@ -0,0 +1,53 @@
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common";
import { PaymentService } from "./payment.service";
import { Public } from "@edr/api-common";
import { randomUUID } from "crypto";
import { Response } from "express"
@Public()
@Controller("payments")
export class PaymentController {
constructor(private readonly paymentService: PaymentService) { }
@Post("/initiate")
async initiatePayment() {
//Only for testing..
const data = await this.paymentService.pay(20, "ETB", "telebirr", (_) => {
return new Promise((resp, _) => {
resp({
id: randomUUID(),
type: "booking"
})
});
})
return data
}
@Get("/telebirr/:refId")
async pay(@Param("refId", ParseUUIDPipe) refId: string, @Res() res: Response) {
const payment = await this.paymentService.getActivePaymentByRefIdAndMethod(refId, "telebirr")
if (!payment) {
throw new NotFoundException('payment not found')
}
return res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Redirecting...</title>
</head>
<body>
<p>Redirecting...</p>
<script>
window.location.href = "${payment.clientAction?.url}";
</script>
</body>
</html>
`);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from "@nestjs/common";
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
import { PaymentService } from "./payment.service";
import { HttpModule } from "@nestjs/axios";
import { PaymentController } from "./payment.controller";
import { ConfigModule } from "@nestjs/config";
import { PaymentRepository } from "./payment.repository";
import { WebhookController } from "./webhooks/webhook.controller";
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
@Module({
imports: [HttpModule, ConfigModule],
providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService],
controllers: [PaymentController, WebhookController]
})
export class PaymentModule { }

View File

@@ -0,0 +1,39 @@
import { Injectable } from "@nestjs/common";
import { DataSource, FindOptionsWhere, QueryDeepPartialEntity, QueryRunner, Repository } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity";
@Injectable()
export class PaymentRepository {
private readonly paymentRepo: Repository<PaymentEntity>;
constructor(private readonly dataSource: DataSource) {
this.paymentRepo = this.dataSource.getRepository(PaymentEntity)
}
async createTr(qr: QueryRunner, data: Pick<PaymentEntity, "amount" | "method" | "currency" | "type" | "refId" | "merchantOrderId" | "rawInitiation" | "clientAction" | "expiresAt">): Promise<PaymentEntity> {
const payment = qr.manager.create(PaymentEntity, data)
return qr.manager.save(payment)
}
findOneBy(options: FindOptionsWhere<PaymentEntity> | FindOptionsWhere<PaymentEntity>[]): Promise<PaymentEntity | null> {
return this.paymentRepo.findOneBy(options);
}
update(where: FindOptionsWhere<PaymentEntity>, data: QueryDeepPartialEntity<PaymentEntity>) {
return this.paymentRepo.update(where, data)
}
getActivePaymentByRefIdAndMethod(refId: string, method: PaymentEntity["method"]) {
return this.paymentRepo
.createQueryBuilder('payment')
.where('payment.method = :method', { method })
.andWhere('payment.refId = :refId', { refId })
.andWhere('payment.status IN (:...statuses)', {
statuses: ['action-required'],
})
.andWhere('payment.expiresAt > :now', { now: new Date() })
.getOne();
}
}

View File

@@ -0,0 +1,113 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { DataSource, QueryRunner } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentStrategy } from "./strategies/payment.strategy";
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
import { PaymentRepository } from "./payment.repository";
import { ClientAction, PaymentPlatform } from "./strategies/payments.types";
import * as crypto from 'crypto';
import { PaymentStatus, UpdatePaymentStatusDto } from "./dto/update-payment-status.dto";
type PaymentMethod = PaymentEntity["method"]
type CurrencyType = PaymentEntity["currency"]
@Injectable()
export class PaymentService {
private strategies: Map<PaymentMethod, PaymentStrategy>;
constructor(
private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository,
private readonly telebirrPaymentStategy: PaymentTelebirrStrategy) {
this.strategies = new Map([
["telebirr", this.telebirrPaymentStategy as PaymentStrategy]
])
}
async pay(amount: number, currency: CurrencyType, method: PaymentMethod, cb: (qr: QueryRunner) => Promise<{ id: string, type: PaymentEntity["type"] }>, payform: PaymentPlatform = "web"): Promise<{
refId: string,
clientAction: ClientAction,
status: PaymentEntity["status"],
paidAt?: string,
failureCode?: string,
failureMessage?: string,
}> {
const strategy = this.strategies.get(method)
if (!strategy) {
throw new NotFoundException("strategy not found")
}
const orderId = `freigh${Date.now()}${crypto.randomBytes(4).toString('hex')}` //todo: make it dynamic
const paymentResp = await strategy.pay({
amountMinor: amount,
currency: currency,
merchantOrderId: orderId,
platform: payform,
});
const queryRunner = this.datasource.createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction()
console.log(paymentResp.expiresAt)
try {
const resp = await cb(queryRunner)
const payment = await this.paymentRepo.createTr(queryRunner, {
amount,
currency,
method,
refId: resp.id,
type: resp.type,
merchantOrderId: orderId,
rawInitiation: paymentResp.rawInitiation,
clientAction: paymentResp.clientAction,
expiresAt: paymentResp.expiresAt
})
await queryRunner.commitTransaction()
return {
refId: payment.refId,
clientAction: paymentResp.clientAction,
status: payment.status,
paidAt: payment.paidAt?.toISOString(),
failureCode: payment.failerCode ?? undefined,
failureMessage: payment.failureMessage ?? undefined,
}
} catch (err) {
await queryRunner.rollbackTransaction()
throw new Error("payment failed")
} finally {
await queryRunner.release()
}
}
async getActivePaymentByRefIdAndMethod(refId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
return this.paymentRepo.getActivePaymentByRefIdAndMethod(refId, method)
}
async handleTelebirrPaymentCb(dto: UpdatePaymentStatusDto): Promise<void> {
switch (dto.status) {
case PaymentStatus.SUCCEEDED:
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "success" })
break;
case PaymentStatus.FAILED:
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "failed", failureMessage: dto.failureMessage })
break;
case PaymentStatus.CANCELLED:
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "canceled" })
break;
case PaymentStatus.PROCESSING:
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "canceled" })
break;
case PaymentStatus.REFUNDED:
await this.paymentRepo.update({ merchantOrderId: dto.orderId }, { status: "canceled" })
break;
}
}
}

View File

@@ -0,0 +1,8 @@
import { Injectable } from "@nestjs/common";
import { ProviderInitiationInput, ProviderInitiationResult } from "./payments.types";
@Injectable()
export abstract class PaymentStrategy {
abstract pay(data: ProviderInitiationInput): Promise<ProviderInitiationResult>
}

View File

@@ -0,0 +1,303 @@
import { Injectable, Logger } from "@nestjs/common";
import { PaymentStrategy } from "./payment.strategy";
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as https from 'node:https';
import { PaymentEntity } from "../entities/payment.entity";
import { ProviderInitiationInput, ProviderInitiationResult, ProviderStatus } from "./payments.types";
import { CreateOrderRequest, CreateOrderResponse, FabricTokenResponse, QueryOrderResponse } from "./telebirr/telebirr.types";
import { createNonceStr, createTimestamp, signRequestObject, verifyRequestObject } from "./telebirr/telebirr.crypto";
// type PaymentCurrency = PaymentEntity["currency"]
type PaymentIntentStatus = PaymentEntity["status"]
const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
@Injectable()
export class PaymentTelebirrStrategy implements PaymentStrategy {
async pay(data: ProviderInitiationInput): Promise<any> {
// const refId = randomUUID()
// const orderId = createMerchantOrderId()
const resp = await this.initiate(data)
return resp;
}
// readonly method = PaymentMethodType.TELEBIRR;
private readonly logger = new Logger(PaymentTelebirrStrategy.name);
private readonly httpsAgent: https.Agent;
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {
const insecure = this.config.get<boolean>('telebirr.insecureTls');
if (insecure) {
this.logger.warn('TELEBIRR_INSECURE_TLS=true — TLS verification disabled for Telebirr calls. DEV ONLY.');
}
this.httpsAgent = new https.Agent({
rejectUnauthorized: !insecure,
secureProtocol: 'TLSv1_2_method',
});
}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const fabricToken = await this.applyFabricToken();
const requestBody = this.buildCreateOrderRequest(input);
const response = await this.requestCreateOrder(fabricToken, requestBody);
const prepayId = response.biz_content?.prepay_id;
if (!prepayId) {
throw new Error(
`Telebirr createOrder returned no prepay_id: ${JSON.stringify(response)}`,
);
}
const expiresAt = this.computeExpiresAt(requestBody.biz_content.timeout_express);
const platform = input.platform ?? 'web';
const clientAction =
platform === 'mobile'
? {
type: 'LAUNCH_APP' as const,
prepayId,
receiveCode: response.biz_content?.receiveCode,
shortCode: this.merchantCode,
}
: { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) };
return {
providerOrderId: prepayId,
clientAction,
expiresAt,
rawInitiation: {
request: this.sanitize(requestBody),
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const fabricToken = await this.applyFabricToken();
const requestBody = this.buildQueryOrderRequest(merchantOrderId);
const response = await this.postJson<QueryOrderResponse>(
`${this.baseUrl}/payment/v1/merchant/queryOrder`,
requestBody,
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
Authorization: fabricToken,
},
);
const tradeStatus = response.biz_content?.trade_status;
const providerTxnId =
response.biz_content?.trans_id ?? response.biz_content?.payment_order_id;
const mapped = this.mapTradeStatus(tradeStatus);
return {
status: mapped,
providerTxnId,
failureCode:
mapped === "failed" && tradeStatus ? tradeStatus : undefined,
rawResponse: response as Record<string, unknown>,
};
}
mapTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
switch (tradeStatus) {
case 'PAY_SUCCESS':
return "success";
case 'PAY_FAILED':
case 'ORDER_CLOSED':
return "failed";
case 'WAIT_PAY':
return "action-required";
case 'PAYING':
return "processing";
default:
return "processing";
}
}
mapWebhookTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
switch (tradeStatus) {
case 'Completed':
return "success";
case 'Failure':
case 'Expired':
return "failed";
case 'Paying':
case 'Pending':
return "processing";
default:
return "processing";
}
}
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
if (!this.publicKey) {
this.logger.error('TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks');
return false;
}
return verifyRequestObject(payload, this.publicKey);
}
private async applyFabricToken(): Promise<string> {
console.log(this.baseUrl, "base url")
const response = await this.postJson<FabricTokenResponse>(
`${this.baseUrl}/payment/v1/token`,
{ appSecret: this.appSecret },
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
},
);
if (!response?.token) {
throw new Error(`Telebirr token request failed: ${JSON.stringify(response)}`);
}
return response.token;
}
private async requestCreateOrder(
fabricToken: string,
body: CreateOrderRequest,
): Promise<CreateOrderResponse> {
return this.postJson<CreateOrderResponse>(
`${this.baseUrl}/payment/v1/inapp/createOrder`,
body,
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
Authorization: fabricToken,
},
);
}
private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest {
const totalAmount = String(input.amountMinor / 100);
const req = {
timestamp: createTimestamp(),
nonce_str: createNonceStr(),
method: 'payment.preorder' as const,
version: '1.0' as const,
biz_content: {
notify_url: this.notifyUrl,
appid: this.merchantAppId,
merch_code: this.merchantCode,
merch_order_id: input.merchantOrderId,
trade_type: 'Checkout' as const,
title: `EDR Booking`,
total_amount: totalAmount,
trans_currency: input.currency,
timeout_express: this.timeoutExpress,
},
};
const sign = signRequestObject(req as unknown as Record<string, unknown>, this.privateKey);
return { ...req, sign, sign_type: 'SHA256WithRSA' };
}
private buildQueryOrderRequest(merchantOrderId: string): Record<string, unknown> {
const req = {
timestamp: createTimestamp(),
nonce_str: createNonceStr(),
method: 'payment.queryorder',
version: '1.0',
biz_content: {
appid: this.merchantAppId,
merch_code: this.merchantCode,
merch_order_id: merchantOrderId,
},
};
const sign = signRequestObject(req as Record<string, unknown>, this.privateKey);
return { ...req, sign, sign_type: 'SHA256WithRSA' };
}
private buildCheckoutUrl(prepayId: string): string {
const map: Record<string, string> = {
appid: this.merchantAppId,
merch_code: this.merchantCode,
nonce_str: createNonceStr(),
prepay_id: prepayId,
timestamp: createTimestamp(),
};
const sign = signRequestObject(map, this.privateKey);
const rawRequest = [
`appid=${map.appid}`,
`merch_code=${map.merch_code}`,
`nonce_str=${map.nonce_str}`,
`prepay_id=${map.prepay_id}`,
`timestamp=${map.timestamp}`,
'sign_type=SHA256WithRSA',
`sign=${sign}`,
'version=1.0',
'trade_type=Checkout',
].join('&');
return `${this.webBaseUrl}${rawRequest}`;
}
private computeExpiresAt(timeoutExpress: string): Date {
const match = /^(\d+)([smhd])$/.exec(timeoutExpress);
const minutes = match ? this.toMinutes(parseInt(match[1], 10), match[2]) : 15;
return new Date(Date.now() + minutes * 60_000);
}
private toMinutes(n: number, unit: string): number {
switch (unit) {
case 's': return Math.max(1, Math.round(n / 60));
case 'm': return n;
case 'h': return n * 60;
case 'd': return n * 60 * 24;
default: return 15;
}
}
private async postJson<T>(
url: string,
body: unknown,
headers: Record<string, string>,
): Promise<T> {
const config: AxiosRequestConfig = {
headers,
timeout: TELEBIRR_HTTP_TIMEOUT_MS,
httpsAgent: this.httpsAgent,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(`Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`Telebirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
);
} else {
this.logger.error(`Telebirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private sanitize(body: CreateOrderRequest): Record<string, unknown> {
const { sign: _sign, ...rest } = body;
return rest;
}
private get baseUrl(): string { return this.config.get<string>('telebirr.baseUrl') ?? ''; }
private get webBaseUrl(): string { return this.config.get<string>('telebirr.webBaseUrl') ?? ''; }
private get fabricAppId(): string { return this.config.get<string>('telebirr.fabricAppId') ?? ''; }
private get appSecret(): string { return this.config.get<string>('telebirr.appSecret') ?? ''; }
private get merchantAppId(): string { return this.config.get<string>('telebirr.merchantAppId') ?? ''; }
private get merchantCode(): string { return this.config.get<string>('telebirr.merchantCode') ?? ''; }
private get notifyUrl(): string { return this.config.get<string>('telebirr.notifyUrl') ?? ''; }
private get timeoutExpress(): string { return this.config.get<string>('telebirr.timeoutExpress') ?? '15m'; }
private get privateKey(): string { return this.config.get<string>('telebirr.privateKey') ?? ''; }
private get publicKey(): string {
return this.config.get<string>('telebirr.publicKey') ?? '';
}
}

View File

@@ -0,0 +1,39 @@
import { PaymentEntity } from "../entities/payment.entity";
type PaymentIntentStatus = PaymentEntity["status"]
type PaymentMethodType = PaymentEntity["method"]
export type PaymentPlatform = 'web' | 'mobile';
export type ClientAction =
| { type: 'REDIRECT'; url: string }
| { type: 'LAUNCH_APP'; prepayId: string; receiveCode?: string; shortCode: string };
export interface ProviderInitiationInput {
merchantOrderId: string;
// bookingRef: string;
amountMinor: number;
currency: string;
platform?: PaymentPlatform;
}
export interface ProviderInitiationResult {
providerOrderId: string;
clientAction: ClientAction;
expiresAt: Date;
rawInitiation: Record<string, unknown>;
}
export interface ProviderStatus {
status: PaymentIntentStatus;
providerTxnId?: string;
failureCode?: string;
failureMessage?: string;
rawResponse: Record<string, unknown>;
}
export interface PaymentProvider {
readonly method: PaymentMethodType;
initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult>;
queryStatus(merchantOrderId: string): Promise<ProviderStatus>;
}

View File

@@ -0,0 +1,98 @@
import * as crypto from 'crypto';
const EXCLUDE_FIELDS = new Set([
'sign',
'sign_type',
'header',
'refund_info',
'openType',
'raw_request',
'biz_content',
]);
const NONCE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
export function buildCanonicalString(requestObject: Record<string, unknown>): string {
const fieldMap: Record<string, unknown> = {};
for (const key of Object.keys(requestObject)) {
if (EXCLUDE_FIELDS.has(key)) continue;
fieldMap[key] = requestObject[key];
}
const biz = requestObject['biz_content'];
if (biz && typeof biz === 'object') {
for (const key of Object.keys(biz as Record<string, unknown>)) {
if (EXCLUDE_FIELDS.has(key)) continue;
fieldMap[key] = (biz as Record<string, unknown>)[key];
}
}
return Object.keys(fieldMap)
.sort()
.map((k) => `${k}=${fieldMap[k]}`)
.join('&');
}
export function signRequestObject(
requestObject: Record<string, unknown>,
privateKey: string,
): string {
return signString(buildCanonicalString(requestObject), privateKey);
}
export function verifyRequestObject(
requestObject: Record<string, unknown>,
publicKey: string,
): boolean {
const signature = requestObject['sign'];
if (typeof signature !== 'string' || signature.length === 0) return false;
return verifySignature(buildCanonicalString(requestObject), signature, publicKey);
}
export function signString(text: string, privateKey: string): string {
const signature = crypto.sign('sha256', Buffer.from(text), {
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
});
return signature.toString('base64');
}
export function verifySignature(
text: string,
signatureBase64: string,
publicKey: string,
): boolean {
try {
return crypto.verify(
'sha256',
Buffer.from(text),
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
},
Buffer.from(signatureBase64, 'base64'),
);
} catch {
return false;
}
}
export function createTimestamp(): string {
return Math.round(Date.now() / 1000).toString();
}
export function createNonceStr(length = 32): string {
const bytes = crypto.randomBytes(length);
let out = '';
for (let i = 0; i < length; i++) {
out += NONCE_CHARS[bytes[i] % NONCE_CHARS.length];
}
return out;
}
export function createMerchantOrderId(): string {
return `${Date.now()}${crypto.randomBytes(4).toString('hex')}`;
}

View File

@@ -0,0 +1,69 @@
export interface FabricTokenResponse {
token: string;
expires_in?: number | string;
}
export interface CreateOrderBizContent {
notify_url: string;
appid: string;
merch_code: string;
merch_order_id: string;
trade_type: 'Checkout' | 'InApp' | 'MiniApp';
title: string;
total_amount: string;
trans_currency: string;
timeout_express: string;
}
export interface CreateOrderRequest {
timestamp: string;
nonce_str: string;
method: 'payment.preorder';
version: '1.0';
biz_content: CreateOrderBizContent;
sign: string;
sign_type: 'SHA256WithRSA';
}
export interface CreateOrderResponse {
code?: string;
msg?: string;
biz_content?: {
prepay_id?: string;
receiveCode?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export type TelebirrTradeStatus =
| 'PAY_SUCCESS'
| 'PAY_FAILED'
| 'WAIT_PAY'
| 'ORDER_CLOSED'
| 'PAYING'
| 'ACCEPTED'
| 'REFUNDING'
| 'REFUND_SUCCESS'
| 'REFUND_FAILED';
export interface QueryOrderResponse {
result?: 'SUCCESS' | 'FAIL';
code?: string;
msg?: string;
nonce_str?: string;
sign?: string;
sign_type?: string;
biz_content?: {
merch_order_id?: string;
order_status?: string;
trade_status?: TelebirrTradeStatus | string;
payment_order_id?: string;
trans_id?: string;
trans_time?: string;
trans_currency?: string;
total_amount?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}

View File

@@ -0,0 +1,13 @@
export class TelebirrDto {
merch_order_id!: string;
payment_order_id!: string;
trade_status!: string;
trans_id?: string;
total_amount?: string;
trans_currency?: string;
notify_time?: string;
trans_end_time?: string;
sign!: string;
sign_type?: string;
[key: string]: unknown;
}

View File

@@ -0,0 +1,49 @@
import { Injectable, } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from "crypto"
import { TelebirrDto } from '../dto/telebirr.dto';
@Injectable()
export class TelebirrWebhookService {
// private readonly logger = new Logger(TelebirrWebhookService.name);
constructor(
private readonly config: ConfigService
) { }
verifyTelebirrNotification(payload: TelebirrDto) {
// 1. Extract the signature provided by Telebirr
const { sign, ...bizContent } = payload;
if (!sign) {
throw new Error("Missing 'sign' field from Telebirr payload");
}
// 2. Sort the remaining keys alphabetically to rebuild the raw string
const sortedKeys = Object.keys(bizContent).sort();
const signString = sortedKeys
.map(key => `${key}=${typeof bizContent[key] === 'object' ? JSON.stringify(bizContent[key]) : bizContent[key]}`)
.join('&');
// 3. Convert Telebirr's public key into an object specifying RSA-PSS padding
const publicKey = {
key: this.config.get<string>("telebirr.publicKey") ?? "",
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: 32 // Telebirr standard salt length
};
// 4. Verify the signature against the sorted string
const isVerified = crypto.verify(
"sha256",
Buffer.from(signString),
publicKey,
Buffer.from(sign, 'base64')
);
return isVerified;
}
async handle(payload: TelebirrDto): Promise<void> {
console.log(payload)
}
}

View File

@@ -0,0 +1,41 @@
import { All, Body, Controller, HttpCode, HttpStatus, Logger, } from '@nestjs/common';
import { TelebirrWebhookService } from './providers/telebirr.service';
import { ApiOperation } from '@nestjs/swagger';
import { TelebirrDto } from './dto/telebirr.dto';
@Controller("payments/webhooks")
export class WebhookController {
constructor(private readonly telebirr: TelebirrWebhookService) { }
private readonly logger = new Logger(WebhookController.name);
@All('telebirr')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Telebirr payment notification callback (Ethiopia)',
description: 'Webhook endpoint for Telebirr payment status updates. Used by Ethiopian passengers.'
})
async receiveTelebirr(@Body() payload: TelebirrDto) {
this.logger.log(
`Telebirr webhook Called`,
);
try {
const verified = this.telebirr.verifyTelebirrNotification(payload)
if (!verified) {
throw new Error("not valid")
}
const merchantOrderId = payload.merch_order_id;
if (merchantOrderId.startsWith("freight")) {
await this.telebirr.handle(payload);
} else if (merchantOrderId.startsWith("passagner")) {
//todo: handle else where
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Telebirr webhook handler threw: ${message}`);
}
return { code: '0', message: 'OK' };
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,297 +1,297 @@
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import {
Boxes,
FileText,
LayoutDashboard,
Network,
Paperclip,
Settings,
SlidersHorizontal,
Train,
Truck,
Container,
Package,
//TrainTrack,
} from "lucide-react";
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
import LoadingScreen from "./components/LoadingScreen";
import { useAuth } from "./auth/useAuth";
import LoginPage from "./pages/auth/LoginPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
import OverviewPage from "./pages/dashboard/OverviewPage";
import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
import RolesPage from "./pages/dashboard/user-management/RolesPage";
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
import UsersPage from "./pages/dashboard/user-management/UsersPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import TrainsPage from "./pages/trains/TrainsPage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
//import TrainsPage from "./pages/trains/TrainsPage";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
import WagonsPage from "./pages/wagons/WagonsPage";
import ContainersPage from "./pages/containers_management/ContainersPage";
import CargoesPage from "./pages/cargoes/CargoesPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Main menu",
mutedTitle: true,
items: [
{
label: "Overview",
href: "/dashboard/overview",
icon: <LayoutDashboard />,
},
{
label: "Booking requests",
href: "/dashboard/booking-requests",
icon: <FileText />,
},
{
label: "Train scheduling",
href: "/dashboard/operations/train-scheduling",
icon: <Train />,
},
...demoItems,
],
},
{
title: "Fleet Management",
items: [
{
label: "Trains",
href: "/dashboard/trains",
icon: <Train />,
},
{
label: "Wagons",
href: "/dashboard/wagons",
icon: <Truck />,
},
{
label: "Containers",
href: "/dashboard/containers",
icon: <Container />,
},
{
label: "Cargoes",
href: "/dashboard/cargoes",
icon: <Package />,
},
],
},
{
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"],
key: string,
) => {
if (!user) return false;
if (user.permissions?.some((p) => p.key === key)) return true;
return (user.employee ?? []).some((emp) =>
(emp.positions ?? []).some((pos) =>
(pos.permissions ?? []).some((p) => p.key === key),
),
);
};
const DashboardShell = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuth();
const demoItems: SidebarItem[] = [
...(hasPermission(user, "can:demo:user1")
? [
{
label: "User1",
href: "/dashboard/user1",
icon: <Settings />,
},
]
: []),
...(hasPermission(user, "can:demo:user2")
? [
{
label: "User2",
href: "/dashboard/user2",
icon: <Settings />,
},
]
: []),
];
const sidebarSections = buildSidebarSections(demoItems);
const displayName = user?.name?.en || user?.username || user?.email || "User";
return (
<FreightDashboardLayout
sidebarSections={sidebarSections}
activeHref={location.pathname}
onNavigate={navigate}
enableThemeToggle
userName={displayName}
userEmail={user?.email}
onLogout={logout}
>
<Outlet />
</FreightDashboardLayout>
);
};
const App = () => {
const { user, loading } = useAuth();
if (loading) {
return <LoadingScreen />;
}
if (!user) {
return (
<Routes>
<Route path="/auth" element={<LoginPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
</Routes>
);
}
return (
<Routes>
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
<Route path="operations/train-scheduling" element={<TrainsPage />} />
<Route path="trains" element={<TrainsPage />} />
<Route path="trains/:id" element={<TrainDetailPage />} />
<Route path="wagons" element={<WagonsPage />} />
<Route path="containers" element={<ContainersPage />} />
<Route path="cargoes" element={<CargoesPage />} />
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} />
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
<Route path="file-settings" element={<FileUploadSettingsPage />} />
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
<Route
path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
<Route
path="rules"
element={<Navigate to="/dashboard/rules/priority-rules" replace />}
/>
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
<Route
path="rule-engine"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
<Route
path="org-structure"
element={<Navigate to="/dashboard/user-management" replace />}
/>
<Route
path="org-structure/*"
element={<Navigate to="/dashboard/user-management" replace />}
/>
</Route>
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
</Routes>
);
};
export default App;
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import {
Boxes,
FileText,
LayoutDashboard,
Network,
Paperclip,
Settings,
SlidersHorizontal,
Train,
Truck,
Container,
Package,
//TrainTrack,
} from "lucide-react";
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
import LoadingScreen from "./components/LoadingScreen";
import { useAuth } from "./auth/useAuth";
import LoginPage from "./pages/auth/LoginPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
import OverviewPage from "./pages/dashboard/OverviewPage";
import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
import RolesPage from "./pages/dashboard/user-management/RolesPage";
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
import UsersPage from "./pages/dashboard/user-management/UsersPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
// import TrainsPage from "./pages/trains/TrainsPage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
//import TrainsPage from "./pages/trains/TrainsPage";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
import WagonsPage from "./pages/wagons/WagonsPage";
import ContainersPage from "./pages/containers_management/ContainersPage";
import CargoesPage from "./pages/cargoes/CargoesPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Main menu",
mutedTitle: true,
items: [
{
label: "Overview",
href: "/dashboard/overview",
icon: <LayoutDashboard />,
},
{
label: "Booking requests",
href: "/dashboard/booking-requests",
icon: <FileText />,
},
{
label: "Train scheduling",
href: "/dashboard/operations/train-scheduling",
icon: <Train />,
},
...demoItems,
],
},
{
title: "Fleet Management",
items: [
{
label: "Trains",
href: "/dashboard/trains",
icon: <Train />,
},
{
label: "Wagons",
href: "/dashboard/wagons",
icon: <Truck />,
},
{
label: "Containers",
href: "/dashboard/containers",
icon: <Container />,
},
{
label: "Cargoes",
href: "/dashboard/cargoes",
icon: <Package />,
},
],
},
{
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"],
key: string,
) => {
if (!user) return false;
if (user.permissions?.some((p) => p.key === key)) return true;
return (user.employee ?? []).some((emp) =>
(emp.positions ?? []).some((pos) =>
(pos.permissions ?? []).some((p) => p.key === key),
),
);
};
const DashboardShell = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuth();
const demoItems: SidebarItem[] = [
...(hasPermission(user, "can:demo:user1")
? [
{
label: "User1",
href: "/dashboard/user1",
icon: <Settings />,
},
]
: []),
...(hasPermission(user, "can:demo:user2")
? [
{
label: "User2",
href: "/dashboard/user2",
icon: <Settings />,
},
]
: []),
];
const sidebarSections = buildSidebarSections(demoItems);
const displayName = user?.name?.en || user?.username || user?.email || "User";
return (
<FreightDashboardLayout
sidebarSections={sidebarSections}
activeHref={location.pathname}
onNavigate={navigate}
enableThemeToggle
userName={displayName}
userEmail={user?.email}
onLogout={logout}
>
<Outlet />
</FreightDashboardLayout>
);
};
const App = () => {
const { user, loading } = useAuth();
if (loading) {
return <LoadingScreen />;
}
if (!user) {
return (
<Routes>
<Route path="/auth" element={<LoginPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
</Routes>
);
}
return (
<Routes>
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
{/* <Route path="operations/train-scheduling" element={<TrainsPage />} />
<Route path="trains" element={<TrainsPage />} /> */}
<Route path="trains/:id" element={<TrainDetailPage />} />
<Route path="wagons" element={<WagonsPage />} />
<Route path="containers" element={<ContainersPage />} />
<Route path="cargoes" element={<CargoesPage />} />
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} />
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
<Route path="file-settings" element={<FileUploadSettingsPage />} />
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
<Route
path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
<Route
path="rules"
element={<Navigate to="/dashboard/rules/priority-rules" replace />}
/>
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
<Route
path="rule-engine"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
<Route
path="org-structure"
element={<Navigate to="/dashboard/user-management" replace />}
/>
<Route
path="org-structure/*"
element={<Navigate to="/dashboard/user-management" replace />}
/>
</Route>
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
</Routes>
);
};
export default App;

View File

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

View File

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

View File

@@ -1,17 +1,35 @@
import { useMemo } from "react";
import { ShieldCheck } from "lucide-react";
import { useMemo, useState } from "react";
import { Check, ShieldCheck } from "lucide-react";
import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useAuth } from "@/auth/useAuth";
import { formatApprovalProgress } from "@/features/bookings/approval-progress";
import {
buildApproveActionForStep,
canActOnApprovalStep,
getNextPendingApprovalStep,
} from "@/features/bookings/booking-actions.config";
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
import { Badge } from "@edr/ui-common";
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
import { Badge, Button } from "@edr/ui-common";
import { cn } from "@/lib/utils";
type Mutations = ReturnType<typeof useBookingMutations>;
interface ApprovalStepsCardProps {
booking: BookingDetail;
mutations: Mutations;
}
/** Read-only approval chain visualization; actions live in BookingActionsToolbar. */
export function ApprovalStepsCard({ booking }: ApprovalStepsCardProps) {
/** Approval chain with inline approve on the current pending step. */
export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) {
const { user } = useAuth();
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(
null,
);
const steps = useMemo(
() =>
[...(booking.approvalSteps ?? [])].sort(
@@ -21,77 +39,136 @@ export function ApprovalStepsCard({ booking }: ApprovalStepsCardProps) {
);
const nextPending = getNextPendingApprovalStep(steps);
const summary = formatApprovalProgress(booking.status, steps);
const pendingAction = pendingStep
? buildApproveActionForStep(pendingStep)
: null;
const openApprove = (step: BookingApprovalStep) => {
setPendingStep(step);
setConfirmOpen(true);
};
const closeApprove = () => {
setConfirmOpen(false);
setPendingStep(null);
};
const runApprove = () => {
if (!pendingStep) return;
mutations.approveStep.mutate(
{ stepId: pendingStep.id, requiredRole: pendingStep.requiredRole },
{ onSuccess: () => closeApprove() },
);
};
return (
<div className="overflow-hidden rounded-xl border border-amber-200/80 bg-gradient-to-b from-amber-50/40 to-card shadow-sm dark:from-amber-950/20">
<div className="flex items-center gap-3 border-b border-amber-200/50 bg-amber-50/50 px-5 py-4 dark:bg-amber-950/30">
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/15 text-amber-800 dark:text-amber-300">
<ShieldCheck className="size-4" />
<>
<div className={cn(bookingSurface.sectionCard, bookingGlass.activeTab)}>
<div className={bookingSurface.sectionHeader}>
<div className={bookingSurface.sectionIcon}>
<ShieldCheck className="size-4" strokeWidth={1.75} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
Approval chain
</h2>
<p className="text-xs text-muted-foreground">
{summary.detail ||
(nextPending
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
: steps.length
? "All steps complete"
: "Accept submission to begin")}
</p>
</div>
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
Approval chain
</h2>
<p className="text-xs text-muted-foreground">
Next:{" "}
{nextPending
? `${nextPending.requiredRole} · step ${nextPending.stepOrder}`
: steps.length
? "All steps complete"
: "Accept submission to begin"}
</p>
<div className="px-5 py-5">
{steps.length === 0 ? (
<p className="rounded-lg border border-dashed border-border/60 bg-muted/10 px-4 py-6 text-center text-sm text-muted-foreground backdrop-blur-sm">
Use{" "}
<strong className="font-semibold text-foreground">
Accept for approval
</strong>{" "}
in staff actions to instantiate steps.
</p>
) : (
<ul className="space-y-2">
{steps.map((step) => (
<StepRow
key={step.id}
step={step}
steps={steps}
user={user}
isNext={nextPending?.id === step.id}
isPending={mutations.approveStep.isPending}
onApprove={openApprove}
/>
))}
</ul>
)}
</div>
</div>
<div className="px-5 py-5">
{steps.length === 0 ? (
<p className="rounded-lg border border-dashed border-border bg-muted/20 px-4 py-6 text-center text-sm text-muted-foreground">
Use <strong className="font-semibold text-foreground">Accept for approval</strong>{" "}
in staff actions to instantiate steps.
</p>
) : (
<ul className="space-y-2">
{steps.map((step) => (
<StepRow
key={step.id}
step={step}
isNext={nextPending?.id === step.id}
/>
))}
</ul>
)}
</div>
</div>
<BookingConfirmDialog
open={confirmOpen}
onOpenChange={(open) => {
if (!open) closeApprove();
else setConfirmOpen(true);
}}
action={pendingAction}
reference={booking.reference}
inputValue=""
onInputChange={() => {}}
onConfirm={runApprove}
isPending={mutations.approveStep.isPending}
/>
</>
);
}
function StepRow({
step,
steps,
user,
isNext,
isPending,
onApprove,
}: {
step: BookingApprovalStep;
steps: BookingApprovalStep[];
user: ReturnType<typeof useAuth>["user"];
isNext: boolean;
isPending: boolean;
onApprove: (step: BookingApprovalStep) => void;
}) {
const canApprove = canActOnApprovalStep(user, step, steps);
const statusStyles =
step.status === "APPROVED"
? "bg-emerald-500/15 text-emerald-800 dark:text-emerald-300"
? "border-emerald-500/25 bg-emerald-500/10 text-black"
: step.status === "REJECTED"
? "bg-red-500/15 text-red-800 dark:text-red-300"
? "bg-red-500/10 text-red-800 dark:text-red-300"
: isNext
? "bg-amber-500/15 text-amber-800 dark:text-amber-300"
: "bg-muted text-muted-foreground";
? "border-emerald-500/25 bg-emerald-500/10 text-black"
: "bg-muted/40 text-muted-foreground";
return (
<li
className={cn(
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors",
isNext
? "border-primary/30 bg-primary/[0.03] shadow-sm"
: "border-border/60 bg-card",
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors backdrop-blur-sm",
isNext ? bookingGlass.activeTab : "border-border/50 bg-card/60",
)}
>
<div className="flex min-w-0 items-center gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-xs font-bold text-muted-foreground">
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold",
isNext
? cn(bookingGlass.iconWellGreen, "text-black")
: "bg-muted/40 text-muted-foreground",
)}
>
{step.stepOrder}
</span>
<div className="min-w-0">
@@ -105,12 +182,26 @@ function StepRow({
)}
</div>
</div>
<Badge
variant="outline"
className={cn("shrink-0 text-[9px] uppercase", statusStyles)}
>
{step.status}
</Badge>
<div className="flex shrink-0 items-center gap-2">
{canApprove && (
<Button
type="button"
size="sm"
className="h-8 gap-1.5 shadow-sm"
disabled={isPending}
onClick={() => onApprove(step)}
>
<Check className="size-3.5" />
Approve
</Button>
)}
<Badge
variant="outline"
className={cn("shrink-0 border text-[9px] uppercase", statusStyles)}
>
{step.status}
</Badge>
</div>
</li>
);
}

View File

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

View File

@@ -1,5 +1,4 @@
import { useRef } from "react";
import { Download, Upload, Zap } from "lucide-react";
import { Download, Zap } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { BookingActionsMenu } from "./BookingActionsMenu";
@@ -7,6 +6,7 @@ import { bookingSurface } from "./booking-ui.styles";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
import { Button } from "@edr/ui-common";
import { cn } from "@/lib/utils";
type Mutations = ReturnType<typeof useBookingMutations>;
@@ -15,15 +15,13 @@ interface BookingActionsToolbarProps {
mutations: Mutations;
}
/** Detail-page actions: primary toolbar + payment uploads + downloads. */
/** Detail-page actions: primary toolbar + downloads. */
export function BookingActionsToolbar({
booking,
mutations,
}: BookingActionsToolbarProps) {
const fileRef = useRef<HTMLInputElement>(null);
const row = toBookingListRow(booking);
const { status, paymentCurrency } = booking;
const pending = mutations.isPending;
const { status } = booking;
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
const blob = await fn();
@@ -47,7 +45,7 @@ export function BookingActionsToolbar({
return (
<PanelShell title="Awaiting customer" description="No staff actions until resubmit.">
{booking.latestChangeRequestNote && (
<p className="rounded-lg border bg-muted/30 p-3 text-sm leading-relaxed">
<p className="rounded-lg border border-border/50 bg-muted/15 p-3 text-sm leading-relaxed backdrop-blur-sm">
{booking.latestChangeRequestNote}
</p>
)}
@@ -74,50 +72,11 @@ export function BookingActionsToolbar({
<BookingActionsMenu row={row} variant="toolbar" />
</PanelShell>
{status === "FULLY_EXECUTED" && paymentCurrency === "USD" && (
<PanelShell title="Payment (USD)" description="Upload proof of payment.">
<input
ref={fileRef}
type="file"
className="hidden"
accept=".pdf,.png,.jpg,.jpeg"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) mutations.submitPaymentProof.mutate(file);
}}
/>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
disabled={pending}
className="gap-2"
onClick={() => fileRef.current?.click()}
>
<Upload className="size-4" />
Upload payment proof
</Button>
<Button
variant="outline"
className="gap-2"
onClick={() =>
downloadBlob(
() => mutations.downloadPaymentLetter(),
`payment-letter-${booking.reference}.txt`,
)
}
>
<Download className="size-4" />
Request letter
</Button>
</div>
</PanelShell>
)}
{status === "CONTRACT_READY" && (
<PanelShell title="Documents" description="Download generated contract.">
<Button
variant="outline"
className="gap-2"
className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
onClick={() =>
downloadBlob(
() => mutations.downloadContract(),
@@ -146,16 +105,10 @@ function PanelShell({
muted?: boolean;
}) {
return (
<div
className={
muted
? bookingSurface.sectionCard
: `${bookingSurface.sectionCard} ring-1 ring-primary/10`
}
>
<div className={cn(bookingSurface.sectionCard, !muted && "ring-0")}>
<div className={bookingSurface.sectionHeader}>
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Zap className="size-4" />
<div className={bookingSurface.sectionIcon}>
<Zap className="size-4" strokeWidth={1.75} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">{title}</h2>

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { Banknote, Receipt } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { Separator } from "@edr/ui-common";
import { bookingSurface } from "./booking-ui.styles";
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
const amount = Number(booking.totalAmount);
@@ -11,8 +11,8 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
return (
<div className={bookingSurface.sectionCard}>
<div className={bookingSurface.sectionHeader}>
<div className="flex size-9 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-700 dark:text-emerald-400">
<Banknote className="size-4" />
<div className={bookingSurface.sectionIcon}>
<Banknote className="size-4" strokeWidth={1.75} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
@@ -22,11 +22,11 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
</div>
</div>
<div className="space-y-4 px-5 py-5">
<div className="rounded-xl border border-primary/15 bg-gradient-to-br from-primary/[0.06] to-transparent p-4">
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<div className={bookingSurface.valueCard}>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Total amount
</p>
<p className="mt-1 font-mono text-2xl font-bold tracking-tight text-foreground">
<p className="mt-1 font-mono text-2xl font-semibold tabular-nums tracking-tight text-foreground">
{booking.paymentCurrency}{" "}
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</p>
@@ -35,8 +35,8 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
{modifiers.length > 0 && (
<>
<Separator />
<p className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<Separator className="opacity-50" />
<p className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<Receipt className="size-3" />
Surcharges applied
</p>
@@ -44,7 +44,7 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
{modifiers.map((m) => (
<li
key={m.id}
className="flex justify-between rounded-lg border border-border/60 bg-muted/20 px-3 py-2 text-sm"
className="flex justify-between rounded-lg border border-border/50 bg-muted/15 px-3 py-2 text-sm backdrop-blur-sm"
>
<span className="text-muted-foreground">Modifier</span>
<span className="font-mono font-semibold tabular-nums">
@@ -70,7 +70,7 @@ function Row({
mono?: boolean;
}) {
return (
<div className="flex items-center justify-between gap-2 text-sm">
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/40 bg-muted/10 px-3 py-2.5 text-sm backdrop-blur-sm">
<span className="text-muted-foreground">{label}</span>
<span
className={

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useWagons, useAssignWagonToTrain } from '@/hooks/useWagons';
import { useToast } from '@/hooks/use-toast';
import { Plus } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
export function AssignWagonDialog({ trainId }: { trainId: string }) {
const [open, setOpen] = useState(false);
@@ -16,7 +17,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
const assign = useAssignWagonToTrain();
const { toast } = useToast();
const available = wagons?.filter(w => w.status === 'AVAILABLE' || !w.trainId);
const available = wagons?.filter((w:any) => w.status === 'AVAILABLE' || !w.trainId);
const handleAssign = async () => {
if (!wagonId) return;
@@ -36,7 +37,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
<Select value={wagonId} onValueChange={setWagonId}>
<SelectTrigger><SelectValue placeholder="Select wagon" /></SelectTrigger>
<SelectContent>
{available?.map(w => <SelectItem key={w.id} value={w.id}>{w.wagonNumber}</SelectItem>)}
{available?.map((w:any) => <SelectItem key={w.id} value={w.id}>{w.wagonNumber}</SelectItem>)}
</SelectContent>
</Select>
</div>

View File

@@ -2,7 +2,7 @@ import { useWagonsByTrain, useUnassignWagon, useReorderWagons } from '@/hooks/us
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Trash2, GripVertical } from 'lucide-react';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
// import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
export function WagonsTable({ trainId }: { trainId: string }) {
const { data: wagons, refetch } = useWagonsByTrain(trainId);
@@ -14,50 +14,51 @@ export function WagonsTable({ trainId }: { trainId: string }) {
const items = Array.from(wagons || []);
const [removed] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, removed);
reorder.mutate({ trainId, wagonIds: items.map(w => w.id) });
reorder.mutate({ trainId, wagonIds: items.map((w:any) => w.id) });
};
if (!wagons?.length) return <div className="text-muted-foreground">No wagons assigned.</div>;
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="wagons">
{(provided) => (
<Table {...provided.droppableProps} ref={provided.innerRef}>
<TableHeader>
<TableRow>
<TableHead className="w-10"></TableHead>
<TableHead>Number</TableHead>
<TableHead>Type</TableHead>
<TableHead>Sequence</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{wagons.map((wagon, idx) => (
<Draggable key={wagon.id} draggableId={wagon.id} index={idx}>
{(provided) => (
<TableRow ref={provided.innerRef} {...provided.draggableProps}>
<TableCell {...provided.dragHandleProps}><GripVertical className="h-4 w-4 cursor-grab" /></TableCell>
<TableCell>{wagon.wagonNumber}</TableCell>
<TableCell>{wagon.wagonTypeId}</TableCell>
<TableCell>{wagon.sequenceNumber}</TableCell>
<TableCell>{wagon.status}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" onClick={() => unassign.mutateAsync(wagon.id).then(() => refetch())}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
)}
</Draggable>
))}
{provided.placeholder}
</TableBody>
</Table>
)}
</Droppable>
</DragDropContext>
<div></div>
// <DragDropContext onDragEnd={onDragEnd}>
// <Droppable droppableId="wagons">
// {(provided) => (
// <Table {...provided.droppableProps} ref={provided.innerRef}>
// <TableHeader>
// <TableRow>
// <TableHead className="w-10"></TableHead>
// <TableHead>Number</TableHead>
// <TableHead>Type</TableHead>
// <TableHead>Sequence</TableHead>
// <TableHead>Status</TableHead>
// <TableHead>Actions</TableHead>
// </TableRow>
// </TableHeader>
// <TableBody>
// {wagons.map((wagon, idx) => (
// <Draggable key={wagon.id} draggableId={wagon.id} index={idx}>
// {(provided) => (
// <TableRow ref={provided.innerRef} {...provided.draggableProps}>
// <TableCell {...provided.dragHandleProps}><GripVertical className="h-4 w-4 cursor-grab" /></TableCell>
// <TableCell>{wagon.wagonNumber}</TableCell>
// <TableCell>{wagon.wagonTypeId}</TableCell>
// <TableCell>{wagon.sequenceNumber}</TableCell>
// <TableCell>{wagon.status}</TableCell>
// <TableCell>
// <Button variant="ghost" size="icon" onClick={() => unassign.mutateAsync(wagon.id).then(() => refetch())}>
// <Trash2 className="h-4 w-4" />
// </Button>
// </TableCell>
// </TableRow>
// )}
// </Draggable>
// ))}
// {provided.placeholder}
// </TableBody>
// </Table>
// )}
// </Droppable>
// </DragDropContext>
);
}

View File

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

Some files were not shown because too many files have changed in this diff Show More