mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
booking flow,summtion, approval, contract, mock payemnt and integration to back office, and also add permissions
This commit is contained in:
@@ -23,6 +23,10 @@ SUPER_ADMIN_EMAIL=superadmin@tria.com
|
|||||||
SUPER_ADMIN_PHONE=
|
SUPER_ADMIN_PHONE=
|
||||||
DEFAULT_PASSWORD=password@tria
|
DEFAULT_PASSWORD=password@tria
|
||||||
|
|
||||||
|
# Freight org + staff (bookings / rule-engine IAM)
|
||||||
|
SEED_EDR_ORG=true
|
||||||
|
SEED_FREIGHT_STAFF=true
|
||||||
|
|
||||||
# MinIO (used by @tria-plc/iamapi-common for file storage)
|
# MinIO (used by @tria-plc/iamapi-common for file storage)
|
||||||
MINIO_ENDPOINT=localhost
|
MINIO_ENDPOINT=localhost
|
||||||
MINIO_PORT=9000
|
MINIO_PORT=9000
|
||||||
|
|||||||
@@ -24,12 +24,14 @@ import { OtpModule } from './modules/otp/otp.module';
|
|||||||
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||||
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
||||||
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
|
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
|
||||||
|
import { FreightAuthModule } from "./modules/auth/freight-auth.module";
|
||||||
import {
|
import {
|
||||||
EDR_FREIGHT_APPLICATION,
|
EDR_FREIGHT_APPLICATION,
|
||||||
EDR_FREIGHT_PERMISSIONS,
|
EDR_FREIGHT_PERMISSIONS,
|
||||||
} from "./seed/edr-freight.seed";
|
} from "./seed/edr-freight.seed";
|
||||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||||
|
import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -70,19 +72,22 @@ import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
|||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
BackofficeModule,
|
BackofficeModule,
|
||||||
DemoPermissionsModule,
|
DemoPermissionsModule,
|
||||||
|
FreightAuthModule,
|
||||||
],
|
],
|
||||||
providers: [EdrOrgSeeder, DemoUsersSeeder],
|
providers: [EdrOrgSeeder, DemoUsersSeeder, FreightStaffUsersSeeder],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly seeder: DataSeeder,
|
private readonly seeder: DataSeeder,
|
||||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||||
|
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
async onApplicationBootstrap() {
|
||||||
await this.seeder.run();
|
await this.seeder.run();
|
||||||
await this.edrOrgSeeder.run();
|
await this.edrOrgSeeder.run();
|
||||||
await this.demoUsersSeeder.run();
|
await this.demoUsersSeeder.run();
|
||||||
|
await this.freightStaffUsersSeeder.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
apps/edr-freight-api/src/common/booking-guards.ts
Normal file
17
apps/edr-freight-api/src/common/booking-guards.ts
Normal 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);
|
||||||
38
apps/edr-freight-api/src/common/freight-permission.guard.ts
Normal file
38
apps/edr-freight-api/src/common/freight-permission.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
99
apps/edr-freight-api/src/common/freight-permission.util.ts
Normal file
99
apps/edr-freight-api/src/common/freight-permission.util.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
|
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
||||||
|
|
||||||
|
const SUPER_ADMIN_ROLE = 'super_admin';
|
||||||
|
|
||||||
|
type PermissionLike = { key?: string };
|
||||||
|
type MeLikeUser = {
|
||||||
|
roles?: { key?: string }[];
|
||||||
|
permissions?: PermissionLike[];
|
||||||
|
employee?:
|
||||||
|
| {
|
||||||
|
position?: { permissions?: PermissionLike[] };
|
||||||
|
delegatedPositions?: { permissions?: PermissionLike[] }[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
positions?: { permissions?: PermissionLike[] }[];
|
||||||
|
}[]
|
||||||
|
| null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean {
|
||||||
|
if (!user?.roles?.length) return false;
|
||||||
|
return user.roles.some((r) => r.key === SUPER_ADMIN_ROLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flat permission keys from JWT / session user (roles + position permissions). */
|
||||||
|
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
|
||||||
|
if (!user) return [];
|
||||||
|
|
||||||
|
const keys = new Set<string>();
|
||||||
|
|
||||||
|
for (const p of user.permissions ?? []) {
|
||||||
|
if (p.key) keys.add(p.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const employee = user.employee;
|
||||||
|
if (!employee) {
|
||||||
|
return [...keys];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(employee)) {
|
||||||
|
for (const emp of employee) {
|
||||||
|
for (const pos of emp.positions ?? []) {
|
||||||
|
for (const p of pos.permissions ?? []) {
|
||||||
|
if (p.key) keys.add(p.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...keys];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of employee.position?.permissions ?? []) {
|
||||||
|
if (p.key) keys.add(p.key);
|
||||||
|
}
|
||||||
|
for (const delegated of employee.delegatedPositions ?? []) {
|
||||||
|
for (const p of delegated.permissions ?? []) {
|
||||||
|
if (p.key) keys.add(p.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...keys];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasFreightPermission(
|
||||||
|
user: MeLikeUser | null | undefined,
|
||||||
|
permissionKey: string,
|
||||||
|
): boolean {
|
||||||
|
if (!user) return false;
|
||||||
|
if (isSuperAdmin(user)) return true;
|
||||||
|
return collectPermissionKeys(user).includes(permissionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertFreightPermission(
|
||||||
|
user: TCurrentUser | MeLikeUser | null | undefined,
|
||||||
|
permissionKey: string,
|
||||||
|
): void {
|
||||||
|
if (hasFreightPermission(user, permissionKey)) return;
|
||||||
|
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||||
|
LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff,
|
||||||
|
DIRECTOR: FREIGHT_PERMS.bookings.approveDirector,
|
||||||
|
CEO: FREIGHT_PERMS.bookings.approveCeo,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function assertCanApproveBookingStep(
|
||||||
|
user: TCurrentUser | MeLikeUser | null | undefined,
|
||||||
|
requiredRole: string,
|
||||||
|
): void {
|
||||||
|
if (isSuperAdmin(user)) return;
|
||||||
|
const perm = APPROVE_ROLE_PERMISSION[requiredRole];
|
||||||
|
if (!perm) {
|
||||||
|
throw new ForbiddenException(`Unknown approval role: ${requiredRole}`);
|
||||||
|
}
|
||||||
|
assertFreightPermission(user, perm);
|
||||||
|
}
|
||||||
18
apps/edr-freight-api/src/common/rule-engine-guards.ts
Normal file
18
apps/edr-freight-api/src/common/rule-engine-guards.ts
Normal 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)])),
|
||||||
|
);
|
||||||
@@ -1,57 +1,116 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
|
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
const MIN_VALID_PDF_BYTES = 2_000;
|
||||||
|
|
||||||
|
const PDF_PRINT_STYLES = `
|
||||||
|
<style id="contract-pdf-print-fix">
|
||||||
|
@media print {
|
||||||
|
html, body {
|
||||||
|
background: #fff !important;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
.cover {
|
||||||
|
min-height: auto !important;
|
||||||
|
page-break-after: always;
|
||||||
|
}
|
||||||
|
.cover-title {
|
||||||
|
margin: 24mm 0 20mm !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>`;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ContractPdfService {
|
export class ContractPdfService {
|
||||||
private readonly logger = new Logger(ContractPdfService.name);
|
private readonly logger = new Logger(ContractPdfService.name);
|
||||||
|
|
||||||
async htmlToPdfBuffer(html: string): Promise<Buffer> {
|
async htmlToPdfBuffer(html: string): Promise<Buffer> {
|
||||||
|
const preparedHtml = this.injectPdfPrintStyles(html);
|
||||||
|
const executablePath = this.resolveExecutablePath();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const puppeteer = await import('puppeteer');
|
const puppeteer = await import('puppeteer');
|
||||||
const browser = await puppeteer.default.launch({
|
const launchOptions: import('puppeteer').LaunchOptions = {
|
||||||
headless: true,
|
headless: true,
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
args: [
|
||||||
});
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
],
|
||||||
|
...(executablePath ? { executablePath } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const browser = await puppeteer.default.launch(launchOptions);
|
||||||
try {
|
try {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.setContent(html, { waitUntil: 'load' });
|
await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 });
|
||||||
|
await page.setContent(preparedHtml, {
|
||||||
|
waitUntil: 'load',
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await page.emulateMediaType('print');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||||
|
|
||||||
const pdf = await page.pdf({
|
const pdf = await page.pdf({
|
||||||
format: 'A4',
|
format: 'A4',
|
||||||
printBackground: true,
|
printBackground: true,
|
||||||
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
|
displayHeaderFooter: true,
|
||||||
|
headerTemplate: '<span></span>',
|
||||||
|
footerTemplate:
|
||||||
|
'<div style="width:100%;font-size:8px;color:#64748b;text-align:center;font-family:Arial,sans-serif;">Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
|
||||||
|
margin: { top: '18mm', bottom: '22mm', left: '14mm', right: '14mm' },
|
||||||
});
|
});
|
||||||
return Buffer.from(pdf);
|
|
||||||
|
const buffer = Buffer.from(pdf);
|
||||||
|
if (!this.isValidPdf(buffer)) {
|
||||||
|
throw new Error(
|
||||||
|
`Puppeteer produced invalid PDF (${buffer.length} bytes)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.logger.log(
|
||||||
|
`Contract PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`,
|
||||||
|
);
|
||||||
|
return buffer;
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(
|
this.logger.error(
|
||||||
`Puppeteer PDF failed, falling back to minimal PDF stub: ${err}`,
|
`Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.',
|
||||||
);
|
);
|
||||||
return this.fallbackPdfBuffer(html);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal valid PDF when Chromium is unavailable. */
|
private injectPdfPrintStyles(html: string): string {
|
||||||
private fallbackPdfBuffer(html: string): Buffer {
|
if (html.includes('contract-pdf-print-fix')) return html;
|
||||||
const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').slice(0, 2000);
|
if (html.includes('</head>')) {
|
||||||
const escaped = text.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
return html.replace('</head>', `${PDF_PRINT_STYLES}</head>`);
|
||||||
const stream = `BT /F1 10 Tf 50 750 Td (${escaped}) Tj ET`;
|
}
|
||||||
const len = stream.length;
|
return `${PDF_PRINT_STYLES}${html}`;
|
||||||
const pdf = `%PDF-1.4
|
}
|
||||||
1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj
|
|
||||||
2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj
|
private resolveExecutablePath(): string | undefined {
|
||||||
3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>endobj
|
const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim();
|
||||||
4 0 obj<< /Length ${len} >>stream
|
if (fromEnv && existsSync(fromEnv)) return fromEnv;
|
||||||
${stream}
|
|
||||||
endstream endobj
|
const candidates = [
|
||||||
5 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>endobj
|
'/usr/bin/chromium',
|
||||||
xref
|
'/usr/bin/chromium-browser',
|
||||||
0 6
|
'/usr/bin/google-chrome-stable',
|
||||||
0000000000 65535 f
|
'/usr/bin/google-chrome',
|
||||||
trailer<< /Size 6 /Root 1 0 R >>
|
];
|
||||||
startxref
|
return candidates.find((p) => existsSync(p));
|
||||||
0
|
}
|
||||||
%%EOF`;
|
|
||||||
return Buffer.from(pdf, 'utf-8');
|
private isValidPdf(buffer: Buffer): boolean {
|
||||||
|
return (
|
||||||
|
buffer.length >= MIN_VALID_PDF_BYTES &&
|
||||||
|
buffer.subarray(0, 5).toString('ascii') === '%PDF-'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export class ContractPricingScheduleBuilder {
|
|||||||
})),
|
})),
|
||||||
totalAmount,
|
totalAmount,
|
||||||
currency,
|
currency,
|
||||||
equipmentReturn: booking.equipmentReturn ?? undefined,
|
equipmentReturn: booking.equipmentReturn ?? '—',
|
||||||
originLabel: booking.originYard?.label ?? booking.originYard?.code ?? '—',
|
originLabel: booking.originYard?.label ?? booking.originYard?.code ?? '—',
|
||||||
destinationLabel:
|
destinationLabel:
|
||||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—',
|
booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—',
|
||||||
|
|||||||
@@ -23,6 +23,31 @@ describe('ContractRendererService', () => {
|
|||||||
phone: '+251900000000',
|
phone: '+251900000000',
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
tinNumber: '1234567890',
|
tinNumber: '1234567890',
|
||||||
|
vatNumber: 'VAT-001',
|
||||||
|
fanNumber: 'FAN-001',
|
||||||
|
businessLicense: 'BL-001',
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
|
||||||
|
address: 'Addis Ababa, Ethiopia',
|
||||||
|
phone: '+251 11 872 0000',
|
||||||
|
email: 'info@edr.gov.et',
|
||||||
|
tinNumber: '—',
|
||||||
|
},
|
||||||
|
schedule: {
|
||||||
|
originLabel: 'SGTD',
|
||||||
|
destinationLabel: 'Modjo',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
serviceType: 'Rail transport',
|
||||||
|
scheduledDate: '1 January 2026',
|
||||||
|
contractType: 'NEW',
|
||||||
|
cargoDescription: 'Container cargo',
|
||||||
|
totalWeightVgm: '24 tons',
|
||||||
|
equipmentReturn: 'RETURN',
|
||||||
|
hazardousLabel: 'No',
|
||||||
|
firstMilePickupAddress: '—',
|
||||||
|
lastMileDeliveryAddress: '—',
|
||||||
},
|
},
|
||||||
pricing: {
|
pricing: {
|
||||||
lineItems: [{ label: 'RAIL', description: 'Rail transport', amount: 1000, currency: 'ETB' }],
|
lineItems: [{ label: 'RAIL', description: 'Rail transport', amount: 1000, currency: 'ETB' }],
|
||||||
|
|||||||
@@ -32,6 +32,31 @@ export interface ContractViewModel {
|
|||||||
phone: string;
|
phone: string;
|
||||||
email: string;
|
email: string;
|
||||||
tinNumber: string;
|
tinNumber: string;
|
||||||
|
vatNumber: string;
|
||||||
|
fanNumber: string;
|
||||||
|
businessLicense: string;
|
||||||
|
};
|
||||||
|
provider: {
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
tinNumber: string;
|
||||||
|
};
|
||||||
|
schedule: {
|
||||||
|
originLabel: string;
|
||||||
|
destinationLabel: string;
|
||||||
|
tradeDirection: string;
|
||||||
|
freightType: string;
|
||||||
|
serviceType: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
contractType: string;
|
||||||
|
cargoDescription: string;
|
||||||
|
totalWeightVgm: string;
|
||||||
|
equipmentReturn: string;
|
||||||
|
hazardousLabel: string;
|
||||||
|
firstMilePickupAddress: string;
|
||||||
|
lastMileDeliveryAddress: string;
|
||||||
};
|
};
|
||||||
pricing: PricingSchedule;
|
pricing: PricingSchedule;
|
||||||
signatures: ContractSignatureView[];
|
signatures: ContractSignatureView[];
|
||||||
@@ -81,19 +106,24 @@ export class ContractViewModelBuilder {
|
|||||||
}),
|
}),
|
||||||
contractYear: new Date().getFullYear(),
|
contractYear: new Date().getFullYear(),
|
||||||
client: {
|
client: {
|
||||||
// companyName: booking.customer?.companyName ?? 'Client',
|
|
||||||
// companyAddress: booking.customer?.companyAddress ?? '—',
|
|
||||||
// companyLocation: booking.customer?.companyLocation ?? '—',
|
|
||||||
// phone: booking.customer?.companyPhone ?? booking.customer?.phone ?? '—',
|
|
||||||
// email: booking.customer?.companyEmail ?? booking.customer?.email ?? '—',
|
|
||||||
// tinNumber: booking.customer?.tinNumber ?? '—',
|
|
||||||
companyName: booking.company?.name ?? 'Client',
|
companyName: booking.company?.name ?? 'Client',
|
||||||
companyAddress: booking.company?.address ?? '—',
|
companyAddress: this.valueOrDash(booking.company?.address),
|
||||||
companyLocation: booking.company?.country ?? '—',
|
companyLocation: this.valueOrDash(booking.company?.country),
|
||||||
phone: booking.company?.phone ?? '—',
|
phone: this.valueOrDash(booking.company?.phone),
|
||||||
email: booking.company?.email ?? '—',
|
email: this.valueOrDash(booking.company?.email),
|
||||||
tinNumber: booking.company?.tin ?? '—',
|
tinNumber: this.valueOrDash(booking.company?.tin),
|
||||||
|
vatNumber: this.valueOrDash(booking.company?.vatNumber),
|
||||||
|
fanNumber: this.valueOrDash(booking.company?.fanNumber),
|
||||||
|
businessLicense: this.valueOrDash(booking.company?.businessLicense),
|
||||||
},
|
},
|
||||||
|
provider: {
|
||||||
|
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
|
||||||
|
address: 'Addis Ababa, Ethiopia',
|
||||||
|
phone: '+251 11 872 0000',
|
||||||
|
email: 'info@edr.gov.et',
|
||||||
|
tinNumber: '—',
|
||||||
|
},
|
||||||
|
schedule: this.buildSchedule(booking),
|
||||||
pricing,
|
pricing,
|
||||||
signatures,
|
signatures,
|
||||||
canSignCustomer:
|
canSignCustomer:
|
||||||
@@ -117,8 +147,61 @@ export class ContractViewModelBuilder {
|
|||||||
return {
|
return {
|
||||||
role: row.signerRole,
|
role: row.signerRole,
|
||||||
signerDisplayName: row.signerDisplayName,
|
signerDisplayName: row.signerDisplayName,
|
||||||
signedAt: row.signedAt.toISOString(),
|
signedAt: this.formatDate(row.signedAt),
|
||||||
signatureImageUrl: row.signatureFile?.url ?? null,
|
signatureImageUrl: row.signatureFile?.url ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildSchedule(booking: Booking): ContractViewModel['schedule'] {
|
||||||
|
const cargoName =
|
||||||
|
booking.freightType === 'BULK'
|
||||||
|
? booking.cargoFreeText ||
|
||||||
|
booking.cargoType?.cargoTypeName ||
|
||||||
|
'Bulk commodity'
|
||||||
|
: booking.cargoType?.cargoTypeName || 'Container cargo';
|
||||||
|
const totalWeight = Number(booking.cargoTotalWeightVgm || 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
originLabel: this.yardLabel(booking.originYard),
|
||||||
|
destinationLabel: this.yardLabel(booking.destinationYard),
|
||||||
|
tradeDirection: this.valueOrDash(booking.tradeDirection),
|
||||||
|
freightType: this.valueOrDash(booking.freightType),
|
||||||
|
serviceType: this.valueOrDash(
|
||||||
|
booking.serviceType?.serviceName ?? booking.serviceType?.code,
|
||||||
|
),
|
||||||
|
scheduledDate: this.formatDate(booking.scheduledDate),
|
||||||
|
contractType: this.valueOrDash(booking.contractType),
|
||||||
|
cargoDescription: this.valueOrDash(cargoName),
|
||||||
|
totalWeightVgm:
|
||||||
|
totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—',
|
||||||
|
equipmentReturn: this.valueOrDash(booking.equipmentReturn),
|
||||||
|
hazardousLabel: booking.isHazardous ? 'Yes' : 'No',
|
||||||
|
firstMilePickupAddress: this.valueOrDash(
|
||||||
|
booking.firstMilePickupAddress,
|
||||||
|
),
|
||||||
|
lastMileDeliveryAddress: this.valueOrDash(
|
||||||
|
booking.lastMileDeliveryAddress,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private yardLabel(yard?: { label?: string; code?: string } | null): string {
|
||||||
|
return this.valueOrDash(yard?.label ?? yard?.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDate(value?: Date | string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '—';
|
||||||
|
return date.toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private valueOrDash(value?: string | number | null): string {
|
||||||
|
if (value === undefined || value === null || value === '') return '—';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<div class="article">
|
<div class="article">
|
||||||
<h2>Article 1: Objective and Scope of Services</h2>
|
<h2>Article 1: Objective and Scope of Services</h2>
|
||||||
<p><strong>Objective:</strong> {{template.article1.objective}}</p>
|
<p>
|
||||||
|
<strong>1.1 Objective.</strong>
|
||||||
|
{{template.article1.objective}}
|
||||||
|
</p>
|
||||||
{{#if template.article1.scope.length}}
|
{{#if template.article1.scope.length}}
|
||||||
<p><strong>Scope:</strong></p>
|
<p><strong>1.2 Scope of Services.</strong></p>
|
||||||
<ol>
|
<ol>
|
||||||
{{#each template.article1.scope}}
|
{{#each template.article1.scope}}
|
||||||
<li>{{this}}</li>
|
<li>{{this}}</li>
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
<h2>Article 5: Contract Price and Terms of Payment</h2>
|
<h2>Article 5: Contract Price and Terms of Payment</h2>
|
||||||
<div class="article">
|
<div class="article">
|
||||||
<h3>Contract Price</h3>
|
<h3>Contract Price</h3>
|
||||||
<p><strong>Corridor:</strong> {{pricing.originLabel}} → {{pricing.destinationLabel}}</p>
|
<p>
|
||||||
|
The contract price is calculated based on the agreed railway corridor, cargo details, applicable rate
|
||||||
|
schedule, and any approved operational surcharges.
|
||||||
|
</p>
|
||||||
|
<table class="details-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Corridor</th>
|
||||||
|
<td>{{pricing.originLabel}} → {{pricing.destinationLabel}}</td>
|
||||||
|
<th>Currency</th>
|
||||||
|
<td>{{pricing.currency}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Payment currency</th>
|
||||||
|
<td>{{paymentArticle}}</td>
|
||||||
|
<th>Equipment return</th>
|
||||||
|
<td>{{pricing.equipmentReturn}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
{{#if pricing.equipmentReturn}}
|
{{#if pricing.equipmentReturn}}
|
||||||
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
|
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{#if pricing.containerLines.length}}
|
|
||||||
<table class="schedule">
|
<h3>Charges</h3>
|
||||||
<thead>
|
|
||||||
<tr><th>Container type</th><th>Quantity</th><th>VGM / unit (t)</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{{#each pricing.containerLines}}
|
|
||||||
<tr><td>{{label}}</td><td>{{quantity}}</td><td>{{vgmPerUnitTons}}</td></tr>
|
|
||||||
{{/each}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{{/if}}
|
|
||||||
<table class="schedule">
|
<table class="schedule">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Item</th><th>Description</th><th>Amount</th></tr>
|
<tr><th>Item</th><th>Description</th><th>Amount</th></tr>
|
||||||
@@ -29,6 +38,10 @@
|
|||||||
<td>{{currency}} {{amount}}</td>
|
<td>{{currency}} {{amount}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
{{#if pricing.surcharges.length}}
|
||||||
|
<tr>
|
||||||
|
<th colspan="3">Surcharges and Adjustments</th>
|
||||||
|
</tr>
|
||||||
{{#each pricing.surcharges}}
|
{{#each pricing.surcharges}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{label}}</td>
|
<td>{{label}}</td>
|
||||||
@@ -36,12 +49,18 @@
|
|||||||
<td>{{currency}} {{amount}}</td>
|
<td>{{currency}} {{amount}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
<tr>
|
{{/if}}
|
||||||
|
<tr class="total-row">
|
||||||
<td colspan="2"><strong>Total contract value</strong></td>
|
<td colspan="2"><strong>Total contract value</strong></td>
|
||||||
<td><strong>{{pricing.currency}} {{pricing.totalAmount}}</strong></td>
|
<td><strong>{{pricing.currency}} {{pricing.totalAmount}}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<h3>Terms of payment</h3>
|
<h3>Terms of payment</h3>
|
||||||
<p>All payments shall be made in accordance with EDR policy in <strong>{{paymentArticle}}</strong>, unless otherwise agreed in writing.</p>
|
<p>
|
||||||
|
Unless otherwise agreed in writing, the Client shall settle the contract value in
|
||||||
|
<strong>{{paymentArticle}}</strong> before the service is performed and in accordance with EDR payment
|
||||||
|
instructions. Bank charges, penalties, demurrage, storage, and third-party charges remain the
|
||||||
|
responsibility of the Client where applicable.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<div class="article">
|
<div class="article">
|
||||||
<h2>Article 2: Obligations of the Client (summary)</h2>
|
<h2>Article 2: Obligations of the Client</h2>
|
||||||
|
<p>The Client shall perform the following obligations in good faith and within the operational timelines communicated by EDR:</p>
|
||||||
<ol>
|
<ol>
|
||||||
{{#each template.clientObligations}}
|
{{#each template.clientObligations}}
|
||||||
<li>{{this}}</li>
|
<li>{{this}}</li>
|
||||||
@@ -8,7 +9,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="article">
|
<div class="article">
|
||||||
<h2>Article 3: Obligations of the Service Provider (summary)</h2>
|
<h2>Article 3: Obligations of the Service Provider</h2>
|
||||||
|
<p>EDR shall provide the agreed railway freight services in accordance with this Agreement and applicable operational rules:</p>
|
||||||
<ol>
|
<ol>
|
||||||
{{#each template.providerObligations}}
|
{{#each template.providerObligations}}
|
||||||
<li>{{this}}</li>
|
<li>{{this}}</li>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<div class="article">
|
<div class="article">
|
||||||
<h2>Article 6: Contract Documents</h2>
|
<h2>Article 6: Contract Documents</h2>
|
||||||
|
<p>The following documents form part of this Agreement and shall be read together with the signed contract:</p>
|
||||||
<ol>
|
<ol>
|
||||||
{{#each template.contractDocuments}}
|
{{#each template.contractDocuments}}
|
||||||
<li>{{this}}</li>
|
<li>{{this}}</li>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
<div class="article">
|
<div class="article">
|
||||||
<h2>Article 4: Force Majeure</h2>
|
<h2>Article 4: Force Majeure</h2>
|
||||||
<p>Neither party is liable for delays due to force majeure interpreted under the Ethiopian Civil Code.</p>
|
<p>
|
||||||
|
Neither party shall be liable for delay or non-performance caused by events beyond its reasonable control,
|
||||||
|
including natural disaster, war, civil unrest, government restriction, railway interruption, port closure,
|
||||||
|
or other force majeure events interpreted under the Ethiopian Civil Code.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The affected party shall notify the other party promptly and shall use reasonable efforts to reduce the
|
||||||
|
effect of the force majeure event on the performance of this Agreement.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +1,44 @@
|
|||||||
<div class="signatures">
|
<div class="signatures">
|
||||||
<div class="sig-block">
|
<div class="sig-block">
|
||||||
<p><strong>Service Provider (EDR)</strong></p>
|
<p class="sig-title">For the Service Provider</p>
|
||||||
|
<p><strong>{{provider.name}}</strong></p>
|
||||||
{{#if hasStaffSignature}}
|
{{#if hasStaffSignature}}
|
||||||
{{#each signatures}}
|
{{#each signatures}}
|
||||||
{{#if (eq role "STAFF")}}
|
{{#if (eq role "STAFF")}}
|
||||||
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Staff signature" />{{/if}}
|
<div class="sig-image-box">
|
||||||
<p>{{signerDisplayName}}</p>
|
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Staff signature" />{{/if}}
|
||||||
<p class="sig-line">Signed: {{signedAt}}</p>
|
</div>
|
||||||
|
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
|
||||||
|
<p class="sig-meta"><strong>Role:</strong> Authorized EDR representative</p>
|
||||||
|
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="sig-line">Authorized representative (pending)</p>
|
<div class="sig-image-box"><span class="sig-placeholder">Signature pending</span></div>
|
||||||
|
<p class="sig-line"><strong>Name:</strong> Authorized representative</p>
|
||||||
|
<p class="sig-meta"><strong>Role:</strong> EDR representative</p>
|
||||||
|
<p class="sig-meta"><strong>Date:</strong></p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
<div class="sig-block">
|
<div class="sig-block">
|
||||||
<p><strong>Client — {{client.companyName}}</strong></p>
|
<p class="sig-title">For the Client</p>
|
||||||
|
<p><strong>{{client.companyName}}</strong></p>
|
||||||
{{#if hasCustomerSignature}}
|
{{#if hasCustomerSignature}}
|
||||||
{{#each signatures}}
|
{{#each signatures}}
|
||||||
{{#if (eq role "CUSTOMER")}}
|
{{#if (eq role "CUSTOMER")}}
|
||||||
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Customer signature" />{{/if}}
|
<div class="sig-image-box">
|
||||||
<p>{{signerDisplayName}}</p>
|
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Customer signature" />{{/if}}
|
||||||
<p class="sig-line">Signed: {{signedAt}}</p>
|
</div>
|
||||||
|
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
|
||||||
|
<p class="sig-meta"><strong>Role:</strong> Authorized client representative</p>
|
||||||
|
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="sig-line">Client representative (pending)</p>
|
<div class="sig-image-box"><span class="sig-placeholder">Signature pending</span></div>
|
||||||
|
<p class="sig-line"><strong>Name:</strong> Client representative</p>
|
||||||
|
<p class="sig-meta"><strong>Role:</strong> Authorized client representative</p>
|
||||||
|
<p class="sig-meta"><strong>Date:</strong></p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,258 @@
|
|||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { font-family: 'Times New Roman', Times, serif; font-size: 11pt; line-height: 1.45; color: #111; margin: 0; padding: 24px; }
|
@page { size: A4; margin: 18mm 14mm; }
|
||||||
h1 { text-align: center; font-size: 14pt; text-transform: uppercase; margin: 0 0 8px; }
|
|
||||||
h2 { font-size: 12pt; margin: 20px 0 8px; text-transform: uppercase; }
|
body {
|
||||||
h3 { font-size: 11pt; margin: 14px 0 6px; }
|
margin: 0;
|
||||||
.cover { text-align: center; margin-bottom: 32px; border-bottom: 2px solid #1e3a5f; padding-bottom: 24px; }
|
background: #f5f7fb;
|
||||||
.meta { margin: 12px 0; }
|
color: #111827;
|
||||||
.meta strong { display: inline-block; min-width: 140px; }
|
font-family: "Times New Roman", Times, serif;
|
||||||
.article { margin-bottom: 16px; }
|
font-size: 10.5pt;
|
||||||
.article ol { padding-left: 20px; }
|
line-height: 1.48;
|
||||||
.article li { margin-bottom: 6px; }
|
}
|
||||||
table.schedule { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 10pt; }
|
|
||||||
table.schedule th, table.schedule td { border: 1px solid #333; padding: 6px 8px; text-align: left; }
|
.contract {
|
||||||
table.schedule th { background: #f0f4f8; }
|
width: 210mm;
|
||||||
.signatures { display: flex; gap: 40px; margin-top: 40px; page-break-inside: avoid; }
|
min-height: 297mm;
|
||||||
.sig-block { flex: 1; }
|
margin: 0 auto;
|
||||||
.sig-block img { max-height: 64px; max-width: 200px; display: block; margin: 8px 0; }
|
background: #fff;
|
||||||
.sig-line { border-top: 1px solid #000; margin-top: 48px; padding-top: 4px; font-size: 10pt; }
|
padding: 18mm 15mm;
|
||||||
.pending-banner { background: #fff8e6; border: 1px solid #e6c200; padding: 8px 12px; margin-bottom: 16px; font-size: 10pt; }
|
}
|
||||||
@media print { body { padding: 0; } }
|
|
||||||
|
h1, h2, h3, p { margin-top: 0; }
|
||||||
|
h1 {
|
||||||
|
color: #0f2742;
|
||||||
|
font-size: 18pt;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
border-bottom: 1.5px solid #1e3a5f;
|
||||||
|
color: #1e3a5f;
|
||||||
|
font-size: 12pt;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
margin: 18px 0 10px;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
color: #0f2742;
|
||||||
|
font-size: 10.8pt;
|
||||||
|
margin: 12px 0 6px;
|
||||||
|
}
|
||||||
|
p { margin-bottom: 8px; }
|
||||||
|
ol { margin: 6px 0 0; padding-left: 20px; }
|
||||||
|
li { margin-bottom: 5px; }
|
||||||
|
|
||||||
|
.page-section,
|
||||||
|
.article {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-row {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 3px solid #1e3a5f;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
}
|
||||||
|
.logo-mark {
|
||||||
|
align-items: center;
|
||||||
|
background: #1e3a5f;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 16pt;
|
||||||
|
font-weight: 700;
|
||||||
|
height: 52px;
|
||||||
|
justify-content: center;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
.kicker {
|
||||||
|
color: #1e3a5f;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #6b7280;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 9pt;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
min-height: 255mm;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.cover-title {
|
||||||
|
margin: 54mm 0 34mm;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.document-label {
|
||||||
|
color: #6b7280;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.summary-line {
|
||||||
|
color: #374151;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 9.5pt;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.meta-grid,
|
||||||
|
.details-table,
|
||||||
|
.schedule {
|
||||||
|
font-size: 9.5pt;
|
||||||
|
margin: 10px 0 16px;
|
||||||
|
}
|
||||||
|
.meta-grid th,
|
||||||
|
.meta-grid td,
|
||||||
|
.details-table th,
|
||||||
|
.details-table td,
|
||||||
|
.schedule th,
|
||||||
|
.schedule td {
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
padding: 7px 8px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.meta-grid th,
|
||||||
|
.details-table th,
|
||||||
|
.schedule th {
|
||||||
|
background: #eef4fb;
|
||||||
|
color: #1e3a5f;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.schedule tbody tr:nth-child(even) td { background: #f8fafc; }
|
||||||
|
.total-row td {
|
||||||
|
background: #e8f0f8 !important;
|
||||||
|
color: #0f2742;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
color: #374151;
|
||||||
|
font-size: 10.5pt;
|
||||||
|
}
|
||||||
|
.party-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.party-card {
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.party-card h3 {
|
||||||
|
background: #1e3a5f;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 9pt;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.party-name {
|
||||||
|
color: #0f2742;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 32% 68%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
color: #475569;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 6px 2px 0;
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signatures {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
margin-top: 24px;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
.sig-block {
|
||||||
|
border: 1.5px solid #1e3a5f;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-height: 96mm;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.sig-title {
|
||||||
|
color: #1e3a5f;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 9pt;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.sig-image-box {
|
||||||
|
align-items: center;
|
||||||
|
border: 1px dashed #94a3b8;
|
||||||
|
display: flex;
|
||||||
|
height: 28mm;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
.sig-image-box img {
|
||||||
|
display: block;
|
||||||
|
max-height: 24mm;
|
||||||
|
max-width: 70mm;
|
||||||
|
}
|
||||||
|
.sig-placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
.sig-line {
|
||||||
|
border-top: 1px solid #111827;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 5px;
|
||||||
|
}
|
||||||
|
.sig-meta {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 9pt;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body { background: #fff; }
|
||||||
|
.contract {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.cover { page-break-after: always; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,25 +6,86 @@
|
|||||||
{{> styles}}
|
{{> styles}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="cover">
|
<main class="contract">
|
||||||
<h1>Contract Agreement</h1>
|
<section class="cover page-section">
|
||||||
<h1>{{template.title}}</h1>
|
<div class="brand-row">
|
||||||
<p class="meta"><strong>Contract Ref No:</strong> {{reference}}</p>
|
<div class="logo-mark">EDR</div>
|
||||||
<p class="meta"><strong>Year:</strong> {{contractYear}}</p>
|
<div>
|
||||||
</div>
|
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
|
||||||
|
<p class="muted">Freight Transport Contract</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p>This Contract Agreement is made on <strong>{{contractDate}}</strong>.</p>
|
<div class="cover-title">
|
||||||
<p><strong>Between</strong> Ethio-Djibouti Standard Gauge Railway Share Company (EDR), Addis Ababa (“Service Provider”), and <strong>{{client.companyName}}</strong> at {{client.companyAddress}}, {{client.companyLocation}} (“Client”). Phone {{client.phone}} / {{client.email}}. TIN {{client.tinNumber}}.</p>
|
<p class="document-label">Contract Agreement</p>
|
||||||
|
<h1>{{template.title}}</h1>
|
||||||
|
<p class="summary-line">{{template.directionLabel}} • {{template.freightLabel}} • {{template.currency}} • {{template.serviceScope}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>Whereas</h2>
|
<table class="meta-grid">
|
||||||
<p>{{template.whereas}}</p>
|
<tr>
|
||||||
<p>Now therefore, the parties agree as follows:</p>
|
<th>Contract Ref No.</th>
|
||||||
|
<td>{{reference}}</td>
|
||||||
|
<th>Contract Year</th>
|
||||||
|
<td>{{contractYear}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Contract Date</th>
|
||||||
|
<td>{{contractDate}}</td>
|
||||||
|
<th>Status</th>
|
||||||
|
<td>{{status}}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
{{> article1}}
|
<section class="page-section">
|
||||||
{{> articles_obligations}}
|
<h2>Parties to the Agreement</h2>
|
||||||
{{> article5_pricing}}
|
<p class="lead">
|
||||||
{{> force_majeure}}
|
This Contract Agreement is made on <strong>{{contractDate}}</strong> between the Service Provider and the Client named below.
|
||||||
{{> contract_documents}}
|
</p>
|
||||||
{{> signatures_block}}
|
|
||||||
|
<div class="party-grid">
|
||||||
|
<div class="party-card">
|
||||||
|
<h3>Service Provider</h3>
|
||||||
|
<p class="party-name">{{provider.name}}</p>
|
||||||
|
<dl>
|
||||||
|
<dt>Address</dt><dd>{{provider.address}}</dd>
|
||||||
|
<dt>Phone</dt><dd>{{provider.phone}}</dd>
|
||||||
|
<dt>Email</dt><dd>{{provider.email}}</dd>
|
||||||
|
<dt>TIN</dt><dd>{{provider.tinNumber}}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="party-card">
|
||||||
|
<h3>Client</h3>
|
||||||
|
<p class="party-name">{{client.companyName}}</p>
|
||||||
|
<dl>
|
||||||
|
<dt>Address</dt><dd>{{client.companyAddress}}</dd>
|
||||||
|
<dt>Location</dt><dd>{{client.companyLocation}}</dd>
|
||||||
|
<dt>Phone</dt><dd>{{client.phone}}</dd>
|
||||||
|
<dt>Email</dt><dd>{{client.email}}</dd>
|
||||||
|
<dt>TIN</dt><dd>{{client.tinNumber}}</dd>
|
||||||
|
<dt>VAT</dt><dd>{{client.vatNumber}}</dd>
|
||||||
|
<dt>FAN</dt><dd>{{client.fanNumber}}</dd>
|
||||||
|
<dt>Business license</dt><dd>{{client.businessLicense}}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{> contract_schedule}}
|
||||||
|
|
||||||
|
<section class="page-section">
|
||||||
|
<h2>Whereas</h2>
|
||||||
|
<p>{{template.whereas}}</p>
|
||||||
|
<p>Now therefore, the parties agree as follows:</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{> article1}}
|
||||||
|
{{> articles_obligations}}
|
||||||
|
{{> force_majeure}}
|
||||||
|
{{> article5_pricing}}
|
||||||
|
{{> contract_documents}}
|
||||||
|
{{> signatures_block}}
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/edr-freight-api/src/modules/auth/freight-auth.module.ts
Normal file
10
apps/edr-freight-api/src/modules/auth/freight-auth.module.ts
Normal 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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
apps/edr-freight-api/src/modules/auth/freight-me.service.ts
Normal file
57
apps/edr-freight-api/src/modules/auth/freight-me.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { ContractTemplateResolver } from '../../contracts/contract-template.reso
|
|||||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||||
import { MinioService } from '../minio/minio.service';
|
import { MinioService } from '../minio/minio.service';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
@@ -68,7 +69,7 @@ export class BookingContractService {
|
|||||||
|
|
||||||
async getContractView(bookingId: string): Promise<ContractViewDto> {
|
async getContractView(bookingId: string): Promise<ContractViewDto> {
|
||||||
const { view } = await this.viewModelBuilder.build(bookingId);
|
const { view } = await this.viewModelBuilder.build(bookingId);
|
||||||
await this.enrichSignatureUrls(view.signatures);
|
await this.inlineSignatureImages(view.signatures);
|
||||||
const html = this.renderer.render(view);
|
const html = this.renderer.render(view);
|
||||||
return {
|
return {
|
||||||
bookingId: view.bookingId,
|
bookingId: view.bookingId,
|
||||||
@@ -90,33 +91,8 @@ export class BookingContractService {
|
|||||||
assertBookingStatus(booking, ['APPROVED']);
|
assertBookingStatus(booking, ['APPROVED']);
|
||||||
|
|
||||||
const templateKey = this.templateResolver.resolve(booking);
|
const templateKey = this.templateResolver.resolve(booking);
|
||||||
const { view } = await this.viewModelBuilder.build(bookingId);
|
|
||||||
view.templateKey = templateKey;
|
|
||||||
view.template = getTemplateMeta(templateKey);
|
|
||||||
|
|
||||||
const html = this.renderer.render(view);
|
|
||||||
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
|
|
||||||
const summary = this.buildContractSummary(booking);
|
const summary = this.buildContractSummary(booking);
|
||||||
|
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
|
||||||
const file: Express.Multer.File = {
|
|
||||||
fieldname: 'contract',
|
|
||||||
originalname: `contract-${booking.reference}.pdf`,
|
|
||||||
encoding: '7bit',
|
|
||||||
mimetype: 'application/pdf',
|
|
||||||
size: pdfBuffer.length,
|
|
||||||
buffer: pdfBuffer,
|
|
||||||
stream: Readable.from(pdfBuffer),
|
|
||||||
destination: '',
|
|
||||||
filename: '',
|
|
||||||
path: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.filesService.upsertByCode({
|
|
||||||
resourceId: bookingId,
|
|
||||||
resource: 'bookings',
|
|
||||||
code: 'contract',
|
|
||||||
file,
|
|
||||||
});
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
@@ -129,18 +105,15 @@ export class BookingContractService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async streamContract(bookingId: string) {
|
async streamContract(bookingId: string) {
|
||||||
try {
|
const booking = await this.requireBooking(bookingId);
|
||||||
const record = await this.filesService.findByCode(
|
const templateKey =
|
||||||
bookingId,
|
booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
|
||||||
'bookings',
|
const record = await this.upsertContractPdf(
|
||||||
'contract',
|
bookingId,
|
||||||
);
|
booking.reference,
|
||||||
return this.filesService.streamById(record.id);
|
templateKey,
|
||||||
} catch {
|
);
|
||||||
throw new NotFoundException(
|
return this.filesService.streamById(record.id);
|
||||||
'Contract document not found. Generate the contract first.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async signContract(
|
async signContract(
|
||||||
@@ -218,33 +191,84 @@ export class BookingContractService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, updates as never);
|
const updated = await this.bookingsRepository.update(bookingId, updates as never);
|
||||||
|
await this.upsertContractPdf(
|
||||||
|
bookingId,
|
||||||
|
booking.reference,
|
||||||
|
booking.contractTemplateKey ?? this.templateResolver.resolve(booking),
|
||||||
|
);
|
||||||
return updated!;
|
return updated!;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSignatures(bookingId: string) {
|
async getSignatures(bookingId: string) {
|
||||||
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
|
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
|
||||||
const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r));
|
const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r));
|
||||||
await this.enrichSignatureUrls(views);
|
await this.inlineSignatureImages(views);
|
||||||
return { signatures: views };
|
return { signatures: views };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async enrichSignatureUrls(
|
private async upsertContractPdf(
|
||||||
|
bookingId: string,
|
||||||
|
reference: string,
|
||||||
|
templateKey: string,
|
||||||
|
): Promise<FileRecord> {
|
||||||
|
const { view } = await this.viewModelBuilder.build(bookingId);
|
||||||
|
view.templateKey = templateKey;
|
||||||
|
view.template = getTemplateMeta(templateKey);
|
||||||
|
await this.inlineSignatureImages(view.signatures);
|
||||||
|
|
||||||
|
const html = this.renderer.render(view);
|
||||||
|
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
|
||||||
|
const file: Express.Multer.File = {
|
||||||
|
fieldname: 'contract',
|
||||||
|
originalname: `contract-${reference}.pdf`,
|
||||||
|
encoding: '7bit',
|
||||||
|
mimetype: 'application/pdf',
|
||||||
|
size: pdfBuffer.length,
|
||||||
|
buffer: pdfBuffer,
|
||||||
|
stream: Readable.from(pdfBuffer),
|
||||||
|
destination: '',
|
||||||
|
filename: '',
|
||||||
|
path: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.filesService.upsertByCode({
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: 'contract',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async inlineSignatureImages(
|
||||||
signatures: Array<{ signatureImageUrl?: string | null }>,
|
signatures: Array<{ signatureImageUrl?: string | null }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const sig of signatures) {
|
for (const sig of signatures) {
|
||||||
if (!sig.signatureImageUrl) continue;
|
if (!sig.signatureImageUrl) continue;
|
||||||
try {
|
try {
|
||||||
const objectName = this.extractObjectName(sig.signatureImageUrl);
|
if (sig.signatureImageUrl.startsWith('data:')) continue;
|
||||||
sig.signatureImageUrl = await this.minioService.getSignedUrl(objectName, 3600);
|
const objectName = this.minioService.getObjectNameFromUrl(
|
||||||
|
sig.signatureImageUrl,
|
||||||
|
);
|
||||||
|
const stream = await this.minioService.getFileStream(objectName);
|
||||||
|
const buffer = await this.streamToBuffer(stream);
|
||||||
|
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString(
|
||||||
|
'base64',
|
||||||
|
)}`;
|
||||||
} catch {
|
} catch {
|
||||||
/* keep original url */
|
/* keep original url */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractObjectName(url: string): string {
|
private streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||||
const parts = url.split('/');
|
return new Promise((resolve, reject) => {
|
||||||
return parts.slice(4).join('/');
|
const chunks: Buffer[] = [];
|
||||||
|
stream.on('data', (chunk: Buffer | string) => {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
});
|
||||||
|
stream.on('error', reject);
|
||||||
|
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private decodeSignatureImage(base64: string): Buffer {
|
private decodeSignatureImage(base64: string): Buffer {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,127 +1,47 @@
|
|||||||
import {
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { FilesService } from '../files/files.service';
|
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
|
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||||
|
|
||||||
const PROOF_MAX_BYTES = 5 * 1024 * 1024;
|
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto {}
|
||||||
const PROOF_MIMES = ['application/pdf', 'image/jpeg', 'image/png'];
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingPaymentService {
|
export class BookingPaymentService {
|
||||||
constructor(
|
constructor(private readonly bookingsRepository: BookingsRepository) {}
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
|
||||||
private readonly filesService: FilesService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async generatePnr(bookingId: string): Promise<Booking> {
|
async pay(
|
||||||
const booking = await this.requireBooking(bookingId);
|
|
||||||
assertBookingStatus(booking, ['FULLY_EXECUTED']);
|
|
||||||
|
|
||||||
if (booking.paymentCurrency !== 'ETB') {
|
|
||||||
throw new BadRequestException('PNR generation is only for ETB payers');
|
|
||||||
}
|
|
||||||
|
|
||||||
const year = new Date().getFullYear();
|
|
||||||
const pnrCode = `PNR-${year}-${Math.random().toString(36).slice(2, 10).toUpperCase()}`;
|
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
|
||||||
status: 'PNR_GENERATED',
|
|
||||||
pnrCode,
|
|
||||||
paymentStatus: 'PNR_GENERATED',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
async submitPaymentProof(
|
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
file: Express.Multer.File,
|
): Promise<{ booking: Booking; receipt: InAppPaymentReceipt }> {
|
||||||
): Promise<Booking> {
|
|
||||||
const booking = await this.requireBooking(bookingId);
|
const booking = await this.requireBooking(bookingId);
|
||||||
assertBookingStatus(booking, ['FULLY_EXECUTED']);
|
assertBookingStatus(booking, ['FULLY_EXECUTED']);
|
||||||
|
|
||||||
if (booking.paymentCurrency !== 'USD') {
|
const receipt = this.buildMockReceipt(booking);
|
||||||
throw new BadRequestException('Payment proof upload is only for USD payers');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.validateProofFile(file);
|
|
||||||
|
|
||||||
await this.filesService.upload({
|
|
||||||
resourceId: bookingId,
|
|
||||||
resource: 'bookings',
|
|
||||||
code: 'payment_proof',
|
|
||||||
file,
|
|
||||||
});
|
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
|
||||||
status: 'PAYMENT_VERIFICATION_IN_PROGRESS',
|
|
||||||
paymentStatus: 'VERIFICATION_IN_PROGRESS',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyPayment(bookingId: string): Promise<Booking> {
|
|
||||||
const booking = await this.requireBooking(bookingId);
|
|
||||||
assertBookingStatus(booking, ['PAYMENT_VERIFICATION_IN_PROGRESS']);
|
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'PAID',
|
status: 'PAID',
|
||||||
paymentStatus: 'PAID',
|
paymentStatus: 'PAID',
|
||||||
} as never);
|
} as never);
|
||||||
return updated!;
|
|
||||||
|
return { booking: updated!, receipt };
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleBankCallback(pnrCode: string): Promise<Booking> {
|
private buildMockReceipt(booking: Booking): InAppPaymentReceipt {
|
||||||
const booking = await this.bookingsRepository.findByPnrCode(pnrCode);
|
const timestamp = Date.now();
|
||||||
if (!booking) {
|
const isEtb = booking.paymentCurrency === 'ETB';
|
||||||
throw new NotFoundException(`No booking found for PNR ${pnrCode}`);
|
const prefix = isEtb ? 'TB' : 'CARD';
|
||||||
}
|
const provider = isEtb ? 'TELEBIRR' : 'CARD';
|
||||||
|
|
||||||
if (booking.status !== 'PNR_GENERATED') {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Booking ${booking.reference} is not awaiting bank payment (status: ${booking.status})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'PAID',
|
|
||||||
paymentStatus: 'PAID',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getPaymentRequestLetter(
|
|
||||||
bookingId: string,
|
|
||||||
): Promise<{ buffer: Buffer; filename: string }> {
|
|
||||||
const booking = await this.requireBooking(bookingId);
|
|
||||||
const body = [
|
|
||||||
'PAYMENT REQUEST LETTER (STUB)',
|
|
||||||
`Reference: ${booking.reference}`,
|
|
||||||
`Amount: ${booking.totalAmount} ${booking.paymentCurrency}`,
|
|
||||||
'Pay at your bank and upload stamped proof.',
|
|
||||||
].join('\n');
|
|
||||||
return {
|
return {
|
||||||
buffer: Buffer.from(body, 'utf-8'),
|
success: true,
|
||||||
filename: `payment-request-${booking.reference}.txt`,
|
provider,
|
||||||
|
providerRef: `${prefix}-${booking.reference}-${timestamp}`,
|
||||||
|
amount: booking.totalAmount,
|
||||||
|
currency: booking.paymentCurrency,
|
||||||
|
paidAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private validateProofFile(file: Express.Multer.File): void {
|
|
||||||
if (!file?.buffer?.length) {
|
|
||||||
throw new BadRequestException('Payment proof file is required');
|
|
||||||
}
|
|
||||||
if (file.size > PROOF_MAX_BYTES) {
|
|
||||||
throw new BadRequestException('Payment proof must be 5MB or less');
|
|
||||||
}
|
|
||||||
if (!PROOF_MIMES.includes(file.mimetype)) {
|
|
||||||
throw new BadRequestException('Payment proof must be PDF, JPG, or PNG');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requireBooking(id: string): Promise<Booking> {
|
private async requireBooking(id: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsRepository.findById(id);
|
const booking = await this.bookingsRepository.findById(id);
|
||||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
|
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
|
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||||
import { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
|
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
|
|
||||||
@@ -60,6 +63,16 @@ export class BookingTransitionService {
|
|||||||
return this.bookingsService.findById(updated!.id);
|
return this.bookingsService.findById(updated!.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Auto-create booking approval steps from system rules when none exist yet. */
|
||||||
|
private async ensureBookingApprovalSteps(booking: Booking): Promise<void> {
|
||||||
|
if ((booking.approvalSteps?.length ?? 0) > 0) return;
|
||||||
|
|
||||||
|
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
|
||||||
|
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||||
|
cargoTypeId: booking.cargoTypeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
|
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['SUBMITTED']);
|
assertBookingStatus(booking, ['SUBMITTED']);
|
||||||
@@ -103,13 +116,23 @@ export class BookingTransitionService {
|
|||||||
stepId: string,
|
stepId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
requiredRole: string,
|
requiredRole: string,
|
||||||
|
authUser?: TCurrentUser,
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
if (authUser) {
|
||||||
|
assertCanApproveBookingStep(authUser, requiredRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
let booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, [
|
assertBookingStatus(booking, [
|
||||||
'PENDING_APPROVAL',
|
'PENDING_APPROVAL',
|
||||||
'APPROVED_PENDING_SIGNATURE',
|
'APPROVED_PENDING_SIGNATURE',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ((booking.approvalSteps?.length ?? 0) === 0) {
|
||||||
|
await this.ensureBookingApprovalSteps(booking);
|
||||||
|
booking = await this.bookingsService.findById(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
const step = await this.bookingsRepository.findApprovalStepById(
|
const step = await this.bookingsRepository.findApprovalStepById(
|
||||||
bookingId,
|
bookingId,
|
||||||
stepId,
|
stepId,
|
||||||
@@ -131,7 +154,7 @@ export class BookingTransitionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocksRole = step.approvalRule?.blocksRole;
|
const blocksRole = step.blocksRole;
|
||||||
if (blocksRole && blocksRole === requiredRole) {
|
if (blocksRole && blocksRole === requiredRole) {
|
||||||
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
|
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
|
||||||
}
|
}
|
||||||
@@ -271,6 +294,7 @@ export class BookingTransitionService {
|
|||||||
async enrichBookingResponse(booking: Booking): Promise<Booking & {
|
async enrichBookingResponse(booking: Booking): Promise<Booking & {
|
||||||
latestChangeRequestNote?: string | null;
|
latestChangeRequestNote?: string | null;
|
||||||
contractSummary?: string | null;
|
contractSummary?: string | null;
|
||||||
|
nextStep: BookingNextStep | null;
|
||||||
}> {
|
}> {
|
||||||
const note = await this.bookingsRepository.findLatestReviewNote(
|
const note = await this.bookingsRepository.findLatestReviewNote(
|
||||||
booking.id,
|
booking.id,
|
||||||
@@ -279,10 +303,17 @@ export class BookingTransitionService {
|
|||||||
const summary =
|
const summary =
|
||||||
booking.contractSummary ??
|
booking.contractSummary ??
|
||||||
this.contractService.buildContractSummary(booking);
|
this.contractService.buildContractSummary(booking);
|
||||||
|
const nextPending =
|
||||||
|
booking.status === 'PENDING_APPROVAL' ||
|
||||||
|
booking.status === 'APPROVED_PENDING_SIGNATURE'
|
||||||
|
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
|
||||||
|
: null;
|
||||||
|
const nextStep = computeNextStep(booking, nextPending);
|
||||||
return {
|
return {
|
||||||
...booking,
|
...booking,
|
||||||
latestChangeRequestNote: note?.note ?? null,
|
latestChangeRequestNote: note?.note ?? null,
|
||||||
contractSummary: summary,
|
contractSummary: summary,
|
||||||
|
nextStep,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Header,
|
|
||||||
HttpCode,
|
HttpCode,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
@@ -12,13 +11,13 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
Request,
|
Request,
|
||||||
Res,
|
Res,
|
||||||
StreamableFile,
|
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
UseGuards,
|
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
@@ -31,13 +30,13 @@ import {
|
|||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
|
||||||
import { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingPaymentService } from './booking-payment.service';
|
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { BookingTransitionService } from './booking-transition.service';
|
import { BookingTransitionService } from './booking-transition.service';
|
||||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
||||||
import { CreateBookingDto } from './dto/create-booking.dto';
|
import { CreateBookingDto } from './dto/create-booking.dto';
|
||||||
|
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
|
||||||
import { FilterBookingDto } from './dto/filter-booking.dto';
|
import { FilterBookingDto } from './dto/filter-booking.dto';
|
||||||
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||||||
import {
|
import {
|
||||||
@@ -65,7 +64,6 @@ export class BookingsController {
|
|||||||
private readonly pricingService: BookingPricingService,
|
private readonly pricingService: BookingPricingService,
|
||||||
private readonly transitionService: BookingTransitionService,
|
private readonly transitionService: BookingTransitionService,
|
||||||
private readonly contractService: BookingContractService,
|
private readonly contractService: BookingContractService,
|
||||||
private readonly paymentService: BookingPaymentService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -104,6 +102,13 @@ export class BookingsController {
|
|||||||
return this.bookingsService.findAll(filter);
|
return this.bookingsService.findAll(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('list-summary')
|
||||||
|
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
|
||||||
|
@ApiOkResponse({ type: BookingListSummaryDto })
|
||||||
|
findListSummary(@Query() filter: FilterBookingDto) {
|
||||||
|
return this.bookingsService.getListSummary(filter);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('queues/:queue')
|
@Get('queues/:queue')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'List bookings for a dashboard queue',
|
summary: 'List bookings for a dashboard queue',
|
||||||
@@ -162,7 +167,7 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/staff/request-changes')
|
@Post(':id/staff/request-changes')
|
||||||
@UseGuards(JwtGuard)
|
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
|
||||||
@ApiOperation({ summary: 'Staff return booking for customer updates' })
|
@ApiOperation({ summary: 'Staff return booking for customer updates' })
|
||||||
async requestChanges(
|
async requestChanges(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@@ -178,7 +183,7 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/staff/accept')
|
@Post(':id/staff/accept')
|
||||||
@UseGuards(JwtGuard)
|
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||||
@ApiOperation({ summary: 'Staff accept intake → start approval chain' })
|
@ApiOperation({ summary: 'Staff accept intake → start approval chain' })
|
||||||
async acceptIntake(
|
async acceptIntake(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@@ -192,7 +197,7 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/staff/reject')
|
@Post(':id/staff/reject')
|
||||||
@UseGuards(JwtGuard)
|
@BookingStaff(FREIGHT_PERMS.bookings.reject)
|
||||||
@ApiOperation({ summary: 'Staff final reject' })
|
@ApiOperation({ summary: 'Staff final reject' })
|
||||||
async staffReject(
|
async staffReject(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@@ -208,25 +213,30 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/approval-steps/:stepId/approve')
|
@Post(':id/approval-steps/:stepId/approve')
|
||||||
@UseGuards(JwtGuard)
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||||
|
FREIGHT_PERMS.bookings.approveDirector,
|
||||||
|
FREIGHT_PERMS.bookings.approveCeo,
|
||||||
|
])
|
||||||
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||||
async approveStep(
|
async approveStep(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||||
@Body() dto: ApproveStepDto,
|
@Body() dto: ApproveStepDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
const booking = await this.transitionService.approveStep(
|
const booking = await this.transitionService.approveStep(
|
||||||
id,
|
id,
|
||||||
stepId,
|
stepId,
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
dto.requiredRole,
|
dto.requiredRole,
|
||||||
|
user,
|
||||||
);
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/approval-steps/:stepId/reject')
|
@Post(':id/approval-steps/:stepId/reject')
|
||||||
@UseGuards(JwtGuard)
|
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
|
||||||
@ApiOperation({ summary: 'Reject at approval step' })
|
@ApiOperation({ summary: 'Reject at approval step' })
|
||||||
async rejectStep(
|
async rejectStep(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@@ -244,7 +254,7 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/contract/generate')
|
@Post(':id/contract/generate')
|
||||||
@UseGuards(JwtGuard)
|
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
|
||||||
@ApiOperation({ summary: 'Generate contract PDF from template' })
|
@ApiOperation({ summary: 'Generate contract PDF from template' })
|
||||||
async generateContract(@Param('id', ParseUUIDPipe) id: string) {
|
async generateContract(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
const booking = await this.contractService.generateContract(id);
|
const booking = await this.contractService.generateContract(id);
|
||||||
@@ -262,22 +272,23 @@ export class BookingsController {
|
|||||||
@ApiOperation({ summary: 'Download contract PDF' })
|
@ApiOperation({ summary: 'Download contract PDF' })
|
||||||
async downloadContractDocument(
|
async downloadContractDocument(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res() res: Response,
|
||||||
) {
|
): Promise<void> {
|
||||||
const { stream, record } = await this.contractService.streamContract(id);
|
const { stream, record } = await this.contractService.streamContract(id);
|
||||||
res.set({
|
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
|
||||||
'Content-Type': record.mimeType ?? 'application/pdf',
|
res.setHeader(
|
||||||
'Content-Disposition': `attachment; filename="${record.name}"`,
|
'Content-Disposition',
|
||||||
});
|
`attachment; filename="${record.name}"`,
|
||||||
return new StreamableFile(stream);
|
);
|
||||||
|
stream.pipe(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/contract')
|
@Get(':id/contract')
|
||||||
@ApiOperation({ summary: 'Download contract file (alias)' })
|
@ApiOperation({ summary: 'Download contract file (alias)' })
|
||||||
async downloadContract(
|
async downloadContract(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res() res: Response,
|
||||||
) {
|
): Promise<void> {
|
||||||
return this.downloadContractDocument(id, res);
|
return this.downloadContractDocument(id, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +337,7 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/marketing/approve')
|
@Post(':id/marketing/approve')
|
||||||
@UseGuards(JwtGuard)
|
@BookingStaff(FREIGHT_PERMS.bookings.signStaff)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
|
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
|
||||||
})
|
})
|
||||||
@@ -347,47 +358,8 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/payment/pnr')
|
|
||||||
@ApiOperation({ summary: 'Generate PNR code (ETB)' })
|
|
||||||
async generatePnr(@Param('id', ParseUUIDPipe) id: string) {
|
|
||||||
const booking = await this.paymentService.generatePnr(id);
|
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':id/payment/proof')
|
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
|
||||||
@ApiConsumes('multipart/form-data')
|
|
||||||
@ApiOperation({ summary: 'Upload USD payment proof' })
|
|
||||||
async submitPaymentProof(
|
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
|
||||||
) {
|
|
||||||
const file = files?.[0];
|
|
||||||
const booking = await this.paymentService.submitPaymentProof(id, file);
|
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id/payment/request-letter')
|
|
||||||
@ApiOperation({ summary: 'Download payment request letter (USD stub)' })
|
|
||||||
@Header('Content-Type', 'text/plain')
|
|
||||||
async paymentRequestLetter(
|
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@Res({ passthrough: true }) res: Response,
|
|
||||||
) {
|
|
||||||
const { buffer, filename } =
|
|
||||||
await this.paymentService.getPaymentRequestLetter(id);
|
|
||||||
res.set('Content-Disposition', `attachment; filename="${filename}"`);
|
|
||||||
return new StreamableFile(buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':id/payment/verify')
|
|
||||||
@ApiOperation({ summary: 'Finance verify USD payment' })
|
|
||||||
async verifyPayment(@Param('id', ParseUUIDPipe) id: string) {
|
|
||||||
const booking = await this.paymentService.verifyPayment(id);
|
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':id/operations/start-transit')
|
@Post(':id/operations/start-transit')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||||
@ApiOperation({ summary: 'Mark in transit' })
|
@ApiOperation({ summary: 'Mark in transit' })
|
||||||
async startTransit(@Param('id', ParseUUIDPipe) id: string) {
|
async startTransit(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
const booking = await this.transitionService.startTransit(id);
|
const booking = await this.transitionService.startTransit(id);
|
||||||
@@ -395,6 +367,7 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/operations/complete')
|
@Post(':id/operations/complete')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||||
@ApiOperation({ summary: 'Mark completed' })
|
@ApiOperation({ summary: 'Mark completed' })
|
||||||
async complete(@Param('id', ParseUUIDPipe) id: string) {
|
async complete(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
const booking = await this.transitionService.complete(id);
|
const booking = await this.transitionService.complete(id);
|
||||||
@@ -402,6 +375,7 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/cancel')
|
@Post(':id/cancel')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
|
||||||
@ApiOperation({ summary: 'Cancel booking' })
|
@ApiOperation({ summary: 'Cancel booking' })
|
||||||
async cancel(
|
async cancel(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import { BookingPricingService } from './booking-pricing.service';
|
|||||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||||
import { BookingTransitionService } from './booking-transition.service';
|
import { BookingTransitionService } from './booking-transition.service';
|
||||||
import { BookingsController } from './bookings.controller';
|
import { BookingsController } from './bookings.controller';
|
||||||
|
import { PayController } from './pay.controller';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { ConsolidationService } from './consolidation.service';
|
import { ConsolidationService } from './consolidation.service';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { PaymentsWebhookController } from './payments-webhook.controller';
|
|
||||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||||
import { BookingContainer } from './entities/booking-container.entity';
|
import { BookingContainer } from './entities/booking-container.entity';
|
||||||
@@ -46,7 +46,7 @@ import { ContractViewModelBuilder } from '../../contracts/contract-view-model.bu
|
|||||||
// CustomersModule,
|
// CustomersModule,
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
],
|
],
|
||||||
controllers: [BookingsController, PaymentsWebhookController],
|
controllers: [BookingsController, PayController],
|
||||||
providers: [
|
providers: [
|
||||||
BookingsService,
|
BookingsService,
|
||||||
BookingsRepository,
|
BookingsRepository,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository } from '@edr/api-common';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, FindOptionsWhere, Repository } from 'typeorm';
|
import { DataSource, FindOptionsWhere, Repository, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||||
@@ -17,6 +17,20 @@ import {
|
|||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
|
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
|
||||||
|
|
||||||
|
export interface BookingListFilterOptions {
|
||||||
|
statuses?: string[];
|
||||||
|
status?: string;
|
||||||
|
companyId?: string;
|
||||||
|
contractType?: string;
|
||||||
|
serviceTypeId?: string;
|
||||||
|
cargoTypeId?: string;
|
||||||
|
freightType?: string;
|
||||||
|
tradeDirection?: string;
|
||||||
|
paymentCurrency?: string;
|
||||||
|
allowConsolidation?: boolean;
|
||||||
|
consolidationPaired?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingsRepository extends BaseRepository<Booking> {
|
export class BookingsRepository extends BaseRepository<Booking> {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -230,7 +244,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
||||||
where: { bookingId, status: 'PENDING' },
|
where: { bookingId, status: 'PENDING' },
|
||||||
order: { stepOrder: 'ASC' },
|
order: { stepOrder: 'ASC' },
|
||||||
relations: ['approvalRule'],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +253,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
): Promise<BookingApprovalStep | null> {
|
): Promise<BookingApprovalStep | null> {
|
||||||
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
||||||
where: { bookingId, id: stepId },
|
where: { bookingId, id: stepId },
|
||||||
relations: ['approvalRule'],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,10 +345,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
|
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByPnrCode(pnrCode: string): Promise<Booking | null> {
|
|
||||||
return this.repository.findOne({ where: { pnrCode } });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
|
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
|
||||||
async findQueue(options: {
|
async findQueue(options: {
|
||||||
status: string | string[];
|
status: string | string[];
|
||||||
@@ -353,9 +361,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
const qb = this.repository
|
const qb = this.repository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
.leftJoinAndSelect('booking.company', 'company')
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
// .leftJoinAndSelect('booking.customer', 'customer')
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||||
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
.leftJoinAndSelect('booking.cargoType', 'cargo')
|
.leftJoinAndSelect('booking.cargoType', 'cargo')
|
||||||
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||||
|
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
||||||
.where('booking.status IN (:...statuses)', { statuses });
|
.where('booking.status IN (:...statuses)', { statuses });
|
||||||
|
|
||||||
if (options.excludeBulk) {
|
if (options.excludeBulk) {
|
||||||
@@ -363,7 +373,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sortField =
|
const sortField =
|
||||||
options.sortBy === 'priorityScore' ? 'booking.priority_score' : 'booking.created_at';
|
options.sortBy === 'priorityScore'
|
||||||
|
? 'booking.priorityScore'
|
||||||
|
: 'booking.createdAt';
|
||||||
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
||||||
|
|
||||||
const [items, total] = await qb
|
const [items, total] = await qb
|
||||||
@@ -374,6 +386,158 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
return { items, total };
|
return { items, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Paginated list with optional multi-status filter (API tab queues). */
|
||||||
|
async findAllPaginated(options: BookingListFilterOptions & {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}): Promise<{ items: Booking[]; total: number }> {
|
||||||
|
const page = options.page;
|
||||||
|
const pageSize = options.pageSize;
|
||||||
|
|
||||||
|
const qb = this.repository
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||||
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
|
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||||
|
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
||||||
|
.where('booking.deleted_at IS NULL');
|
||||||
|
|
||||||
|
this.applyListFilters(qb, options);
|
||||||
|
|
||||||
|
const sortField =
|
||||||
|
options.sortBy === 'priorityScore'
|
||||||
|
? 'booking.priorityScore'
|
||||||
|
: 'booking.createdAt';
|
||||||
|
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
||||||
|
|
||||||
|
const [items, total] = await qb
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
return { items, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatusCounts(): Promise<Record<string, number>> {
|
||||||
|
const rows = await this.repository
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.select('booking.status', 'status')
|
||||||
|
.addSelect('COUNT(*)::int', 'count')
|
||||||
|
.where('booking.deleted_at IS NULL')
|
||||||
|
.groupBy('booking.status')
|
||||||
|
.getRawMany<{ status: string; count: string }>();
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
rows.map((row) => [row.status, Number(row.count)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getListSummaryMetrics(
|
||||||
|
options: BookingListFilterOptions & {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
needsActionStatuses: readonly string[];
|
||||||
|
urgentPriorityThreshold: number;
|
||||||
|
},
|
||||||
|
): Promise<{
|
||||||
|
inQueue: number;
|
||||||
|
onThisPage: number;
|
||||||
|
needsAction: number;
|
||||||
|
urgent: number;
|
||||||
|
}> {
|
||||||
|
const baseQb = () => {
|
||||||
|
const qb = this.repository
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.where('booking.deleted_at IS NULL');
|
||||||
|
this.applyListFilters(qb, options);
|
||||||
|
return qb;
|
||||||
|
};
|
||||||
|
|
||||||
|
const inQueue = await baseQb().getCount();
|
||||||
|
|
||||||
|
const needsAction = await baseQb()
|
||||||
|
.andWhere('booking.status IN (:...needsActionStatuses)', {
|
||||||
|
needsActionStatuses: [...options.needsActionStatuses],
|
||||||
|
})
|
||||||
|
.getCount();
|
||||||
|
|
||||||
|
const urgent = await baseQb()
|
||||||
|
.andWhere('booking.priority_score >= :urgentPriorityThreshold', {
|
||||||
|
urgentPriorityThreshold: options.urgentPriorityThreshold,
|
||||||
|
})
|
||||||
|
.getCount();
|
||||||
|
|
||||||
|
const offset = (options.page - 1) * options.pageSize;
|
||||||
|
const onThisPage = Math.min(
|
||||||
|
options.pageSize,
|
||||||
|
Math.max(0, inQueue - offset),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { inQueue, onThisPage, needsAction, urgent };
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyListFilters(
|
||||||
|
qb: SelectQueryBuilder<Booking>,
|
||||||
|
options: BookingListFilterOptions,
|
||||||
|
): void {
|
||||||
|
if (options.statuses?.length) {
|
||||||
|
qb.andWhere('booking.status IN (:...statuses)', {
|
||||||
|
statuses: options.statuses,
|
||||||
|
});
|
||||||
|
} else if (options.status) {
|
||||||
|
qb.andWhere('booking.status = :status', { status: options.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.companyId) {
|
||||||
|
qb.andWhere('booking.company_id = :companyId', {
|
||||||
|
companyId: options.companyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.contractType) {
|
||||||
|
qb.andWhere('booking.contract_type = :contractType', {
|
||||||
|
contractType: options.contractType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.serviceTypeId) {
|
||||||
|
qb.andWhere('booking.service_type_id = :serviceTypeId', {
|
||||||
|
serviceTypeId: options.serviceTypeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.cargoTypeId) {
|
||||||
|
qb.andWhere('booking.cargo_type_id = :cargoTypeId', {
|
||||||
|
cargoTypeId: options.cargoTypeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.freightType) {
|
||||||
|
qb.andWhere('booking.freight_type = :freightType', {
|
||||||
|
freightType: options.freightType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.tradeDirection) {
|
||||||
|
qb.andWhere('booking.trade_direction = :tradeDirection', {
|
||||||
|
tradeDirection: options.tradeDirection,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.paymentCurrency) {
|
||||||
|
qb.andWhere('booking.payment_currency = :paymentCurrency', {
|
||||||
|
paymentCurrency: options.paymentCurrency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.allowConsolidation !== undefined) {
|
||||||
|
qb.andWhere('booking.allow_consolidation = :allowConsolidation', {
|
||||||
|
allowConsolidation: options.allowConsolidation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.consolidationPaired === 'true') {
|
||||||
|
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
|
||||||
|
} else if (options.consolidationPaired === 'false') {
|
||||||
|
qb.andWhere('booking.consolidation_partner_id IS NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
|
async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
|
||||||
skip: number;
|
skip: number;
|
||||||
take: number;
|
take: number;
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { IsNull, Not } from 'typeorm';
|
|
||||||
|
|
||||||
// import { CustomersService } from '../customers/customers.service';
|
// import { CustomersService } from '../customers/customers.service';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
@@ -19,12 +17,25 @@ import { BookingsRepository } from './bookings.repository';
|
|||||||
import { ConsolidationService } from './consolidation.service';
|
import { ConsolidationService } from './consolidation.service';
|
||||||
import { assertFreightShape } from './booking-freight.util';
|
import { assertFreightShape } from './booking-freight.util';
|
||||||
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
|
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
|
||||||
|
import { mapStatusCountsToTabs } from './booking-list-tabs.config';
|
||||||
|
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
|
||||||
import { FilterBookingDto } from './dto/filter-booking.dto';
|
import { FilterBookingDto } from './dto/filter-booking.dto';
|
||||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||||
import { CUSTOMER_EDITABLE_STATUSES, FreightType } from './entities/booking.entity';
|
import {
|
||||||
|
BOOKING_STATUSES,
|
||||||
|
CUSTOMER_EDITABLE_STATUSES,
|
||||||
|
FreightType,
|
||||||
|
} from './entities/booking.entity';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
|
|
||||||
|
const URGENT_PRIORITY_THRESHOLD = 1000;
|
||||||
|
const NEEDS_ACTION_STATUSES = [
|
||||||
|
'SUBMITTED',
|
||||||
|
'PENDING_APPROVAL',
|
||||||
|
'APPROVED_PENDING_SIGNATURE',
|
||||||
|
] as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingsService {
|
export class BookingsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -379,44 +390,88 @@ export class BookingsService {
|
|||||||
return { booking, warnings };
|
return { booking, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse comma-separated or repeated status query values. */
|
||||||
|
private parseStatusFilter(filter: FilterBookingDto): {
|
||||||
|
statuses?: string[];
|
||||||
|
status?: string;
|
||||||
|
} {
|
||||||
|
const allowed = new Set<string>(BOOKING_STATUSES);
|
||||||
|
const raw = filter.statuses;
|
||||||
|
const statusList = raw
|
||||||
|
? raw
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => allowed.has(s))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (statusList.length > 0) {
|
||||||
|
return { statuses: statusList };
|
||||||
|
}
|
||||||
|
if (filter.status && allowed.has(filter.status)) {
|
||||||
|
return { status: filter.status };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
/** Return a paginated list of bookings matching the filter. */
|
/** Return a paginated list of bookings matching the filter. */
|
||||||
async findAll(
|
async findAll(
|
||||||
filter: FilterBookingDto,
|
filter: FilterBookingDto,
|
||||||
): Promise<{ items: Booking[]; total: number }> {
|
): Promise<{ items: Booking[]; total: number }> {
|
||||||
const page = filter.page ?? 1;
|
const page = filter.page ?? 1;
|
||||||
const pageSize = filter.pageSize ?? 20;
|
const pageSize = filter.pageSize ?? 20;
|
||||||
|
const statusFilter = this.parseStatusFilter(filter);
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
return this.bookingsRepository.findAllPaginated({
|
||||||
if (filter.status) where.status = filter.status;
|
page,
|
||||||
// if (filter.customerId) where.customerId = filter.customerId;
|
pageSize,
|
||||||
if (filter.companyId) where.companyId = filter.companyId;
|
...statusFilter,
|
||||||
if (filter.contractType) where.contractType = filter.contractType;
|
companyId: filter.companyId,
|
||||||
if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId;
|
contractType: filter.contractType,
|
||||||
if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId;
|
serviceTypeId: filter.serviceTypeId,
|
||||||
if (filter.freightType) where.freightType = filter.freightType;
|
cargoTypeId: filter.cargoTypeId,
|
||||||
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
|
freightType: filter.freightType,
|
||||||
if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency;
|
tradeDirection: filter.tradeDirection,
|
||||||
if (filter.allowConsolidation !== undefined) {
|
paymentCurrency: filter.paymentCurrency,
|
||||||
where.allowConsolidation = filter.allowConsolidation;
|
allowConsolidation: filter.allowConsolidation,
|
||||||
}
|
consolidationPaired: filter.consolidationPaired,
|
||||||
if (filter.consolidationPaired === 'true') {
|
sortBy: filter.sortBy,
|
||||||
where.consolidationPartnerId = Not(IsNull());
|
sortOrder: filter.sortOrder,
|
||||||
} else if (filter.consolidationPaired === 'false') {
|
|
||||||
where.consolidationPartnerId = IsNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortField = filter.sortBy ?? 'createdAt';
|
|
||||||
const sortDir = filter.sortOrder ?? 'DESC';
|
|
||||||
|
|
||||||
const [items, total] = await this.bookingsRepository.findAndCount({
|
|
||||||
where,
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
take: pageSize,
|
|
||||||
order: { [sortField]: sortDir },
|
|
||||||
relations: ['company', 'originYard', 'destinationYard', 'serviceType'],
|
|
||||||
// relations: ['customer', 'originYard', 'destinationYard', 'serviceType'],
|
|
||||||
});
|
});
|
||||||
return { items, total };
|
}
|
||||||
|
|
||||||
|
/** Aggregate metrics and tab counts for the backoffice booking list. */
|
||||||
|
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
|
||||||
|
const page = filter.page ?? 1;
|
||||||
|
const pageSize = filter.pageSize ?? 20;
|
||||||
|
const statusFilter = this.parseStatusFilter(filter);
|
||||||
|
const listFilter = {
|
||||||
|
...statusFilter,
|
||||||
|
companyId: filter.companyId,
|
||||||
|
contractType: filter.contractType,
|
||||||
|
serviceTypeId: filter.serviceTypeId,
|
||||||
|
cargoTypeId: filter.cargoTypeId,
|
||||||
|
freightType: filter.freightType,
|
||||||
|
tradeDirection: filter.tradeDirection,
|
||||||
|
paymentCurrency: filter.paymentCurrency,
|
||||||
|
allowConsolidation: filter.allowConsolidation,
|
||||||
|
consolidationPaired: filter.consolidationPaired,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [statusCounts, metrics] = await Promise.all([
|
||||||
|
this.bookingsRepository.getStatusCounts(),
|
||||||
|
this.bookingsRepository.getListSummaryMetrics({
|
||||||
|
...listFilter,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
needsActionStatuses: NEEDS_ACTION_STATUSES,
|
||||||
|
urgentPriorityThreshold: URGENT_PRIORITY_THRESHOLD,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
metrics,
|
||||||
|
tabs: mapStatusCountsToTabs(statusCounts),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a single booking by ID with files. */
|
/** Get a single booking by ID with files. */
|
||||||
@@ -429,7 +484,7 @@ export class BookingsService {
|
|||||||
if (booking.files && booking.files.length > 0) {
|
if (booking.files && booking.files.length > 0) {
|
||||||
booking.files = await Promise.all(
|
booking.files = await Promise.all(
|
||||||
booking.files.map(async (file: FileRecord) => {
|
booking.files.map(async (file: FileRecord) => {
|
||||||
const objectName = this.extractObjectName(file.url);
|
const objectName = this.minioService.getObjectNameFromUrl(file.url);
|
||||||
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
|
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
|
||||||
return { ...file, signedUrl };
|
return { ...file, signedUrl };
|
||||||
}),
|
}),
|
||||||
@@ -439,11 +494,6 @@ export class BookingsService {
|
|||||||
return booking;
|
return booking;
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractObjectName(url: string): string {
|
|
||||||
const parts = url.split('/');
|
|
||||||
return parts.slice(4).join('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByReference(reference: string): Promise<Booking> {
|
async findByReference(reference: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsRepository.findByReferenceWithFiles(reference);
|
const booking = await this.bookingsRepository.findByReferenceWithFiles(reference);
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
@@ -467,10 +517,11 @@ export class BookingsService {
|
|||||||
): Promise<{ items: Booking[]; total: number }> {
|
): Promise<{ items: Booking[]; total: number }> {
|
||||||
const statusMap: Record<string, string | string[]> = {
|
const statusMap: Record<string, string | string[]> = {
|
||||||
intake: 'SUBMITTED',
|
intake: 'SUBMITTED',
|
||||||
approval: 'PENDING_APPROVAL',
|
approval: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
|
||||||
signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'],
|
signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'],
|
||||||
|
contract: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
|
||||||
marketing: 'SIGNED_CUSTOMER',
|
marketing: 'SIGNED_CUSTOMER',
|
||||||
finance: 'PAYMENT_VERIFICATION_IN_PROGRESS',
|
finance: 'FULLY_EXECUTED',
|
||||||
};
|
};
|
||||||
|
|
||||||
const status = statusMap[queue];
|
const status = statusMap[queue];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -14,6 +14,18 @@ export class FilterBookingDto {
|
|||||||
@IsIn([...BOOKING_STATUSES])
|
@IsIn([...BOOKING_STATUSES])
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Filter by statuses: comma-separated (PENDING_APPROVAL,APPROVED) or repeated query params. Overrides status when set.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (value === undefined || value === null || value === '') return undefined;
|
||||||
|
if (Array.isArray(value)) return value.map(String).join(',');
|
||||||
|
return String(value);
|
||||||
|
})
|
||||||
|
statuses?: string;
|
||||||
|
|
||||||
// @ApiPropertyOptional({ format: 'uuid' })
|
// @ApiPropertyOptional({ format: 'uuid' })
|
||||||
// @IsOptional()
|
// @IsOptional()
|
||||||
// @IsUUID()
|
// @IsUUID()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -34,9 +34,3 @@ export class CancelBookingDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
reason!: string;
|
reason!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BankCallbackDto {
|
|
||||||
@ApiProperty()
|
|
||||||
@IsString()
|
|
||||||
pnrCode!: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export class BookingApprovalStep extends BaseEntity {
|
|||||||
@Column({ name: 'required_role', type: 'varchar', length: 30 })
|
@Column({ name: 'required_role', type: 'varchar', length: 30 })
|
||||||
requiredRole!: string;
|
requiredRole!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true })
|
||||||
|
blocksRole?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
|
||||||
status!: ApprovalStepStatus;
|
status!: ApprovalStepStatus;
|
||||||
|
|
||||||
|
|||||||
34
apps/edr-freight-api/src/modules/bookings/pay.controller.ts
Normal file
34
apps/edr-freight-api/src/modules/bookings/pay.controller.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -79,13 +79,8 @@ export class FilesService {
|
|||||||
|
|
||||||
async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> {
|
async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> {
|
||||||
const record = await this.findById(id);
|
const record = await this.findById(id);
|
||||||
const objectName = this.extractObjectName(record.url);
|
const objectName = this.minioService.getObjectNameFromUrl(record.url);
|
||||||
const stream = await this.minioService.getFileStream(objectName);
|
const stream = await this.minioService.getFileStream(objectName);
|
||||||
return { stream, record };
|
return { stream, record };
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractObjectName(url: string): string {
|
|
||||||
const parts = url.split("/");
|
|
||||||
return parts.slice(4).join("/");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
import { ConfigType } from "@nestjs/config";
|
import { ConfigType } from "@nestjs/config";
|
||||||
import { Client } from "minio";
|
import { Client } from "minio";
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
@@ -54,6 +54,30 @@ export class MinioService {
|
|||||||
return `${protocol}://${this.config.endPoint}:${this.config.port}/${this.bucket}/${objectName}`;
|
return `${protocol}://${this.config.endPoint}:${this.config.port}/${this.bucket}/${objectName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getObjectNameFromUrl(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new NotFoundException("File object path is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^https?:\/\//i.test(trimmed)) {
|
||||||
|
return trimmed.replace(/^\/+/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(trimmed);
|
||||||
|
const parts = url.pathname.split("/").filter(Boolean);
|
||||||
|
if (parts[0] === this.bucket) {
|
||||||
|
parts.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectName = parts.join("/");
|
||||||
|
if (!objectName) {
|
||||||
|
throw new NotFoundException("File object path is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
return objectName;
|
||||||
|
}
|
||||||
|
|
||||||
async deleteFile(objectName: string): Promise<void> {
|
async deleteFile(objectName: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.client.removeObject(this.bucket, objectName);
|
await this.client.removeObject(this.bucket, objectName);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
||||||
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
||||||
@@ -9,12 +10,12 @@ import { ApprovalRulesService } from '../services/approval-rules.service';
|
|||||||
|
|
||||||
@ApiTags('approval-rules')
|
@ApiTags('approval-rules')
|
||||||
@Controller('approval-rules')
|
@Controller('approval-rules')
|
||||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class ApprovalRulesController {
|
export class ApprovalRulesController {
|
||||||
constructor(private readonly service: ApprovalRulesService) {}
|
constructor(private readonly service: ApprovalRulesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RuleEngineView('approval-rules')
|
||||||
@ApiOperation({ summary: 'List approval rules' })
|
@ApiOperation({ summary: 'List approval rules' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: Record<string, string>) {
|
||||||
return this.service.findAll({
|
return this.service.findAll({
|
||||||
@@ -28,30 +29,35 @@ export class ApprovalRulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('chain')
|
@Get('chain')
|
||||||
|
@RuleEngineView('approval-rules')
|
||||||
@ApiOperation({ summary: 'Get approval chain for cargo routing flag' })
|
@ApiOperation({ summary: 'Get approval chain for cargo routing flag' })
|
||||||
findChain(@Query('requiresDirectorApproval') flag: string) {
|
findChain(@Query('requiresDirectorApproval') flag: string) {
|
||||||
return this.service.findChain(flag === 'true');
|
return this.service.findChain(flag === 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RuleEngineView('approval-rules')
|
||||||
@ApiOperation({ summary: 'Get an approval rule by ID' })
|
@ApiOperation({ summary: 'Get an approval rule by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RuleEngineManage('approval-rules')
|
||||||
@ApiOperation({ summary: 'Create an approval rule step' })
|
@ApiOperation({ summary: 'Create an approval rule step' })
|
||||||
create(@Body() dto: CreateApprovalRuleDto) {
|
create(@Body() dto: CreateApprovalRuleDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RuleEngineManage('approval-rules')
|
||||||
@ApiOperation({ summary: 'Update an approval rule' })
|
@ApiOperation({ summary: 'Update an approval rule' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RuleEngineManage('approval-rules')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete an approval rule' })
|
@ApiOperation({ summary: 'Soft-delete an approval rule' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -3,18 +3,19 @@ import {
|
|||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
||||||
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
||||||
import { CargoTypesService } from '../services/cargo-types.service';
|
import { CargoTypesService } from '../services/cargo-types.service';
|
||||||
|
|
||||||
@ApiTags('cargo-types')
|
@ApiTags('cargo-types')
|
||||||
@Controller('cargo-types')
|
@Controller('cargo-types')
|
||||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class CargoTypesController {
|
export class CargoTypesController {
|
||||||
constructor(private readonly service: CargoTypesService) {}
|
constructor(private readonly service: CargoTypesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RuleEngineView('cargo-types')
|
||||||
@ApiOperation({ summary: 'List cargo types' })
|
@ApiOperation({ summary: 'List cargo types' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: Record<string, string>) {
|
||||||
return this.service.findAll({
|
return this.service.findAll({
|
||||||
@@ -32,24 +33,28 @@ export class CargoTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RuleEngineView('cargo-types')
|
||||||
@ApiOperation({ summary: 'Get a cargo type by ID' })
|
@ApiOperation({ summary: 'Get a cargo type by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RuleEngineManage('cargo-types')
|
||||||
@ApiOperation({ summary: 'Create a cargo type' })
|
@ApiOperation({ summary: 'Create a cargo type' })
|
||||||
create(@Body() dto: CreateCargoTypeDto) {
|
create(@Body() dto: CreateCargoTypeDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RuleEngineManage('cargo-types')
|
||||||
@ApiOperation({ summary: 'Update a cargo type' })
|
@ApiOperation({ summary: 'Update a cargo type' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RuleEngineManage('cargo-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a cargo type' })
|
@ApiOperation({ summary: 'Soft-delete a cargo type' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -3,18 +3,19 @@ import {
|
|||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
||||||
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
||||||
import { ContainerTypesService } from '../services/container-types.service';
|
import { ContainerTypesService } from '../services/container-types.service';
|
||||||
|
|
||||||
@ApiTags('container-types')
|
@ApiTags('container-types')
|
||||||
@Controller('container-types')
|
@Controller('container-types')
|
||||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class ContainerTypesController {
|
export class ContainerTypesController {
|
||||||
constructor(private readonly service: ContainerTypesService) {}
|
constructor(private readonly service: ContainerTypesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RuleEngineView('container-types')
|
||||||
@ApiOperation({ summary: 'List container types' })
|
@ApiOperation({ summary: 'List container types' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: Record<string, string>) {
|
||||||
return this.service.findAll({
|
return this.service.findAll({
|
||||||
@@ -25,24 +26,28 @@ export class ContainerTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RuleEngineView('container-types')
|
||||||
@ApiOperation({ summary: 'Get a container type by ID' })
|
@ApiOperation({ summary: 'Get a container type by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RuleEngineManage('container-types')
|
||||||
@ApiOperation({ summary: 'Create a container type' })
|
@ApiOperation({ summary: 'Create a container type' })
|
||||||
create(@Body() dto: CreateContainerTypeDto) {
|
create(@Body() dto: CreateContainerTypeDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RuleEngineManage('container-types')
|
||||||
@ApiOperation({ summary: 'Update a container type' })
|
@ApiOperation({ summary: 'Update a container type' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RuleEngineManage('container-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a container type' })
|
@ApiOperation({ summary: 'Soft-delete a container type' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto';
|
import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto';
|
||||||
import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto';
|
import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto';
|
||||||
@@ -9,12 +10,12 @@ import { PriorityRulesService } from '../services/priority-rules.service';
|
|||||||
|
|
||||||
@ApiTags('priority-rules')
|
@ApiTags('priority-rules')
|
||||||
@Controller('priority-rules')
|
@Controller('priority-rules')
|
||||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class PriorityRulesController {
|
export class PriorityRulesController {
|
||||||
constructor(private readonly service: PriorityRulesService) {}
|
constructor(private readonly service: PriorityRulesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RuleEngineView('priority-rules')
|
||||||
@ApiOperation({ summary: 'List priority rules' })
|
@ApiOperation({ summary: 'List priority rules' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: Record<string, string>) {
|
||||||
return this.service.findAll({
|
return this.service.findAll({
|
||||||
@@ -25,24 +26,28 @@ export class PriorityRulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RuleEngineView('priority-rules')
|
||||||
@ApiOperation({ summary: 'Get a priority rule by ID' })
|
@ApiOperation({ summary: 'Get a priority rule by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RuleEngineManage('priority-rules')
|
||||||
@ApiOperation({ summary: 'Create a priority rule' })
|
@ApiOperation({ summary: 'Create a priority rule' })
|
||||||
create(@Body() dto: CreatePriorityRuleDto) {
|
create(@Body() dto: CreatePriorityRuleDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RuleEngineManage('priority-rules')
|
||||||
@ApiOperation({ summary: 'Update a priority rule' })
|
@ApiOperation({ summary: 'Update a priority rule' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityRuleDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityRuleDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RuleEngineManage('priority-rules')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a priority rule' })
|
@ApiOperation({ summary: 'Soft-delete a priority rule' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query, UseGuards,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||||
import {
|
import {
|
||||||
type AuthUserPayload,
|
type AuthUserPayload,
|
||||||
@@ -15,12 +15,12 @@ import { RatesService } from '../services/rates.service';
|
|||||||
|
|
||||||
@ApiTags('rates')
|
@ApiTags('rates')
|
||||||
@Controller('rates')
|
@Controller('rates')
|
||||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class RatesController {
|
export class RatesController {
|
||||||
constructor(private readonly service: RatesService) {}
|
constructor(private readonly service: RatesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RuleEngineView('rates')
|
||||||
@ApiOperation({ summary: 'List rates' })
|
@ApiOperation({ summary: 'List rates' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: Record<string, string>) {
|
||||||
return this.service.findAll({
|
return this.service.findAll({
|
||||||
@@ -32,19 +32,21 @@ export class RatesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('live')
|
@Get('live')
|
||||||
|
@RuleEngineView('rates')
|
||||||
@ApiOperation({ summary: 'List all LIVE rates effective now' })
|
@ApiOperation({ summary: 'List all LIVE rates effective now' })
|
||||||
findLive() {
|
findLive() {
|
||||||
return this.service.findLiveRates();
|
return this.service.findLiveRates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RuleEngineView('rates')
|
||||||
@ApiOperation({ summary: 'Get a rate by ID' })
|
@ApiOperation({ summary: 'Get a rate by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(JwtGuard)
|
@RuleEngineManage('rates')
|
||||||
@ApiOperation({ summary: 'Create a rate (DRAFT)' })
|
@ApiOperation({ summary: 'Create a rate (DRAFT)' })
|
||||||
create(
|
create(
|
||||||
@Body() dto: CreateRateDto,
|
@Body() dto: CreateRateDto,
|
||||||
@@ -54,19 +56,21 @@ export class RatesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RuleEngineManage('rates')
|
||||||
@ApiOperation({ summary: 'Update a DRAFT rate' })
|
@ApiOperation({ summary: 'Update a DRAFT rate' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRateDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRateDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/submit')
|
@Post(':id/submit')
|
||||||
|
@RuleEngineManage('rates')
|
||||||
@ApiOperation({ summary: 'Submit rate for CEO approval' })
|
@ApiOperation({ summary: 'Submit rate for CEO approval' })
|
||||||
submit(@Param('id', ParseUUIDPipe) id: string) {
|
submit(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.submitForApproval(id);
|
return this.service.submitForApproval(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/approve')
|
@Post(':id/approve')
|
||||||
@UseGuards(JwtGuard)
|
@RuleEngineManage('rates')
|
||||||
@ApiOperation({ summary: 'CEO approves a rate' })
|
@ApiOperation({ summary: 'CEO approves a rate' })
|
||||||
approve(
|
approve(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@@ -76,6 +80,7 @@ export class RatesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RuleEngineManage('rates')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a rate' })
|
@ApiOperation({ summary: 'Soft-delete a rate' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
||||||
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
|
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
|
||||||
@@ -9,12 +10,12 @@ import { ServiceTypesService } from '../services/service-types.service';
|
|||||||
|
|
||||||
@ApiTags('service-types')
|
@ApiTags('service-types')
|
||||||
@Controller('service-types')
|
@Controller('service-types')
|
||||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class ServiceTypesController {
|
export class ServiceTypesController {
|
||||||
constructor(private readonly service: ServiceTypesService) {}
|
constructor(private readonly service: ServiceTypesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RuleEngineView('service-types')
|
||||||
@ApiOperation({ summary: 'List service types' })
|
@ApiOperation({ summary: 'List service types' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: Record<string, string>) {
|
||||||
return this.service.findAll({
|
return this.service.findAll({
|
||||||
@@ -29,24 +30,28 @@ export class ServiceTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RuleEngineView('service-types')
|
||||||
@ApiOperation({ summary: 'Get a service type by ID' })
|
@ApiOperation({ summary: 'Get a service type by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RuleEngineManage('service-types')
|
||||||
@ApiOperation({ summary: 'Create a service type' })
|
@ApiOperation({ summary: 'Create a service type' })
|
||||||
create(@Body() dto: CreateServiceTypeDto) {
|
create(@Body() dto: CreateServiceTypeDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RuleEngineManage('service-types')
|
||||||
@ApiOperation({ summary: 'Update a service type' })
|
@ApiOperation({ summary: 'Update a service type' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateServiceTypeDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateServiceTypeDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RuleEngineManage('service-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a service type' })
|
@ApiOperation({ summary: 'Soft-delete a service type' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
|
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
|
||||||
import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto';
|
import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto';
|
||||||
@@ -9,12 +10,12 @@ import { ShippingLinesService } from '../services/shipping-lines.service';
|
|||||||
|
|
||||||
@ApiTags('shipping-lines')
|
@ApiTags('shipping-lines')
|
||||||
@Controller('shipping-lines')
|
@Controller('shipping-lines')
|
||||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class ShippingLinesController {
|
export class ShippingLinesController {
|
||||||
constructor(private readonly service: ShippingLinesService) {}
|
constructor(private readonly service: ShippingLinesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RuleEngineView('shipping-lines')
|
||||||
@ApiOperation({ summary: 'List shipping lines' })
|
@ApiOperation({ summary: 'List shipping lines' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: Record<string, string>) {
|
||||||
return this.service.findAll({
|
return this.service.findAll({
|
||||||
@@ -25,24 +26,28 @@ export class ShippingLinesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RuleEngineView('shipping-lines')
|
||||||
@ApiOperation({ summary: 'Get a shipping line by ID' })
|
@ApiOperation({ summary: 'Get a shipping line by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RuleEngineManage('shipping-lines')
|
||||||
@ApiOperation({ summary: 'Create a shipping line' })
|
@ApiOperation({ summary: 'Create a shipping line' })
|
||||||
create(@Body() dto: CreateShippingLineDto) {
|
create(@Body() dto: CreateShippingLineDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RuleEngineManage('shipping-lines')
|
||||||
@ApiOperation({ summary: 'Update a shipping line' })
|
@ApiOperation({ summary: 'Update a shipping line' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateShippingLineDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateShippingLineDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RuleEngineManage('shipping-lines')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a shipping line' })
|
@ApiOperation({ summary: 'Soft-delete a shipping line' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto';
|
import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto';
|
||||||
import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto';
|
import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto';
|
||||||
@@ -9,12 +10,12 @@ import { SurchargeTypesService } from '../services/surcharge-types.service';
|
|||||||
|
|
||||||
@ApiTags('surcharge-types')
|
@ApiTags('surcharge-types')
|
||||||
@Controller('surcharge-types')
|
@Controller('surcharge-types')
|
||||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class SurchargeTypesController {
|
export class SurchargeTypesController {
|
||||||
constructor(private readonly service: SurchargeTypesService) {}
|
constructor(private readonly service: SurchargeTypesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RuleEngineView('surcharge-types')
|
||||||
@ApiOperation({ summary: 'List surcharge types' })
|
@ApiOperation({ summary: 'List surcharge types' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: Record<string, string>) {
|
||||||
return this.service.findAll({
|
return this.service.findAll({
|
||||||
@@ -25,24 +26,28 @@ export class SurchargeTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RuleEngineView('surcharge-types')
|
||||||
@ApiOperation({ summary: 'Get a surcharge type by ID' })
|
@ApiOperation({ summary: 'Get a surcharge type by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RuleEngineManage('surcharge-types')
|
||||||
@ApiOperation({ summary: 'Create a surcharge type' })
|
@ApiOperation({ summary: 'Create a surcharge type' })
|
||||||
create(@Body() dto: CreateSurchargeTypeDto) {
|
create(@Body() dto: CreateSurchargeTypeDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RuleEngineManage('surcharge-types')
|
||||||
@ApiOperation({ summary: 'Update a surcharge type' })
|
@ApiOperation({ summary: 'Update a surcharge type' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeTypeDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeTypeDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RuleEngineManage('surcharge-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a surcharge type' })
|
@ApiOperation({ summary: 'Soft-delete a surcharge type' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
|
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
|
||||||
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
|
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
|
||||||
@@ -9,12 +10,12 @@ import { WeightLimitRulesService } from '../services/weight-limit-rules.service'
|
|||||||
|
|
||||||
@ApiTags('weight-limit-rules')
|
@ApiTags('weight-limit-rules')
|
||||||
@Controller('weight-limit-rules')
|
@Controller('weight-limit-rules')
|
||||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class WeightLimitRulesController {
|
export class WeightLimitRulesController {
|
||||||
constructor(private readonly service: WeightLimitRulesService) {}
|
constructor(private readonly service: WeightLimitRulesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RuleEngineView('weight-limit-rules')
|
||||||
@ApiOperation({ summary: 'List weight limit rules' })
|
@ApiOperation({ summary: 'List weight limit rules' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: Record<string, string>) {
|
||||||
return this.service.findAll({
|
return this.service.findAll({
|
||||||
@@ -26,24 +27,28 @@ export class WeightLimitRulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RuleEngineView('weight-limit-rules')
|
||||||
@ApiOperation({ summary: 'Get a weight limit rule by ID' })
|
@ApiOperation({ summary: 'Get a weight limit rule by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RuleEngineManage('weight-limit-rules')
|
||||||
@ApiOperation({ summary: 'Create a weight limit rule' })
|
@ApiOperation({ summary: 'Create a weight limit rule' })
|
||||||
create(@Body() dto: CreateWeightLimitRuleDto) {
|
create(@Body() dto: CreateWeightLimitRuleDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RuleEngineManage('weight-limit-rules')
|
||||||
@ApiOperation({ summary: 'Update a weight limit rule' })
|
@ApiOperation({ summary: 'Update a weight limit rule' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWeightLimitRuleDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWeightLimitRuleDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RuleEngineManage('weight-limit-rules')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a weight limit rule' })
|
@ApiOperation({ summary: 'Soft-delete a weight limit rule' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateYardDto } from '../dto/create-yard.dto';
|
import { CreateYardDto } from '../dto/create-yard.dto';
|
||||||
import { UpdateYardDto } from '../dto/update-yard.dto';
|
import { UpdateYardDto } from '../dto/update-yard.dto';
|
||||||
@@ -9,12 +10,12 @@ import { YardsService } from '../services/yards.service';
|
|||||||
|
|
||||||
@ApiTags('yards')
|
@ApiTags('yards')
|
||||||
@Controller('yards')
|
@Controller('yards')
|
||||||
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class YardsController {
|
export class YardsController {
|
||||||
constructor(private readonly service: YardsService) {}
|
constructor(private readonly service: YardsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RuleEngineView('yards')
|
||||||
@ApiOperation({ summary: 'List yards' })
|
@ApiOperation({ summary: 'List yards' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: Record<string, string>) {
|
||||||
return this.service.findAll({
|
return this.service.findAll({
|
||||||
@@ -26,24 +27,28 @@ export class YardsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RuleEngineView('yards')
|
||||||
@ApiOperation({ summary: 'Get a yard by ID' })
|
@ApiOperation({ summary: 'Get a yard by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.findById(id);
|
return this.service.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RuleEngineManage('yards')
|
||||||
@ApiOperation({ summary: 'Create a yard' })
|
@ApiOperation({ summary: 'Create a yard' })
|
||||||
create(@Body() dto: CreateYardDto) {
|
create(@Body() dto: CreateYardDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RuleEngineManage('yards')
|
||||||
@ApiOperation({ summary: 'Update a yard' })
|
@ApiOperation({ summary: 'Update a yard' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RuleEngineManage('yards')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a yard' })
|
@ApiOperation({ summary: 'Soft-delete a yard' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
IShippingLinesRepository,
|
IShippingLinesRepository,
|
||||||
SHIPPING_LINES_REPOSITORY,
|
SHIPPING_LINES_REPOSITORY,
|
||||||
} from './interfaces/shipping-lines.repository.interface';
|
} from './interfaces/shipping-lines.repository.interface';
|
||||||
|
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
|
||||||
|
|
||||||
export interface BookingContainerEvalInput {
|
export interface BookingContainerEvalInput {
|
||||||
containerTypeId: string;
|
containerTypeId: string;
|
||||||
@@ -238,6 +239,29 @@ export class RuleEngineService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure ITMLS default approval chains exist (container + bulk). Idempotent.
|
||||||
|
*/
|
||||||
|
async ensureDefaultApprovalRules(): Promise<void> {
|
||||||
|
for (const flag of [false, true] as const) {
|
||||||
|
const existing = await this.approvalRulesRepo.findChainForCargo(flag);
|
||||||
|
if (existing.length > 0) continue;
|
||||||
|
|
||||||
|
const rows = DEFAULT_APPROVAL_RULE_ROWS.filter(
|
||||||
|
(r) => r.requiresDirectorApproval === flag,
|
||||||
|
);
|
||||||
|
for (const row of rows) {
|
||||||
|
await this.approvalRulesRepo.create({
|
||||||
|
requiresDirectorApproval: row.requiresDirectorApproval,
|
||||||
|
stepOrder: row.stepOrder,
|
||||||
|
requiredRole: row.requiredRole,
|
||||||
|
actionLabel: row.actionLabel,
|
||||||
|
blocksRole: row.blocksRole,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instantiate booking_approval_step rows from approval_rules by freight type.
|
* Instantiate booking_approval_step rows from approval_rules by freight type.
|
||||||
*/
|
*/
|
||||||
@@ -248,6 +272,8 @@ export class RuleEngineService {
|
|||||||
cargoTypeId?: string | null;
|
cargoTypeId?: string | null;
|
||||||
},
|
},
|
||||||
): Promise<BookingApprovalStep[]> {
|
): Promise<BookingApprovalStep[]> {
|
||||||
|
await this.ensureDefaultApprovalRules();
|
||||||
|
|
||||||
let requiresDirectorApproval = options.freightType === 'BULK';
|
let requiresDirectorApproval = options.freightType === 'BULK';
|
||||||
|
|
||||||
if (options.cargoTypeId) {
|
if (options.cargoTypeId) {
|
||||||
@@ -264,6 +290,12 @@ export class RuleEngineService {
|
|||||||
requiresDirectorApproval,
|
requiresDirectorApproval,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (chain.length === 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
|
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
|
||||||
const steps: BookingApprovalStep[] = [];
|
const steps: BookingApprovalStep[] = [];
|
||||||
|
|
||||||
@@ -273,6 +305,7 @@ export class RuleEngineService {
|
|||||||
approvalRuleId: rule.id,
|
approvalRuleId: rule.id,
|
||||||
stepOrder: rule.stepOrder,
|
stepOrder: rule.stepOrder,
|
||||||
requiredRole: rule.requiredRole,
|
requiredRole: rule.requiredRole,
|
||||||
|
blocksRole: rule.blocksRole ?? null,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
});
|
});
|
||||||
steps.push(await stepRepo.save(step));
|
steps.push(await stepRepo.save(step));
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
BOOKING_RULE_ENGINE_PERMISSIONS,
|
||||||
|
BOOKING_RULE_ENGINE_PERMISSION_KEYS,
|
||||||
|
ROLE_PERMISSION_PRESETS,
|
||||||
|
} from './freight-permissions.registry';
|
||||||
|
|
||||||
export type FreightSeedRole = {
|
export type FreightSeedRole = {
|
||||||
key: string;
|
key: string;
|
||||||
name: { en: string };
|
name: { en: string };
|
||||||
@@ -183,8 +189,11 @@ export const EDR_FREIGHT_PERMISSIONS = [
|
|||||||
...HIERARCHY_POSITION_PERMISSIONS,
|
...HIERARCHY_POSITION_PERMISSIONS,
|
||||||
...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS,
|
...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS,
|
||||||
...POSITION_TYPE_PERMISSIONS,
|
...POSITION_TYPE_PERMISSIONS,
|
||||||
|
...BOOKING_RULE_ENGINE_PERMISSIONS,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from './freight-permissions.registry';
|
||||||
|
|
||||||
export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
|
export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
|
||||||
{
|
{
|
||||||
key: "edr_employee",
|
key: "edr_employee",
|
||||||
@@ -198,11 +207,42 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
|
|||||||
"edr_freight_app:position_types:view",
|
"edr_freight_app:position_types:view",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "edr_line_staff",
|
||||||
|
name: { en: "EDR Line Staff" },
|
||||||
|
permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "edr_director",
|
||||||
|
name: { en: "EDR Director" },
|
||||||
|
permissionKeys: [...ROLE_PERMISSION_PRESETS.director],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "edr_ceo",
|
||||||
|
name: { en: "EDR CEO" },
|
||||||
|
permissionKeys: [...ROLE_PERMISSION_PRESETS.ceo],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "edr_finance",
|
||||||
|
name: { en: "EDR Finance" },
|
||||||
|
permissionKeys: [...ROLE_PERMISSION_PRESETS.finance],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "edr_marketing",
|
||||||
|
name: { en: "EDR Marketing" },
|
||||||
|
permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "edr_org_manager",
|
key: "edr_org_manager",
|
||||||
name: { en: "EDR Org Manager" },
|
name: { en: "EDR Org Manager" },
|
||||||
permissionKeys: [
|
permissionKeys: [
|
||||||
...EDR_FREIGHT_PERMISSIONS.map((permission) => permission.key),
|
...BOOKING_RULE_ENGINE_PERMISSION_KEYS,
|
||||||
|
...EMPLOYEE_REGISTRATION_PERMISSIONS.map((p) => p.key),
|
||||||
|
...ROLE_ASSIGNMENT_PERMISSIONS.map((p) => p.key),
|
||||||
|
...HIERARCHY_UNIT_PERMISSIONS.map((p) => p.key),
|
||||||
|
...HIERARCHY_POSITION_PERMISSIONS.map((p) => p.key),
|
||||||
|
...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS.map((p) => p.key),
|
||||||
|
...POSITION_TYPE_PERMISSIONS.map((p) => p.key),
|
||||||
IAM_PERMISSION_KEYS.createEmployee,
|
IAM_PERMISSION_KEYS.createEmployee,
|
||||||
IAM_PERMISSION_KEYS.deactivateEmployee,
|
IAM_PERMISSION_KEYS.deactivateEmployee,
|
||||||
IAM_PERMISSION_KEYS.activateEmployee,
|
IAM_PERMISSION_KEYS.activateEmployee,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
} from "@tria-plc/iamapi-common";
|
} from "@tria-plc/iamapi-common";
|
||||||
import { DataSource, EntityManager, In } from "typeorm";
|
import { DataSource, EntityManager, In } from "typeorm";
|
||||||
|
|
||||||
|
import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum";
|
||||||
|
import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry";
|
||||||
import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed";
|
import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed";
|
||||||
|
|
||||||
const EDR_ORG_KEY = "edr_freight";
|
const EDR_ORG_KEY = "edr_freight";
|
||||||
@@ -37,6 +39,7 @@ export class EdrOrgSeeder {
|
|||||||
await this.ensureOrganizationConfiguration(manager, organization.id);
|
await this.ensureOrganizationConfiguration(manager, organization.id);
|
||||||
await this.ensureRoles(manager, EDR_FREIGHT_ROLES);
|
await this.ensureRoles(manager, EDR_FREIGHT_ROLES);
|
||||||
await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES);
|
await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES);
|
||||||
|
await this.ensureSuperAdminPermissions(manager);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`);
|
this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`);
|
||||||
@@ -166,4 +169,40 @@ export class EdrOrgSeeder {
|
|||||||
|
|
||||||
this.logger.log(`Ensured ${rolePermissions.length} EDR role-permission links`);
|
this.logger.log(`Ensured ${rolePermissions.length} EDR role-permission links`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensureSuperAdminPermissions(manager: EntityManager) {
|
||||||
|
const role = await manager.getRepository(Role).findOne({
|
||||||
|
where: { key: ERoleKey.SUPER_ADMIN },
|
||||||
|
select: { id: true, key: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Role ${ERoleKey.SUPER_ADMIN} not found; skipping booking/rule-engine super_admin links`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = await manager.getRepository(Permission).find({
|
||||||
|
where: { key: In(BOOKING_RULE_ENGINE_PERMISSION_KEYS) },
|
||||||
|
select: { id: true, key: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!permissions.length) {
|
||||||
|
this.logger.warn('No booking/rule-engine permissions found for super_admin');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.getRepository(RolePermission).upsert(
|
||||||
|
permissions.map((permission) => ({
|
||||||
|
roleId: role.id,
|
||||||
|
permissionId: permission.id,
|
||||||
|
})),
|
||||||
|
{ conflictPaths: { roleId: true, permissionId: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Ensured ${permissions.length} booking+rule-engine permissions on super_admin`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
152
apps/edr-freight-api/src/seed/freight-permissions.registry.ts
Normal file
152
apps/edr-freight-api/src/seed/freight-permissions.registry.ts
Normal 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',
|
||||||
|
}));
|
||||||
127
apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts
Normal file
127
apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts
Normal 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@)');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
@@ -12,6 +13,11 @@ import {
|
|||||||
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
|
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
|
||||||
import LoadingScreen from "./components/LoadingScreen";
|
import LoadingScreen from "./components/LoadingScreen";
|
||||||
import { useAuth } from "./auth/useAuth";
|
import { useAuth } from "./auth/useAuth";
|
||||||
|
import {
|
||||||
|
canAccessBookings,
|
||||||
|
canAccessRuleEngineResource,
|
||||||
|
hasPermission,
|
||||||
|
} from "@/lib/permissions";
|
||||||
import LoginPage from "./pages/auth/LoginPage";
|
import LoginPage from "./pages/auth/LoginPage";
|
||||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||||
@@ -19,7 +25,6 @@ import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
|||||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||||
import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
|
||||||
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
||||||
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
||||||
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
||||||
@@ -29,102 +34,114 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
|
|||||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
import {
|
||||||
|
getCategorySidebarChildren,
|
||||||
|
RULE_ENGINE_RESOURCES,
|
||||||
|
type RuleEngineNavCategory,
|
||||||
|
} from "./pages/ruleEngine/config/resources";
|
||||||
|
import type { RuleEngineResourceSlug } from "./types/rule-engine";
|
||||||
|
|
||||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
const filterRuleEngineChildren = (
|
||||||
{
|
|
||||||
title: "Main menu",
|
|
||||||
mutedTitle: true,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
label: "Overview",
|
|
||||||
href: "/dashboard/overview",
|
|
||||||
icon: <LayoutDashboard />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Booking requests",
|
|
||||||
href: "/dashboard/booking-requests",
|
|
||||||
icon: <FileText />,
|
|
||||||
},
|
|
||||||
...demoItems,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Administration",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
label: "User management",
|
|
||||||
href: "/dashboard/user-management",
|
|
||||||
icon: <Network />,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
label: "Users",
|
|
||||||
href: "/dashboard/user-management/users",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Employees",
|
|
||||||
href: "/dashboard/user-management/employees",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Position Types",
|
|
||||||
href: "/dashboard/user-management/position-types",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Permissions",
|
|
||||||
href: "/dashboard/user-management/permissions",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Roles",
|
|
||||||
href: "/dashboard/user-management/roles",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "File settings",
|
|
||||||
href: "/dashboard/file-settings",
|
|
||||||
icon: <Paperclip />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Dropdown settings",
|
|
||||||
href: "/dashboard/dropdown-settings",
|
|
||||||
icon: <Settings />,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Freight configuration",
|
|
||||||
mutedTitle: true,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
label: "Configuration",
|
|
||||||
href: "/dashboard/configuration",
|
|
||||||
icon: <Boxes />,
|
|
||||||
children: getCategorySidebarChildren("configuration"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Rules",
|
|
||||||
href: "/dashboard/rules",
|
|
||||||
icon: <SlidersHorizontal />,
|
|
||||||
children: getCategorySidebarChildren("rules"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const hasPermission = (
|
|
||||||
user: ReturnType<typeof useAuth>["user"],
|
user: ReturnType<typeof useAuth>["user"],
|
||||||
key: string,
|
category: RuleEngineNavCategory,
|
||||||
) => {
|
): SidebarItem[] =>
|
||||||
if (!user) return false;
|
getCategorySidebarChildren(category).filter((item) => {
|
||||||
if (user.permissions?.some((p) => p.key === key)) return true;
|
const slug = item.href.split("/").pop() as RuleEngineResourceSlug;
|
||||||
|
return canAccessRuleEngineResource(user, slug, "view");
|
||||||
|
});
|
||||||
|
|
||||||
return (user.employee ?? []).some((emp) =>
|
const buildSidebarSections = (
|
||||||
(emp.positions ?? []).some((pos) =>
|
user: ReturnType<typeof useAuth>["user"],
|
||||||
(pos.permissions ?? []).some((p) => p.key === key),
|
demoItems: SidebarItem[],
|
||||||
),
|
): SidebarSection[] => {
|
||||||
);
|
const configurationChildren = filterRuleEngineChildren(user, "configuration");
|
||||||
|
const rulesChildren = filterRuleEngineChildren(user, "rules");
|
||||||
|
|
||||||
|
const mainItems: SidebarItem[] = [
|
||||||
|
{
|
||||||
|
label: "Overview",
|
||||||
|
href: "/dashboard/overview",
|
||||||
|
icon: <LayoutDashboard />,
|
||||||
|
},
|
||||||
|
...(canAccessBookings(user)
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: "Booking requests",
|
||||||
|
href: "/dashboard/booking-requests",
|
||||||
|
icon: <FileText />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...demoItems,
|
||||||
|
];
|
||||||
|
|
||||||
|
const freightConfigItems: SidebarItem[] = [];
|
||||||
|
if (configurationChildren.length) {
|
||||||
|
freightConfigItems.push({
|
||||||
|
label: "Configuration",
|
||||||
|
href: "/dashboard/configuration",
|
||||||
|
icon: <Boxes />,
|
||||||
|
children: configurationChildren,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rulesChildren.length) {
|
||||||
|
freightConfigItems.push({
|
||||||
|
label: "Rules",
|
||||||
|
href: "/dashboard/rules",
|
||||||
|
icon: <SlidersHorizontal />,
|
||||||
|
children: rulesChildren,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: SidebarSection[] = [
|
||||||
|
{ title: "Main menu", mutedTitle: true, items: mainItems },
|
||||||
|
{
|
||||||
|
title: "Administration",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "User management",
|
||||||
|
href: "/dashboard/user-management",
|
||||||
|
icon: <Network />,
|
||||||
|
children: [
|
||||||
|
{ label: "Users", href: "/dashboard/user-management/users" },
|
||||||
|
{ label: "Position Types", href: "/dashboard/user-management/position-types" },
|
||||||
|
{ label: "Permissions", href: "/dashboard/user-management/permissions" },
|
||||||
|
{ label: "Roles", href: "/dashboard/user-management/roles" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "File settings",
|
||||||
|
href: "/dashboard/file-settings",
|
||||||
|
icon: <Paperclip />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dropdown settings",
|
||||||
|
href: "/dashboard/dropdown-settings",
|
||||||
|
icon: <Settings />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (freightConfigItems.length) {
|
||||||
|
sections.push({
|
||||||
|
title: "Freight configuration",
|
||||||
|
mutedTitle: true,
|
||||||
|
items: freightConfigItems,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PermissionRoute = ({
|
||||||
|
allow,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
allow: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) => (allow ? children : <Navigate to="/dashboard/overview" replace />);
|
||||||
|
|
||||||
const DashboardShell = () => {
|
const DashboardShell = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -132,26 +149,14 @@ const DashboardShell = () => {
|
|||||||
|
|
||||||
const demoItems: SidebarItem[] = [
|
const demoItems: SidebarItem[] = [
|
||||||
...(hasPermission(user, "can:demo:user1")
|
...(hasPermission(user, "can:demo:user1")
|
||||||
? [
|
? [{ label: "User1", href: "/dashboard/user1", icon: <Settings /> }]
|
||||||
{
|
|
||||||
label: "User1",
|
|
||||||
href: "/dashboard/user1",
|
|
||||||
icon: <Settings />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
: []),
|
||||||
...(hasPermission(user, "can:demo:user2")
|
...(hasPermission(user, "can:demo:user2")
|
||||||
? [
|
? [{ label: "User2", href: "/dashboard/user2", icon: <Settings /> }]
|
||||||
{
|
|
||||||
label: "User2",
|
|
||||||
href: "/dashboard/user2",
|
|
||||||
icon: <Settings />,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const sidebarSections = buildSidebarSections(demoItems);
|
const sidebarSections = buildSidebarSections(user, demoItems);
|
||||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -169,6 +174,8 @@ const DashboardShell = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ruleEngineSlugs = RULE_ENGINE_RESOURCES.map((r) => r.slug);
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
@@ -185,63 +192,104 @@ const App = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canBookings = canAccessBookings(user);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
|
|
||||||
<Route path="/dashboard" element={<DashboardShell />}>
|
<Route path="/dashboard" element={<DashboardShell />}>
|
||||||
<Route path="overview" element={<OverviewPage />} />
|
<Route path="overview" element={<OverviewPage />} />
|
||||||
|
|
||||||
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
<Route
|
||||||
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
path="booking-requests"
|
||||||
<Route
|
element={
|
||||||
path="booking-requests/:id/contract"
|
<PermissionRoute allow={canBookings}>
|
||||||
element={<BookingContractPage />}
|
<BookingRequestsPage />
|
||||||
/>
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="booking-requests/:id"
|
||||||
|
element={
|
||||||
|
<PermissionRoute allow={canBookings}>
|
||||||
|
<BookingRequestDetailPage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="booking-requests/:id/contract"
|
||||||
|
element={
|
||||||
|
<PermissionRoute allow={canBookings}>
|
||||||
|
<BookingContractPage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="user-management" element={<UserManagementPage />} />
|
<Route path="user-management" element={<UserManagementPage />} />
|
||||||
<Route path="user-management/users" element={<UsersPage />} />
|
<Route path="user-management/users" element={<UsersPage />} />
|
||||||
<Route path="user-management/position-types" element={<PositionTypesPage />} />
|
<Route path="user-management/position-types" element={<PositionTypesPage />} />
|
||||||
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
|
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
||||||
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
<Route path="user-management/roles" element={<RolesPage />} />
|
||||||
<Route path="user-management/roles" element={<RolesPage />} />
|
|
||||||
|
|
||||||
<Route path="file-settings" element={<FileUploadSettingsPage />} />
|
<Route path="file-settings" element={<FileUploadSettingsPage />} />
|
||||||
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
|
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="configuration"
|
path="configuration"
|
||||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
<Route
|
||||||
|
path="configuration/:resource"
|
||||||
|
element={
|
||||||
|
<PermissionRoute
|
||||||
|
allow={ruleEngineSlugs.some((slug) =>
|
||||||
|
canAccessRuleEngineResource(user, slug, "view"),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RuleEngineResourcePage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="rules"
|
path="rules"
|
||||||
element={<Navigate to="/dashboard/rules/priority-rules" replace />}
|
element={<Navigate to="/dashboard/rules/priority-rules" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
|
<Route
|
||||||
|
path="rules/:resource"
|
||||||
|
element={
|
||||||
|
<PermissionRoute
|
||||||
|
allow={ruleEngineSlugs.some((slug) =>
|
||||||
|
canAccessRuleEngineResource(user, slug, "view"),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RuleEngineResourcePage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="rule-engine"
|
path="rule-engine"
|
||||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
|
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
|
||||||
|
|
||||||
<Route path="user1" element={<DemoUser1Page />} />
|
<Route path="user1" element={<DemoUser1Page />} />
|
||||||
<Route path="user2" element={<DemoUser2Page />} />
|
<Route path="user2" element={<DemoUser2Page />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="org-structure"
|
path="org-structure"
|
||||||
element={<Navigate to="/dashboard/user-management" replace />}
|
element={<Navigate to="/dashboard/user-management" replace />}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="org-structure/*"
|
path="org-structure/*"
|
||||||
element={<Navigate to="/dashboard/user-management" replace />}
|
element={<Navigate to="/dashboard/user-management" replace />}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,6 @@ export const verifyMfaRequest = async (payload: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getMeRequest = async () => {
|
export const getMeRequest = async () => {
|
||||||
const response = await api.get<AuthUser>("/auth/me");
|
const response = await api.get<AuthUser>("/me");
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export interface AuthUser {
|
|||||||
name?: LocaleText;
|
name?: LocaleText;
|
||||||
roles?: AuthRole[];
|
roles?: AuthRole[];
|
||||||
permissions?: AuthPermission[];
|
permissions?: AuthPermission[];
|
||||||
|
/** Flat keys from GET /api/me (roles + position permissions). */
|
||||||
|
permissionKeys?: string[];
|
||||||
|
isSuperAdmin?: boolean;
|
||||||
employee?: AuthEmployeeRecord[];
|
employee?: AuthEmployeeRecord[];
|
||||||
hasSetPassword?: boolean;
|
hasSetPassword?: boolean;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { useMemo } from "react";
|
|||||||
import { ShieldCheck } from "lucide-react";
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
|
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
|
||||||
|
import { formatApprovalProgress } from "@/features/bookings/approval-progress";
|
||||||
import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
|
import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
|
||||||
|
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
|
||||||
import { Badge } from "@edr/ui-common";
|
import { Badge } from "@edr/ui-common";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -21,31 +23,32 @@ export function ApprovalStepsCard({ booking }: ApprovalStepsCardProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const nextPending = getNextPendingApprovalStep(steps);
|
const nextPending = getNextPendingApprovalStep(steps);
|
||||||
|
const summary = formatApprovalProgress(booking.status, steps);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-xl border border-amber-200/80 bg-gradient-to-b from-amber-50/40 to-card shadow-sm dark:from-amber-950/20">
|
<div className={cn(bookingSurface.sectionCard, bookingGlass.activeTab)}>
|
||||||
<div className="flex items-center gap-3 border-b border-amber-200/50 bg-amber-50/50 px-5 py-4 dark:bg-amber-950/30">
|
<div className={bookingSurface.sectionHeader}>
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/15 text-amber-800 dark:text-amber-300">
|
<div className={bookingSurface.sectionIcon}>
|
||||||
<ShieldCheck className="size-4" />
|
<ShieldCheck className="size-4" strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-foreground">
|
<h2 className="text-sm font-semibold text-foreground">
|
||||||
Approval chain
|
Approval chain
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Next:{" "}
|
{summary.detail ||
|
||||||
{nextPending
|
(nextPending
|
||||||
? `${nextPending.requiredRole} · step ${nextPending.stepOrder}`
|
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
|
||||||
: steps.length
|
: steps.length
|
||||||
? "All steps complete"
|
? "All steps complete"
|
||||||
: "Accept submission to begin"}
|
: "Accept submission to begin")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-5">
|
<div className="px-5 py-5">
|
||||||
{steps.length === 0 ? (
|
{steps.length === 0 ? (
|
||||||
<p className="rounded-lg border border-dashed border-border bg-muted/20 px-4 py-6 text-center text-sm text-muted-foreground">
|
<p className="rounded-lg border border-dashed border-border/60 bg-muted/10 px-4 py-6 text-center text-sm text-muted-foreground backdrop-blur-sm">
|
||||||
Use <strong className="font-semibold text-foreground">Accept for approval</strong>{" "}
|
Use <strong className="font-semibold text-foreground">Accept for approval</strong>{" "}
|
||||||
in staff actions to instantiate steps.
|
in staff actions to instantiate steps.
|
||||||
</p>
|
</p>
|
||||||
@@ -74,24 +77,31 @@ function StepRow({
|
|||||||
}) {
|
}) {
|
||||||
const statusStyles =
|
const statusStyles =
|
||||||
step.status === "APPROVED"
|
step.status === "APPROVED"
|
||||||
? "bg-emerald-500/15 text-emerald-800 dark:text-emerald-300"
|
? "border-emerald-500/25 bg-emerald-500/10 text-black"
|
||||||
: step.status === "REJECTED"
|
: step.status === "REJECTED"
|
||||||
? "bg-red-500/15 text-red-800 dark:text-red-300"
|
? "bg-red-500/10 text-red-800 dark:text-red-300"
|
||||||
: isNext
|
: isNext
|
||||||
? "bg-amber-500/15 text-amber-800 dark:text-amber-300"
|
? "border-emerald-500/25 bg-emerald-500/10 text-black"
|
||||||
: "bg-muted text-muted-foreground";
|
: "bg-muted/40 text-muted-foreground";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors",
|
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors backdrop-blur-sm",
|
||||||
isNext
|
isNext
|
||||||
? "border-primary/30 bg-primary/[0.03] shadow-sm"
|
? bookingGlass.activeTab
|
||||||
: "border-border/60 bg-card",
|
: "border-border/50 bg-card/60",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-xs font-bold text-muted-foreground">
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex size-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold",
|
||||||
|
isNext
|
||||||
|
? cn(bookingGlass.iconWellGreen, "text-black")
|
||||||
|
: "bg-muted/40 text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{step.stepOrder}
|
{step.stepOrder}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -107,7 +117,7 @@ function StepRow({
|
|||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn("shrink-0 text-[9px] uppercase", statusStyles)}
|
className={cn("shrink-0 border text-[9px] uppercase", statusStyles)}
|
||||||
>
|
>
|
||||||
{step.status}
|
{step.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Loader2,
|
Loader2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Upload,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||||
import { useBookingActionDialog } from "./useBookingActionDialog";
|
import { useBookingActionDialog } from "./useBookingActionDialog";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import {
|
import {
|
||||||
|
getNextPendingApprovalStep,
|
||||||
|
isContractNavAction,
|
||||||
listRowHasActions,
|
listRowHasActions,
|
||||||
type BookingActionContext,
|
type BookingActionContext,
|
||||||
} from "@/features/bookings/booking-actions.config";
|
} from "@/features/bookings/booking-actions.config";
|
||||||
@@ -30,18 +32,23 @@ interface BookingActionsMenuProps {
|
|||||||
/** Compact table cell vs. larger detail toolbar */
|
/** Compact table cell vs. larger detail toolbar */
|
||||||
variant?: "table" | "toolbar";
|
variant?: "table" | "toolbar";
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Suppresses table row navigation after menu/dialog close (click-through). */
|
||||||
|
onSuppressRowClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BookingActionsMenu({
|
export function BookingActionsMenu({
|
||||||
row,
|
row,
|
||||||
variant = "table",
|
variant = "table",
|
||||||
className,
|
className,
|
||||||
|
onSuppressRowClick,
|
||||||
}: BookingActionsMenuProps) {
|
}: BookingActionsMenuProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
const context: BookingActionContext = {
|
const context: BookingActionContext = {
|
||||||
status: row.status,
|
status: row.status,
|
||||||
paymentCurrency: row.paymentCurrency,
|
paymentCurrency: row.paymentCurrency,
|
||||||
reference: row.reference,
|
reference: row.reference,
|
||||||
|
approvalSteps: row.approvalSteps,
|
||||||
};
|
};
|
||||||
|
|
||||||
const flow = useBookingActionDialog(row.id, context);
|
const flow = useBookingActionDialog(row.id, context);
|
||||||
@@ -50,9 +57,7 @@ export function BookingActionsMenu({
|
|||||||
const goToContract = () =>
|
const goToContract = () =>
|
||||||
navigate(`/dashboard/booking-requests/${row.id}/contract`);
|
navigate(`/dashboard/booking-requests/${row.id}/contract`);
|
||||||
|
|
||||||
const showUsdPaymentHint =
|
const hasMenu = listRowHasActions(row, user);
|
||||||
row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD";
|
|
||||||
const hasMenu = listRowHasActions(row) || showUsdPaymentHint;
|
|
||||||
|
|
||||||
const primary = actions.find((a) => a.primary) ?? actions[0];
|
const primary = actions.find((a) => a.primary) ?? actions[0];
|
||||||
|
|
||||||
@@ -73,8 +78,9 @@ export function BookingActionsMenu({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
|
data-stop-row-click
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-end gap-1",
|
"flex w-full min-h-[2.5rem] items-center justify-end gap-1",
|
||||||
variant === "table" && "opacity-80 transition-opacity group-hover/tr:opacity-100",
|
variant === "table" && "opacity-80 transition-opacity group-hover/tr:opacity-100",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -87,7 +93,7 @@ export function BookingActionsMenu({
|
|||||||
className="hidden h-8 gap-1.5 px-2.5 shadow-sm lg:inline-flex"
|
className="hidden h-8 gap-1.5 px-2.5 shadow-sm lg:inline-flex"
|
||||||
disabled={mutations.isPending}
|
disabled={mutations.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
primary.id === "viewContract"
|
isContractNavAction(primary.id)
|
||||||
? goToContract()
|
? goToContract()
|
||||||
: flow.openAction(primary)
|
: flow.openAction(primary)
|
||||||
}
|
}
|
||||||
@@ -119,7 +125,7 @@ export function BookingActionsMenu({
|
|||||||
)}
|
)}
|
||||||
disabled={mutations.isPending}
|
disabled={mutations.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
action.id === "viewContract"
|
isContractNavAction(action.id)
|
||||||
? goToContract()
|
? goToContract()
|
||||||
: flow.openAction(action)
|
: flow.openAction(action)
|
||||||
}
|
}
|
||||||
@@ -164,36 +170,29 @@ export function BookingActionsMenu({
|
|||||||
"gap-2 cursor-pointer",
|
"gap-2 cursor-pointer",
|
||||||
action.variant === "destructive" && "text-red-700 focus:text-red-700",
|
action.variant === "destructive" && "text-red-700 focus:text-red-700",
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onSelect={(event) => {
|
||||||
action.id === "viewContract"
|
event.preventDefault();
|
||||||
? goToContract()
|
onSuppressRowClick?.();
|
||||||
: flow.openAction(action)
|
if (isContractNavAction(action.id)) {
|
||||||
}
|
goToContract();
|
||||||
|
} else {
|
||||||
|
flow.openAction(action);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Icon className="size-4 opacity-70" />
|
<Icon className="size-4 opacity-70" />
|
||||||
<span>{action.label}</span>
|
<span>{action.label}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{showUsdPaymentHint && (
|
{actions.length > 0 && <DropdownMenuSeparator />}
|
||||||
<DropdownMenuItem
|
|
||||||
className="gap-2 cursor-pointer"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(`/dashboard/booking-requests/${row.id}`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Upload className="size-4 opacity-70" />
|
|
||||||
Upload payment proof…
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{(actions.length > 0 || showUsdPaymentHint) && (
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="gap-2 cursor-pointer"
|
className="gap-2 cursor-pointer"
|
||||||
onClick={() =>
|
onSelect={(event) => {
|
||||||
navigate(`/dashboard/booking-requests/${row.id}`)
|
event.preventDefault();
|
||||||
}
|
onSuppressRowClick?.();
|
||||||
|
navigate(`/dashboard/booking-requests/${row.id}`);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ExternalLink className="size-4 opacity-70" />
|
<ExternalLink className="size-4 opacity-70" />
|
||||||
Open full details
|
Open full details
|
||||||
@@ -205,12 +204,20 @@ export function BookingActionsMenu({
|
|||||||
|
|
||||||
<BookingConfirmDialog
|
<BookingConfirmDialog
|
||||||
open={flow.dialogOpen}
|
open={flow.dialogOpen}
|
||||||
onOpenChange={flow.setDialogOpen}
|
onOpenChange={(open) => {
|
||||||
|
if (!open) onSuppressRowClick?.();
|
||||||
|
flow.setDialogOpen(open);
|
||||||
|
}}
|
||||||
action={pendingAction}
|
action={pendingAction}
|
||||||
reference={flow.mergedContext.reference}
|
reference={flow.mergedContext.reference}
|
||||||
inputValue={flow.inputValue}
|
inputValue={flow.inputValue}
|
||||||
onInputChange={flow.setInputValue}
|
onInputChange={flow.setInputValue}
|
||||||
onConfirm={flow.runAction}
|
selectedFile={flow.selectedFile}
|
||||||
|
onFileChange={flow.setSelectedFile}
|
||||||
|
onConfirm={() => {
|
||||||
|
onSuppressRowClick?.();
|
||||||
|
flow.runAction();
|
||||||
|
}}
|
||||||
isPending={mutations.isPending || flow.detailLoading}
|
isPending={mutations.isPending || flow.detailLoading}
|
||||||
confirmDisabled={flow.confirmDisabled}
|
confirmDisabled={flow.confirmDisabled}
|
||||||
extra={
|
extra={
|
||||||
@@ -220,10 +227,10 @@ export function BookingActionsMenu({
|
|||||||
Loading approval steps…
|
Loading approval steps…
|
||||||
</p>
|
</p>
|
||||||
) : pendingAction?.id === "approve" &&
|
) : pendingAction?.id === "approve" &&
|
||||||
!flow.mergedContext.approvalSteps?.length ? (
|
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? (
|
||||||
<p className="rounded-lg border border-amber-200/80 bg-amber-50/50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
|
<p className="rounded-lg border border-amber-200/80 bg-amber-50/50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
No pending approval step found. Accept the submission on the detail
|
No pending approval step. Refresh the page after staff accept, or
|
||||||
page first.
|
reject the booking.
|
||||||
</p>
|
</p>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useRef } from "react";
|
import { Download, Zap } from "lucide-react";
|
||||||
import { Download, Upload, Zap } from "lucide-react";
|
|
||||||
|
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import { BookingActionsMenu } from "./BookingActionsMenu";
|
import { BookingActionsMenu } from "./BookingActionsMenu";
|
||||||
@@ -7,6 +6,7 @@ import { bookingSurface } from "./booking-ui.styles";
|
|||||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||||
import { Button } from "@edr/ui-common";
|
import { Button } from "@edr/ui-common";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||||
|
|
||||||
@@ -15,15 +15,13 @@ interface BookingActionsToolbarProps {
|
|||||||
mutations: Mutations;
|
mutations: Mutations;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Detail-page actions: primary toolbar + payment uploads + downloads. */
|
/** Detail-page actions: primary toolbar + downloads. */
|
||||||
export function BookingActionsToolbar({
|
export function BookingActionsToolbar({
|
||||||
booking,
|
booking,
|
||||||
mutations,
|
mutations,
|
||||||
}: BookingActionsToolbarProps) {
|
}: BookingActionsToolbarProps) {
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
|
||||||
const row = toBookingListRow(booking);
|
const row = toBookingListRow(booking);
|
||||||
const { status, paymentCurrency } = booking;
|
const { status } = booking;
|
||||||
const pending = mutations.isPending;
|
|
||||||
|
|
||||||
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
|
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
|
||||||
const blob = await fn();
|
const blob = await fn();
|
||||||
@@ -47,7 +45,7 @@ export function BookingActionsToolbar({
|
|||||||
return (
|
return (
|
||||||
<PanelShell title="Awaiting customer" description="No staff actions until resubmit.">
|
<PanelShell title="Awaiting customer" description="No staff actions until resubmit.">
|
||||||
{booking.latestChangeRequestNote && (
|
{booking.latestChangeRequestNote && (
|
||||||
<p className="rounded-lg border bg-muted/30 p-3 text-sm leading-relaxed">
|
<p className="rounded-lg border border-border/50 bg-muted/15 p-3 text-sm leading-relaxed backdrop-blur-sm">
|
||||||
{booking.latestChangeRequestNote}
|
{booking.latestChangeRequestNote}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -74,50 +72,11 @@ export function BookingActionsToolbar({
|
|||||||
<BookingActionsMenu row={row} variant="toolbar" />
|
<BookingActionsMenu row={row} variant="toolbar" />
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
|
|
||||||
{status === "FULLY_EXECUTED" && paymentCurrency === "USD" && (
|
|
||||||
<PanelShell title="Payment (USD)" description="Upload proof of payment.">
|
|
||||||
<input
|
|
||||||
ref={fileRef}
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
accept=".pdf,.png,.jpg,.jpeg"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) mutations.submitPaymentProof.mutate(file);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={pending}
|
|
||||||
className="gap-2"
|
|
||||||
onClick={() => fileRef.current?.click()}
|
|
||||||
>
|
|
||||||
<Upload className="size-4" />
|
|
||||||
Upload payment proof
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="gap-2"
|
|
||||||
onClick={() =>
|
|
||||||
downloadBlob(
|
|
||||||
() => mutations.downloadPaymentLetter(),
|
|
||||||
`payment-letter-${booking.reference}.txt`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Download className="size-4" />
|
|
||||||
Request letter
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</PanelShell>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status === "CONTRACT_READY" && (
|
{status === "CONTRACT_READY" && (
|
||||||
<PanelShell title="Documents" description="Download generated contract.">
|
<PanelShell title="Documents" description="Download generated contract.">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-2"
|
className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
downloadBlob(
|
downloadBlob(
|
||||||
() => mutations.downloadContract(),
|
() => mutations.downloadContract(),
|
||||||
@@ -146,16 +105,10 @@ function PanelShell({
|
|||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn(bookingSurface.sectionCard, !muted && "ring-0")}>
|
||||||
className={
|
|
||||||
muted
|
|
||||||
? bookingSurface.sectionCard
|
|
||||||
: `${bookingSurface.sectionCard} ring-1 ring-primary/10`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={bookingSurface.sectionHeader}>
|
<div className={bookingSurface.sectionHeader}>
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
<div className={bookingSurface.sectionIcon}>
|
||||||
<Zap className="size-4" />
|
<Zap className="size-4" strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ interface BookingConfirmDialogProps {
|
|||||||
reference?: string;
|
reference?: string;
|
||||||
inputValue: string;
|
inputValue: string;
|
||||||
onInputChange: (value: string) => void;
|
onInputChange: (value: string) => void;
|
||||||
|
selectedFile?: File | null;
|
||||||
|
onFileChange?: (file: File | null) => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
confirmDisabled?: boolean;
|
confirmDisabled?: boolean;
|
||||||
@@ -33,6 +35,8 @@ export function BookingConfirmDialog({
|
|||||||
reference,
|
reference,
|
||||||
inputValue,
|
inputValue,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
|
selectedFile = null,
|
||||||
|
onFileChange,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
isPending,
|
isPending,
|
||||||
confirmDisabled = false,
|
confirmDisabled = false,
|
||||||
@@ -41,13 +45,25 @@ export function BookingConfirmDialog({
|
|||||||
if (!action || !action.confirmTitle) return null;
|
if (!action || !action.confirmTitle) return null;
|
||||||
|
|
||||||
const Icon = action.icon;
|
const Icon = action.icon;
|
||||||
const needsInput = Boolean(action.input);
|
const needsTextInput =
|
||||||
const inputMissing = needsInput && !inputValue.trim();
|
action.input === "note" || action.input === "reason";
|
||||||
|
const needsFileInput = action.input === "file";
|
||||||
|
const inputMissing =
|
||||||
|
(needsTextInput && !inputValue.trim()) ||
|
||||||
|
(needsFileInput && !selectedFile);
|
||||||
const isDestructive = action.variant === "destructive";
|
const isDestructive = action.variant === "destructive";
|
||||||
|
|
||||||
|
const preventClickThrough = (event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-md">
|
<DialogContent
|
||||||
|
className="gap-0 overflow-hidden p-0 sm:max-w-md"
|
||||||
|
showCloseButton={false}
|
||||||
|
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b px-6 py-5",
|
"border-b px-6 py-5",
|
||||||
@@ -86,7 +102,7 @@ export function BookingConfirmDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 px-6 py-5">
|
<div className="space-y-4 px-6 py-5">
|
||||||
{needsInput && (
|
{needsTextInput && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{action.inputLabel}
|
{action.inputLabel}
|
||||||
@@ -101,6 +117,27 @@ export function BookingConfirmDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{needsFileInput && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{action.inputLabel ?? "Bank slip file"}
|
||||||
|
<span className="text-red-600"> *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.png,.jpg,.jpeg"
|
||||||
|
className="block w-full text-sm text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-2 file:text-xs file:font-semibold file:text-primary-foreground"
|
||||||
|
onChange={(e) =>
|
||||||
|
onFileChange?.(e.target.files?.[0] ?? null)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{selectedFile && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Selected: {selectedFile.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{extra}
|
{extra}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -109,6 +146,7 @@ export function BookingConfirmDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
|
onMouseDown={preventClickThrough}
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -118,6 +156,7 @@ export function BookingConfirmDialog({
|
|||||||
variant={isDestructive ? "destructive" : "default"}
|
variant={isDestructive ? "destructive" : "default"}
|
||||||
disabled={isPending || inputMissing || confirmDisabled}
|
disabled={isPending || inputMissing || confirmDisabled}
|
||||||
className="min-w-[7rem] gap-2"
|
className="min-w-[7rem] gap-2"
|
||||||
|
onMouseDown={preventClickThrough}
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Banknote, Receipt } from "lucide-react";
|
|||||||
|
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import { Separator } from "@edr/ui-common";
|
import { Separator } from "@edr/ui-common";
|
||||||
import { bookingSurface } from "./booking-ui.styles";
|
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
|
||||||
|
|
||||||
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
||||||
const amount = Number(booking.totalAmount);
|
const amount = Number(booking.totalAmount);
|
||||||
@@ -11,8 +11,8 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
|||||||
return (
|
return (
|
||||||
<div className={bookingSurface.sectionCard}>
|
<div className={bookingSurface.sectionCard}>
|
||||||
<div className={bookingSurface.sectionHeader}>
|
<div className={bookingSurface.sectionHeader}>
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-700 dark:text-emerald-400">
|
<div className={bookingSurface.sectionIcon}>
|
||||||
<Banknote className="size-4" />
|
<Banknote className="size-4" strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-foreground">
|
<h2 className="text-sm font-semibold text-foreground">
|
||||||
@@ -22,11 +22,11 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 px-5 py-5">
|
<div className="space-y-4 px-5 py-5">
|
||||||
<div className="rounded-xl border border-primary/15 bg-gradient-to-br from-primary/[0.06] to-transparent p-4">
|
<div className={bookingSurface.valueCard}>
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
Total amount
|
Total amount
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 font-mono text-2xl font-bold tracking-tight text-foreground">
|
<p className="mt-1 font-mono text-2xl font-semibold tabular-nums tracking-tight text-foreground">
|
||||||
{booking.paymentCurrency}{" "}
|
{booking.paymentCurrency}{" "}
|
||||||
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||||
</p>
|
</p>
|
||||||
@@ -35,8 +35,8 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
|||||||
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
|
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
|
||||||
{modifiers.length > 0 && (
|
{modifiers.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator className="opacity-50" />
|
||||||
<p className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
<p className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
<Receipt className="size-3" />
|
<Receipt className="size-3" />
|
||||||
Surcharges applied
|
Surcharges applied
|
||||||
</p>
|
</p>
|
||||||
@@ -44,7 +44,7 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
|||||||
{modifiers.map((m) => (
|
{modifiers.map((m) => (
|
||||||
<li
|
<li
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="flex justify-between rounded-lg border border-border/60 bg-muted/20 px-3 py-2 text-sm"
|
className="flex justify-between rounded-lg border border-border/50 bg-muted/15 px-3 py-2 text-sm backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<span className="text-muted-foreground">Modifier</span>
|
<span className="text-muted-foreground">Modifier</span>
|
||||||
<span className="font-mono font-semibold tabular-nums">
|
<span className="font-mono font-semibold tabular-nums">
|
||||||
@@ -70,7 +70,7 @@ function Row({
|
|||||||
mono?: boolean;
|
mono?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2 text-sm">
|
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/40 bg-muted/10 px-3 py-2.5 text-sm backdrop-blur-sm">
|
||||||
<span className="text-muted-foreground">{label}</span>
|
<span className="text-muted-foreground">{label}</span>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { bookingGlass } from "./booking-ui.styles";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface StatItem {
|
export interface StatItem {
|
||||||
@@ -9,43 +10,49 @@ export interface StatItem {
|
|||||||
accent?: "default" | "amber" | "emerald" | "rose";
|
accent?: "default" | "amber" | "emerald" | "rose";
|
||||||
}
|
}
|
||||||
|
|
||||||
const accentStyles = {
|
const iconAccentStyles = {
|
||||||
default: "bg-primary/10 text-primary",
|
default: "text-foreground/70",
|
||||||
amber: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
|
amber: "text-amber-600 dark:text-amber-400",
|
||||||
emerald: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
|
emerald: "text-emerald-600 dark:text-emerald-400",
|
||||||
rose: "bg-rose-500/10 text-rose-700 dark:text-rose-400",
|
rose: "text-rose-600 dark:text-rose-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BookingStatGrid({ items }: { items: StatItem[] }) {
|
export function BookingStatGrid({ items }: { items: StatItem[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const accent = item.accent ?? "default";
|
const accent = item.accent ?? "default";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.label}
|
key={item.label}
|
||||||
className="group relative overflow-hidden rounded-xl border border-border bg-card p-5 shadow-sm transition-all duration-200 hover:border-primary/20 hover:shadow-md"
|
className={cn(
|
||||||
|
"group relative overflow-hidden rounded-xl p-5 transition-all duration-200 hover:shadow-md",
|
||||||
|
bookingGlass.card,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{item.label}
|
{item.label}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-3xl font-bold tabular-nums tracking-tight text-foreground">
|
<p className="mt-2 text-3xl font-semibold tabular-nums tracking-tight text-foreground">
|
||||||
{item.value}
|
{item.value}
|
||||||
</p>
|
</p>
|
||||||
{item.hint && (
|
{item.hint && (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{item.hint}</p>
|
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{item.hint}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-11 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-105",
|
"flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-[1.02]",
|
||||||
accentStyles[accent],
|
bookingGlass.iconWellGreen,
|
||||||
|
iconAccentStyles[accent],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="size-5" strokeWidth={2} />
|
<Icon className="size-[18px]" strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,27 +1,34 @@
|
|||||||
import {
|
import {
|
||||||
|
CheckCircle,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
FileText,
|
|
||||||
Inbox,
|
Inbox,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
ShieldCheck,
|
Train,
|
||||||
|
Wallet,
|
||||||
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BOOKING_LIST_TABS,
|
BOOKING_LIST_TABS,
|
||||||
type BookingStatusTabKey,
|
type BookingStatusTabKey,
|
||||||
} from "@/features/bookings/booking-status.config";
|
} from "@/features/bookings/booking-status.config";
|
||||||
|
import { bookingGlass } from "./booking-ui.styles";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
|
const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
|
||||||
all: <LayoutGrid className="size-4" />,
|
all: <LayoutGrid className="size-3.5" strokeWidth={1.75} />,
|
||||||
SUBMITTED: <Inbox className="size-4" />,
|
intake: <Inbox className="size-3.5" strokeWidth={1.75} />,
|
||||||
PENDING_APPROVAL: <ClipboardCheck className="size-4" />,
|
in_approval: <ClipboardCheck className="size-3.5" strokeWidth={1.75} />,
|
||||||
APPROVED_PENDING_SIGNATURE: <FileSignature className="size-4" />,
|
approved_contract: <FileSignature className="size-3.5" strokeWidth={1.75} />,
|
||||||
SIGNED_CUSTOMER: <FileText className="size-4" />,
|
payment: <Wallet className="size-3.5" strokeWidth={1.75} />,
|
||||||
PAYMENT_VERIFICATION_IN_PROGRESS: <ShieldCheck className="size-4" />,
|
operations: <Train className="size-3.5" strokeWidth={1.75} />,
|
||||||
|
completed: <CheckCircle className="size-3.5" strokeWidth={1.75} />,
|
||||||
|
closed: <XCircle className="size-3.5" strokeWidth={1.75} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const activeTabText = "text-black";
|
||||||
|
|
||||||
interface BookingStatusTabsProps {
|
interface BookingStatusTabsProps {
|
||||||
active: BookingStatusTabKey;
|
active: BookingStatusTabKey;
|
||||||
onChange: (tab: BookingStatusTabKey) => void;
|
onChange: (tab: BookingStatusTabKey) => void;
|
||||||
@@ -34,9 +41,9 @@ export function BookingStatusTabs({
|
|||||||
counts,
|
counts,
|
||||||
}: BookingStatusTabsProps) {
|
}: BookingStatusTabsProps) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-muted/30 p-1.5">
|
<div className={bookingGlass.tabRail}>
|
||||||
<div
|
<div
|
||||||
className="flex gap-1 overflow-x-auto pb-0.5 scrollbar-thin"
|
className="flex flex-nowrap gap-1.5 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label="Booking status filters"
|
aria-label="Booking status filters"
|
||||||
>
|
>
|
||||||
@@ -51,29 +58,38 @@ export function BookingStatusTabs({
|
|||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
onClick={() => onChange(tab.key)}
|
onClick={() => onChange(tab.key)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-w-[7.5rem] shrink-0 flex-col items-start gap-0.5 rounded-lg px-3.5 py-2.5 text-left transition-all duration-200",
|
"flex min-w-[8rem] shrink-0 flex-col items-start gap-0.5 rounded-lg px-3 py-2.5 text-left transition-all duration-200",
|
||||||
isActive
|
isActive
|
||||||
? "bg-background text-foreground shadow-sm ring-1 ring-border/80"
|
? bookingGlass.activeTab
|
||||||
: "text-muted-foreground hover:bg-background/60 hover:text-foreground",
|
: "text-muted-foreground hover:bg-emerald-500/5 hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex w-full items-center justify-between gap-2">
|
<span className="flex w-full items-center justify-between gap-2">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 text-sm font-semibold",
|
"flex items-center gap-2 text-sm font-medium",
|
||||||
isActive && "text-primary",
|
isActive ? activeTabText : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{TAB_ICONS[tab.key]}
|
<span
|
||||||
{tab.label}
|
className={cn(
|
||||||
|
"flex size-7 shrink-0 items-center justify-center rounded-md",
|
||||||
|
isActive
|
||||||
|
? cn(bookingGlass.iconWellGreen, "text-black")
|
||||||
|
: "border border-transparent bg-muted/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{TAB_ICONS[tab.key]}
|
||||||
|
</span>
|
||||||
|
<span className="whitespace-nowrap">{tab.label}</span>
|
||||||
</span>
|
</span>
|
||||||
{count !== undefined && count > 0 && (
|
{count !== undefined && count > 0 && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-2 py-0.5 text-[10px] font-bold tabular-nums",
|
"rounded-full px-2 py-0.5 text-[10px] font-semibold tabular-nums",
|
||||||
isActive
|
isActive
|
||||||
? "bg-primary/15 text-primary"
|
? cn("bg-emerald-500/15", activeTabText)
|
||||||
: "bg-muted text-muted-foreground",
|
: "bg-muted/50 text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{count}
|
{count}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Package, Search } from "lucide-react";
|
import { Package, Search } from "lucide-react";
|
||||||
import { Button } from "@edr/ui-common";
|
import { Button } from "@edr/ui-common";
|
||||||
import { bookingSurface } from "./booking-ui.styles";
|
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface BookingTableEmptyProps {
|
interface BookingTableEmptyProps {
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
@@ -15,7 +16,12 @@ export function BookingTableEmpty({
|
|||||||
}: BookingTableEmptyProps) {
|
}: BookingTableEmptyProps) {
|
||||||
return (
|
return (
|
||||||
<div className={bookingSurface.emptyState}>
|
<div className={bookingSurface.emptyState}>
|
||||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-14 items-center justify-center rounded-2xl text-muted-foreground",
|
||||||
|
bookingGlass.iconWellGreen,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{hasSearch ? <Search className="size-6" /> : <Package className="size-6" />}
|
{hasSearch ? <Search className="size-6" /> : <Package className="size-6" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-sm space-y-1">
|
<div className="max-w-sm space-y-1">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
getWorkflowStageIndex,
|
getWorkflowStageIndex,
|
||||||
WORKFLOW_STAGES,
|
WORKFLOW_STAGES,
|
||||||
} from "@/features/bookings/booking-status.config";
|
} from "@/features/bookings/booking-status.config";
|
||||||
import { bookingSurface } from "./booking-ui.styles";
|
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
|
||||||
|
|
||||||
const STAGE_ICONS = [FileText, FileSignature, FileSignature, Wallet, Train, Check];
|
const STAGE_ICONS = [FileText, FileSignature, FileSignature, Wallet, Train, Check];
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ export function BookingWorkflowStepper({
|
|||||||
return (
|
return (
|
||||||
<div className={bookingSurface.sectionCard}>
|
<div className={bookingSurface.sectionCard}>
|
||||||
<div className={bookingSurface.sectionHeader}>
|
<div className={bookingSurface.sectionHeader}>
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
<div className={bookingSurface.sectionIcon}>
|
||||||
<Train className="size-4" />
|
<Train className="size-4" strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-foreground">
|
<h2 className="text-sm font-semibold text-foreground">
|
||||||
@@ -49,9 +49,9 @@ export function BookingWorkflowStepper({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-8 px-5 py-6">
|
<div className="space-y-8 px-5 py-6">
|
||||||
<div className="relative px-2">
|
<div className="relative px-2">
|
||||||
<div className="absolute left-4 right-4 top-5 h-0.5 bg-border" />
|
<div className="absolute left-4 right-4 top-5 h-px bg-border/60" />
|
||||||
<div
|
<div
|
||||||
className="absolute left-4 top-5 h-0.5 bg-primary transition-all duration-700 ease-out"
|
className="absolute left-4 top-5 h-px bg-emerald-500/40 transition-all duration-700 ease-out"
|
||||||
style={{
|
style={{
|
||||||
width:
|
width:
|
||||||
!isTerminal && currentStage >= 0
|
!isTerminal && currentStage >= 0
|
||||||
@@ -71,14 +71,17 @@ export function BookingWorkflowStepper({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-10 items-center justify-center rounded-full border-2 bg-card transition-all duration-300",
|
"flex size-10 items-center justify-center rounded-full border-2 bg-card/80 backdrop-blur-sm transition-all duration-300",
|
||||||
isCompleted &&
|
isCompleted &&
|
||||||
"border-primary bg-primary text-primary-foreground shadow-sm",
|
cn(bookingGlass.iconWellGreen, "border-emerald-500/30 text-black"),
|
||||||
isActive &&
|
isActive &&
|
||||||
"scale-110 border-primary bg-background text-primary shadow-md ring-4 ring-primary/15",
|
cn(
|
||||||
|
bookingGlass.activeTab,
|
||||||
|
"scale-105 border-emerald-500/30 text-black shadow-sm",
|
||||||
|
),
|
||||||
!isCompleted &&
|
!isCompleted &&
|
||||||
!isActive &&
|
!isActive &&
|
||||||
"border-border text-muted-foreground",
|
"border-border/60 text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isCompleted ? (
|
{isCompleted ? (
|
||||||
@@ -89,8 +92,8 @@ export function BookingWorkflowStepper({
|
|||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-center text-[10px] font-bold uppercase leading-tight tracking-wide",
|
"text-center text-[10px] font-semibold uppercase leading-tight tracking-wide",
|
||||||
isActive ? "text-primary" : "text-muted-foreground",
|
isActive ? "text-black" : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{stage.label}
|
{stage.label}
|
||||||
@@ -103,14 +106,17 @@ export function BookingWorkflowStepper({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl border px-5 py-4",
|
"rounded-xl border px-5 py-4 backdrop-blur-sm",
|
||||||
isTerminal
|
isTerminal
|
||||||
? "border-destructive/20 bg-destructive/5"
|
? "border-destructive/20 bg-destructive/5"
|
||||||
: "border-primary/15 bg-primary/[0.04]",
|
: bookingGlass.activeTab,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<h4
|
<h4
|
||||||
className={cn("text-sm font-bold tracking-tight", titleColor)}
|
className={cn(
|
||||||
|
"text-sm font-semibold tracking-tight",
|
||||||
|
isTerminal ? titleColor : "text-black",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h4>
|
</h4>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +1,63 @@
|
|||||||
/** Shared surfaces for booking list & detail — aligned with rule-engine polish. */
|
/** Shared surfaces for booking list & detail — frosted glass, neutral accents. */
|
||||||
|
|
||||||
|
export const bookingGlass = {
|
||||||
|
card:
|
||||||
|
"border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
|
||||||
|
panel:
|
||||||
|
"border border-border/50 bg-card/80 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/65",
|
||||||
|
rail:
|
||||||
|
"border border-border/40 bg-muted/20 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/15",
|
||||||
|
iconWell:
|
||||||
|
"border border-border/50 bg-background/70 text-foreground/75 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-background/50",
|
||||||
|
iconWellHero:
|
||||||
|
"border border-border/50 bg-background/60 text-foreground shadow-sm ring-1 ring-border/30 backdrop-blur-md supports-[backdrop-filter]:bg-background/45",
|
||||||
|
activeTab:
|
||||||
|
"border border-emerald-500/20 bg-emerald-500/10 shadow-sm backdrop-blur-md ring-1 ring-emerald-500/10 supports-[backdrop-filter]:bg-emerald-500/[0.08]",
|
||||||
|
iconWellGreen:
|
||||||
|
"border border-emerald-500/20 bg-emerald-500/15 text-emerald-700 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-emerald-500/10 dark:text-emerald-400",
|
||||||
|
tabRail:
|
||||||
|
"rounded-xl border border-border/60 bg-muted/10 p-2 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/5",
|
||||||
|
tableHeader:
|
||||||
|
"bg-muted/30 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/20",
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const bookingSurface = {
|
export const bookingSurface = {
|
||||||
page: "min-h-screen bg-gradient-to-b from-muted/40 via-background to-background",
|
page:
|
||||||
pageInner: "mx-auto max-w-[1600px] space-y-6 p-6 lg:p-8",
|
"min-h-screen bg-gradient-to-b from-muted/30 via-background to-background",
|
||||||
hero:
|
pageInner: "mx-auto max-w-[1600px] space-y-5 p-6 lg:p-8",
|
||||||
"relative overflow-hidden rounded-2xl border border-border/80 bg-card shadow-sm",
|
hero: `relative overflow-hidden rounded-2xl ${bookingGlass.card}`,
|
||||||
heroGlow:
|
heroGlow:
|
||||||
"pointer-events-none absolute -right-20 -top-20 size-64 rounded-full bg-primary/10 blur-3xl",
|
"pointer-events-none absolute -right-24 -top-24 size-72 rounded-full bg-muted/40 blur-3xl",
|
||||||
panel:
|
heroSheen:
|
||||||
"overflow-hidden rounded-xl border border-border bg-card shadow-sm",
|
"pointer-events-none absolute inset-0 bg-gradient-to-br from-background/40 via-transparent to-muted/20",
|
||||||
panelToolbar:
|
panel: `overflow-hidden rounded-xl ${bookingGlass.panel}`,
|
||||||
"flex flex-wrap items-center justify-between gap-3 border-b border-border bg-muted/25 px-4 py-3.5 sm:px-5",
|
panelToolbar: `flex flex-wrap items-center justify-between gap-3 border-b border-border/50 px-4 py-3.5 sm:px-5 ${bookingGlass.rail}`,
|
||||||
tableWrap: "px-0",
|
tableWrap: "px-0",
|
||||||
sectionCard:
|
sectionCard: `overflow-hidden rounded-xl transition-shadow duration-200 hover:shadow-md ${bookingGlass.card}`,
|
||||||
"overflow-hidden rounded-xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md",
|
|
||||||
sectionHeader:
|
sectionHeader:
|
||||||
"flex items-center gap-3 border-b border-border/60 bg-muted/20 px-5 py-4",
|
"flex items-center gap-3 border-b border-border/50 bg-muted/15 px-5 py-4 backdrop-blur-sm",
|
||||||
sectionBody: "px-5 py-5",
|
sectionBody: "px-5 py-5",
|
||||||
detailHero:
|
detailHero: `relative overflow-hidden rounded-2xl ${bookingGlass.card}`,
|
||||||
"relative overflow-hidden rounded-2xl border border-border bg-gradient-to-br from-card via-card to-primary/[0.04] shadow-sm",
|
sectionIcon: `flex size-9 shrink-0 items-center justify-center rounded-lg ${bookingGlass.iconWellGreen}`,
|
||||||
|
sectionIconLg: `flex size-11 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
|
||||||
|
valueCard:
|
||||||
|
"rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-emerald-500/[0.08]",
|
||||||
stickySidebar: "lg:sticky lg:top-6 lg:self-start",
|
stickySidebar: "lg:sticky lg:top-6 lg:self-start",
|
||||||
metricTile:
|
metricTile:
|
||||||
"rounded-lg border border-border/70 bg-background/80 px-4 py-3 shadow-xs",
|
"rounded-lg border border-border/50 bg-background/70 px-4 py-3 shadow-xs backdrop-blur-sm",
|
||||||
emptyState:
|
emptyState:
|
||||||
"flex flex-col items-center justify-center gap-3 px-6 py-16 text-center",
|
"flex flex-col items-center justify-center gap-3 px-6 py-16 text-center",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const bookingInput = {
|
export const bookingInput = {
|
||||||
search:
|
search:
|
||||||
"h-10 w-full rounded-lg border border-input bg-background pl-10 text-sm shadow-xs transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25 sm:max-w-xs",
|
"h-10 w-full rounded-lg border border-border/60 bg-background/80 pl-10 text-sm shadow-xs backdrop-blur-sm transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring/60 focus-visible:ring-[3px] focus-visible:ring-ring/20 sm:max-w-xs",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const bookingTable = {
|
||||||
|
headerCell:
|
||||||
|
"h-11 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground",
|
||||||
|
rowHover:
|
||||||
|
"transition-colors hover:bg-muted/25 data-[state=selected]:bg-muted/30",
|
||||||
|
rowIcon: `flex size-10 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type BookingActionContext,
|
type BookingActionContext,
|
||||||
type BookingActionDef,
|
type BookingActionDef,
|
||||||
} from "@/features/bookings/booking-actions.config";
|
} from "@/features/bookings/booking-actions.config";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
|
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||||
|
|
||||||
export function useBookingActionDialog(
|
export function useBookingActionDialog(
|
||||||
@@ -14,13 +15,18 @@ export function useBookingActionDialog(
|
|||||||
) {
|
) {
|
||||||
const [pendingAction, setPendingAction] = useState<BookingActionDef | null>(null);
|
const [pendingAction, setPendingAction] = useState<BookingActionDef | null>(null);
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
const needsApprovalSteps =
|
const needsApprovalSteps =
|
||||||
pendingAction?.id === "approve" || pendingAction?.id === "rejectApproval";
|
pendingAction?.id === "approve" || pendingAction?.id === "rejectApproval";
|
||||||
|
|
||||||
|
const needsApprovalContext =
|
||||||
|
context.status === "PENDING_APPROVAL" ||
|
||||||
|
context.status === "APPROVED_PENDING_SIGNATURE";
|
||||||
|
|
||||||
const { data: detail, isLoading: detailLoading } = useBookingDetail(
|
const { data: detail, isLoading: detailLoading } = useBookingDetail(
|
||||||
needsApprovalSteps ? bookingId : undefined,
|
needsApprovalSteps || needsApprovalContext ? bookingId : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mergedContext: BookingActionContext = {
|
const mergedContext: BookingActionContext = {
|
||||||
@@ -29,12 +35,14 @@ export function useBookingActionDialog(
|
|||||||
reference: detail?.reference ?? context.reference,
|
reference: detail?.reference ?? context.reference,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { user } = useAuth();
|
||||||
const mutations = useBookingMutations(bookingId);
|
const mutations = useBookingMutations(bookingId);
|
||||||
const actions = getBookingActions(mergedContext);
|
const actions = getBookingActions(mergedContext, user);
|
||||||
|
|
||||||
const openAction = useCallback((action: BookingActionDef) => {
|
const openAction = useCallback((action: BookingActionDef) => {
|
||||||
setPendingAction(action);
|
setPendingAction(action);
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
|
setSelectedFile(null);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -42,6 +50,7 @@ export function useBookingActionDialog(
|
|||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
|
setSelectedFile(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const runAction = useCallback(() => {
|
const runAction = useCallback(() => {
|
||||||
@@ -82,11 +91,8 @@ export function useBookingActionDialog(
|
|||||||
break;
|
break;
|
||||||
case "viewContract":
|
case "viewContract":
|
||||||
break;
|
break;
|
||||||
case "generatePnr":
|
case "payBooking":
|
||||||
mutations.generatePnr.mutate(undefined, { onSuccess });
|
mutations.payBooking.mutate(undefined, { onSuccess });
|
||||||
break;
|
|
||||||
case "verifyPayment":
|
|
||||||
mutations.verifyPayment.mutate(undefined, { onSuccess });
|
|
||||||
break;
|
break;
|
||||||
case "startTransit":
|
case "startTransit":
|
||||||
mutations.startTransit.mutate(undefined, { onSuccess });
|
mutations.startTransit.mutate(undefined, { onSuccess });
|
||||||
@@ -94,12 +100,16 @@ export function useBookingActionDialog(
|
|||||||
case "complete":
|
case "complete":
|
||||||
mutations.complete.mutate(undefined, { onSuccess });
|
mutations.complete.mutate(undefined, { onSuccess });
|
||||||
break;
|
break;
|
||||||
|
case "cancel":
|
||||||
|
mutations.cancel.mutate(inputValue.trim(), { onSuccess });
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
pendingAction,
|
pendingAction,
|
||||||
inputValue,
|
inputValue,
|
||||||
|
selectedFile,
|
||||||
mergedContext.approvalSteps,
|
mergedContext.approvalSteps,
|
||||||
mutations,
|
mutations,
|
||||||
closeDialog,
|
closeDialog,
|
||||||
@@ -109,13 +119,18 @@ export function useBookingActionDialog(
|
|||||||
mutations.isPending ||
|
mutations.isPending ||
|
||||||
(needsApprovalSteps && detailLoading) ||
|
(needsApprovalSteps && detailLoading) ||
|
||||||
(pendingAction?.id === "approve" &&
|
(pendingAction?.id === "approve" &&
|
||||||
!getNextPendingApprovalStep(mergedContext.approvalSteps));
|
!getNextPendingApprovalStep(mergedContext.approvalSteps)) ||
|
||||||
|
(pendingAction?.input === "file" && !selectedFile) ||
|
||||||
|
(pendingAction?.input === "reason" && !inputValue.trim()) ||
|
||||||
|
(pendingAction?.input === "note" && !inputValue.trim());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actions,
|
actions,
|
||||||
pendingAction,
|
pendingAction,
|
||||||
inputValue,
|
inputValue,
|
||||||
setInputValue,
|
setInputValue,
|
||||||
|
selectedFile,
|
||||||
|
setSelectedFile,
|
||||||
dialogOpen,
|
dialogOpen,
|
||||||
setDialogOpen: (open: boolean) => {
|
setDialogOpen: (open: boolean) => {
|
||||||
if (!open) closeDialog();
|
if (!open) closeDialog();
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ export interface RuleEngineCardGridProps {
|
|||||||
pageCount: number;
|
pageCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
};
|
};
|
||||||
onEdit: (record: RuleEngineRecord) => void;
|
onEdit?: (record: RuleEngineRecord) => void;
|
||||||
onDelete: (record: RuleEngineRecord) => void;
|
onDelete?: (record: RuleEngineRecord) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
onViewChain?: () => void;
|
onViewChain?: () => void;
|
||||||
onSubmitRate?: (id: string) => void;
|
onSubmitRate?: (id: string) => void;
|
||||||
onApproveRate?: (record: RuleEngineRecord) => void;
|
onApproveRate?: (record: RuleEngineRecord) => void;
|
||||||
@@ -41,6 +42,7 @@ const RuleEngineCardGrid = ({
|
|||||||
onViewChain,
|
onViewChain,
|
||||||
onSubmitRate,
|
onSubmitRate,
|
||||||
onApproveRate,
|
onApproveRate,
|
||||||
|
readOnly = false,
|
||||||
}: RuleEngineCardGridProps) => {
|
}: RuleEngineCardGridProps) => {
|
||||||
const presentation = resolveCardPresentation(config);
|
const presentation = resolveCardPresentation(config);
|
||||||
|
|
||||||
@@ -153,8 +155,9 @@ const RuleEngineCardGrid = ({
|
|||||||
record={record}
|
record={record}
|
||||||
config={config}
|
config={config}
|
||||||
layout="compact"
|
layout="compact"
|
||||||
onEdit={onEdit}
|
readOnly={readOnly}
|
||||||
onDelete={onDelete}
|
onEdit={onEdit ?? (() => {})}
|
||||||
|
onDelete={onDelete ?? (() => {})}
|
||||||
onViewChain={onViewChain}
|
onViewChain={onViewChain}
|
||||||
onSubmitRate={onSubmitRate}
|
onSubmitRate={onSubmitRate}
|
||||||
onApproveRate={onApproveRate}
|
onApproveRate={onApproveRate}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface RuleEngineRecordActionsProps {
|
|||||||
onSubmitRate?: (id: string) => void;
|
onSubmitRate?: (id: string) => void;
|
||||||
onApproveRate?: (record: RuleEngineRecord) => void;
|
onApproveRate?: (record: RuleEngineRecord) => void;
|
||||||
layout?: "row" | "compact";
|
layout?: "row" | "compact";
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RuleEngineRecordActions = ({
|
const RuleEngineRecordActions = ({
|
||||||
@@ -37,6 +38,7 @@ const RuleEngineRecordActions = ({
|
|||||||
onSubmitRate,
|
onSubmitRate,
|
||||||
onApproveRate,
|
onApproveRate,
|
||||||
layout = "row",
|
layout = "row",
|
||||||
|
readOnly = false,
|
||||||
}: RuleEngineRecordActionsProps) => {
|
}: RuleEngineRecordActionsProps) => {
|
||||||
const status = String(record.status ?? "");
|
const status = String(record.status ?? "");
|
||||||
const hasRateActions =
|
const hasRateActions =
|
||||||
@@ -47,6 +49,21 @@ const RuleEngineRecordActions = ({
|
|||||||
? "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
? "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
: "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground";
|
: "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground";
|
||||||
|
|
||||||
|
if (readOnly) {
|
||||||
|
return onViewChain ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={iconBtnClass}
|
||||||
|
onClick={onViewChain}
|
||||||
|
aria-label="View chain"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-end gap-0.5">
|
<div className="flex items-center justify-end gap-0.5">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface RuleEngineToolbarProps {
|
|||||||
search: string;
|
search: string;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
searchPlaceholder: string;
|
searchPlaceholder: string;
|
||||||
onAdd: () => void;
|
onAdd?: () => void;
|
||||||
addLabel?: string;
|
addLabel?: string;
|
||||||
viewMode: RuleEngineViewMode;
|
viewMode: RuleEngineViewMode;
|
||||||
onViewModeChange: (mode: RuleEngineViewMode) => void;
|
onViewModeChange: (mode: RuleEngineViewMode) => void;
|
||||||
@@ -81,10 +81,12 @@ const RuleEngineToolbar = ({
|
|||||||
<Filter className="h-4 w-4" />
|
<Filter className="h-4 w-4" />
|
||||||
Filter
|
Filter
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" className={ruleEngineToolbar.primaryBtn} onClick={onAdd}>
|
{onAdd ? (
|
||||||
<Plus className="h-4 w-4" />
|
<Button type="button" className={ruleEngineToolbar.primaryBtn} onClick={onAdd}>
|
||||||
{addLabel}
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export const QUERY_KEYS = {
|
|||||||
ROOT: ["bookings"] as const,
|
ROOT: ["bookings"] as const,
|
||||||
list: (filter?: BookingListFilter) =>
|
list: (filter?: BookingListFilter) =>
|
||||||
["bookings", "list", filter ?? {}] as const,
|
["bookings", "list", filter ?? {}] as const,
|
||||||
|
listSummary: (filter?: BookingListFilter) =>
|
||||||
|
["bookings", "list-summary", filter ?? {}] as const,
|
||||||
byId: (id: string) => ["bookings", "detail", id] as const,
|
byId: (id: string) => ["bookings", "detail", id] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export const URL_CONSTANTS = {
|
|||||||
|
|
||||||
BOOKINGS: {
|
BOOKINGS: {
|
||||||
BASE: "/bookings",
|
BASE: "/bookings",
|
||||||
|
LIST_SUMMARY: "/bookings/list-summary",
|
||||||
BY_ID: (id: string) => `/bookings/${id}`,
|
BY_ID: (id: string) => `/bookings/${id}`,
|
||||||
QUEUE: (queue: string) => `/bookings/queues/${queue}`,
|
QUEUE: (queue: string) => `/bookings/queues/${queue}`,
|
||||||
STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`,
|
STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`,
|
||||||
@@ -95,11 +96,7 @@ export const URL_CONSTANTS = {
|
|||||||
SUMMARY: (id: string) => `/bookings/${id}/summary`,
|
SUMMARY: (id: string) => `/bookings/${id}/summary`,
|
||||||
CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`,
|
CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`,
|
||||||
MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`,
|
MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`,
|
||||||
PAYMENT_PNR: (id: string) => `/bookings/${id}/payment/pnr`,
|
PAYMENT_PAY: (id: string) => `/bookings/${id}/payment/pay`,
|
||||||
PAYMENT_PROOF: (id: string) => `/bookings/${id}/payment/proof`,
|
|
||||||
PAYMENT_VERIFY: (id: string) => `/bookings/${id}/payment/verify`,
|
|
||||||
PAYMENT_REQUEST_LETTER: (id: string) =>
|
|
||||||
`/bookings/${id}/payment/request-letter`,
|
|
||||||
START_TRANSIT: (id: string) => `/bookings/${id}/operations/start-transit`,
|
START_TRANSIT: (id: string) => `/bookings/${id}/operations/start-transit`,
|
||||||
COMPLETE: (id: string) => `/bookings/${id}/operations/complete`,
|
COMPLETE: (id: string) => `/bookings/${id}/operations/complete`,
|
||||||
CANCEL: (id: string) => `/bookings/${id}/cancel`,
|
CANCEL: (id: string) => `/bookings/${id}/cancel`,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { BookingApprovalStep, BookingStatus } from "@/types/booking";
|
||||||
|
import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
|
||||||
|
|
||||||
|
export interface ApprovalProgressSummary {
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
complete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact approval chain summary for list rows and badges. */
|
||||||
|
export function formatApprovalProgress(
|
||||||
|
status: BookingStatus | string,
|
||||||
|
steps?: BookingApprovalStep[] | null,
|
||||||
|
): ApprovalProgressSummary {
|
||||||
|
const sorted = [...(steps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder);
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
if (status === "SUBMITTED") {
|
||||||
|
return {
|
||||||
|
label: "Awaiting accept",
|
||||||
|
detail: "Staff must accept intake",
|
||||||
|
complete: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
status === "PENDING_APPROVAL" ||
|
||||||
|
status === "APPROVED_PENDING_SIGNATURE"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
label: "No steps",
|
||||||
|
detail: "Approval chain not started",
|
||||||
|
complete: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
"APPROVED",
|
||||||
|
"CONTRACT_READY",
|
||||||
|
"SIGNED_CUSTOMER",
|
||||||
|
"FULLY_EXECUTED",
|
||||||
|
"PAID",
|
||||||
|
"COMPLETED",
|
||||||
|
].includes(status)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
label: "Approved",
|
||||||
|
detail: "Internal approval complete",
|
||||||
|
complete: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { label: "—", detail: "", complete: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const approved = sorted.filter((s) => s.status === "APPROVED").length;
|
||||||
|
const total = sorted.length;
|
||||||
|
const next = getNextPendingApprovalStep(sorted);
|
||||||
|
|
||||||
|
if (!next && approved === total) {
|
||||||
|
return {
|
||||||
|
label: `${approved}/${total} done`,
|
||||||
|
detail: sorted.map((s) => `${s.requiredRole} ✓`).join(" · "),
|
||||||
|
complete: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next) {
|
||||||
|
return {
|
||||||
|
label: `${approved}/${total}`,
|
||||||
|
detail: `Next: ${next.requiredRole} (step ${next.stepOrder})`,
|
||||||
|
complete: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: `${approved}/${total}`,
|
||||||
|
detail: sorted.map((s) => `${s.requiredRole}: ${s.status}`).join(" · "),
|
||||||
|
complete: approved === total,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import type { AuthUser } from "@/auth/types";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
import type {
|
import type {
|
||||||
BookingApprovalStep,
|
BookingApprovalStep,
|
||||||
BookingDetail,
|
BookingDetail,
|
||||||
@@ -26,12 +28,13 @@ export type BookingActionId =
|
|||||||
| "rejectApproval"
|
| "rejectApproval"
|
||||||
| "generateContract"
|
| "generateContract"
|
||||||
| "viewContract"
|
| "viewContract"
|
||||||
| "generatePnr"
|
| "signContractStaff"
|
||||||
| "verifyPayment"
|
| "payBooking"
|
||||||
| "startTransit"
|
| "startTransit"
|
||||||
| "complete";
|
| "complete"
|
||||||
|
| "cancel";
|
||||||
|
|
||||||
export type BookingActionInputKind = "note" | "reason";
|
export type BookingActionInputKind = "note" | "reason" | "file";
|
||||||
|
|
||||||
export interface BookingActionDef {
|
export interface BookingActionDef {
|
||||||
id: BookingActionId;
|
id: BookingActionId;
|
||||||
@@ -140,18 +143,118 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CANCEL_ACTION: BookingActionDef = {
|
||||||
|
id: "cancel",
|
||||||
|
label: "Cancel booking",
|
||||||
|
shortLabel: "Cancel",
|
||||||
|
description: "Cancel this booking",
|
||||||
|
confirmTitle: "Cancel booking?",
|
||||||
|
confirmDescription:
|
||||||
|
"The booking will be marked cancelled. Provide a reason for the audit trail.",
|
||||||
|
variant: "destructive",
|
||||||
|
icon: Ban,
|
||||||
|
input: "reason",
|
||||||
|
inputLabel: "Cancellation reason",
|
||||||
|
inputPlaceholder: "Reason for cancellation…",
|
||||||
|
};
|
||||||
|
|
||||||
|
const VIEW_CONTRACT_ACTION: BookingActionDef = {
|
||||||
|
id: "viewContract",
|
||||||
|
label: "View contract",
|
||||||
|
shortLabel: "Contract",
|
||||||
|
description: "Open contract document and signatures",
|
||||||
|
confirmTitle: "",
|
||||||
|
confirmDescription: "",
|
||||||
|
variant: "outline",
|
||||||
|
icon: FileSignature,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = {
|
||||||
|
id: "signContractStaff",
|
||||||
|
label: "Sign contract",
|
||||||
|
shortLabel: "Sign",
|
||||||
|
description: "Open contract page and apply staff counter-signature",
|
||||||
|
confirmTitle: "",
|
||||||
|
confirmDescription: "",
|
||||||
|
variant: "default",
|
||||||
|
icon: FileSignature,
|
||||||
|
primary: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAY_BOOKING_ACTION: BookingActionDef = {
|
||||||
|
id: "payBooking",
|
||||||
|
label: "Pay",
|
||||||
|
shortLabel: "Pay",
|
||||||
|
description: "Complete in-app payment",
|
||||||
|
confirmTitle: "Complete payment?",
|
||||||
|
confirmDescription:
|
||||||
|
"This simulates an in-app payment (Telebirr for ETB, card for USD) and marks the booking as paid.",
|
||||||
|
variant: "default",
|
||||||
|
icon: Wallet,
|
||||||
|
primary: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function withCancel(actions: BookingActionDef[]): BookingActionDef[] {
|
||||||
|
return [...actions, CANCEL_ACTION];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
||||||
|
accept: FREIGHT_PERMS.bookings.staffAccept,
|
||||||
|
requestChanges: FREIGHT_PERMS.bookings.requestChanges,
|
||||||
|
reject: FREIGHT_PERMS.bookings.reject,
|
||||||
|
rejectApproval: FREIGHT_PERMS.bookings.rejectApproval,
|
||||||
|
generateContract: FREIGHT_PERMS.bookings.generateContract,
|
||||||
|
viewContract: FREIGHT_PERMS.bookings.view,
|
||||||
|
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
|
||||||
|
payBooking: FREIGHT_PERMS.bookings.view,
|
||||||
|
startTransit: FREIGHT_PERMS.bookings.operations,
|
||||||
|
complete: FREIGHT_PERMS.bookings.operations,
|
||||||
|
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||||
|
};
|
||||||
|
|
||||||
|
const approvePermissionForRole = (role: string): string | undefined => {
|
||||||
|
if (role === "LINE_STAFF") return FREIGHT_PERMS.bookings.approveLineStaff;
|
||||||
|
if (role === "DIRECTOR") return FREIGHT_PERMS.bookings.approveDirector;
|
||||||
|
if (role === "CEO") return FREIGHT_PERMS.bookings.approveCeo;
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
function filterActionsByUser(
|
||||||
|
actions: BookingActionDef[],
|
||||||
|
user: AuthUser | null | undefined,
|
||||||
|
approvalSteps?: BookingApprovalStep[] | null,
|
||||||
|
): BookingActionDef[] {
|
||||||
|
if (!user) return [];
|
||||||
|
const next = getNextPendingApprovalStep(approvalSteps);
|
||||||
|
return actions.filter((action) => {
|
||||||
|
if (action.id === "approve" && next) {
|
||||||
|
const perm = approvePermissionForRole(next.requiredRole);
|
||||||
|
return perm ? hasPermission(user, perm) : false;
|
||||||
|
}
|
||||||
|
const perm = ACTION_PERMISSION[action.id];
|
||||||
|
return perm ? hasPermission(user, perm) : true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Actions available for the current booking status (detail or list). */
|
/** Actions available for the current booking status (detail or list). */
|
||||||
export function getBookingActions(ctx: BookingActionContext): BookingActionDef[] {
|
export function getBookingActions(
|
||||||
const { status, paymentCurrency, approvalSteps } = ctx;
|
ctx: BookingActionContext,
|
||||||
|
user?: AuthUser | null,
|
||||||
|
): BookingActionDef[] {
|
||||||
|
const { status, approvalSteps } = ctx;
|
||||||
|
|
||||||
|
let actions: BookingActionDef[];
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "SUBMITTED":
|
case "SUBMITTED":
|
||||||
return SUBMITTED_ACTIONS;
|
actions = withCancel(SUBMITTED_ACTIONS);
|
||||||
|
break;
|
||||||
case "PENDING_APPROVAL":
|
case "PENDING_APPROVAL":
|
||||||
case "APPROVED_PENDING_SIGNATURE":
|
case "APPROVED_PENDING_SIGNATURE":
|
||||||
return approvalActions(approvalSteps);
|
actions = withCancel(approvalActions(approvalSteps));
|
||||||
|
break;
|
||||||
case "APPROVED":
|
case "APPROVED":
|
||||||
return [
|
actions = [
|
||||||
{
|
{
|
||||||
id: "generateContract",
|
id: "generateContract",
|
||||||
label: "Generate contract",
|
label: "Generate contract",
|
||||||
@@ -164,64 +267,23 @@ export function getBookingActions(ctx: BookingActionContext): BookingActionDef[]
|
|||||||
icon: FileText,
|
icon: FileText,
|
||||||
primary: true,
|
primary: true,
|
||||||
},
|
},
|
||||||
|
CANCEL_ACTION,
|
||||||
];
|
];
|
||||||
|
break;
|
||||||
case "CONTRACT_READY":
|
case "CONTRACT_READY":
|
||||||
|
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }];
|
||||||
|
break;
|
||||||
case "SIGNED_CUSTOMER":
|
case "SIGNED_CUSTOMER":
|
||||||
|
actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION];
|
||||||
|
break;
|
||||||
case "FULLY_EXECUTED":
|
case "FULLY_EXECUTED":
|
||||||
return [
|
actions = [
|
||||||
{
|
PAY_BOOKING_ACTION,
|
||||||
id: "viewContract",
|
{ ...VIEW_CONTRACT_ACTION, label: "View executed contract" },
|
||||||
label:
|
|
||||||
status === "SIGNED_CUSTOMER"
|
|
||||||
? "View & sign contract (staff)"
|
|
||||||
: status === "CONTRACT_READY"
|
|
||||||
? "View contract"
|
|
||||||
: "View executed contract",
|
|
||||||
shortLabel: "Contract",
|
|
||||||
description: "Open contract document and signatures",
|
|
||||||
confirmTitle: "",
|
|
||||||
confirmDescription: "",
|
|
||||||
variant: "default",
|
|
||||||
icon: FileSignature,
|
|
||||||
primary: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
case "FULLY_EXECUTED":
|
|
||||||
if (paymentCurrency === "ETB") {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "generatePnr",
|
|
||||||
label: "Generate PNR",
|
|
||||||
shortLabel: "PNR",
|
|
||||||
description: "Issue PNR for ETB bank payment",
|
|
||||||
confirmTitle: "Generate PNR?",
|
|
||||||
confirmDescription:
|
|
||||||
"A payment reference number will be issued for the customer.",
|
|
||||||
variant: "default",
|
|
||||||
icon: Wallet,
|
|
||||||
primary: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
case "PAYMENT_VERIFICATION_IN_PROGRESS":
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "verifyPayment",
|
|
||||||
label: "Verify payment",
|
|
||||||
shortLabel: "Verify",
|
|
||||||
description: "Confirm USD payment proof",
|
|
||||||
confirmTitle: "Verify payment?",
|
|
||||||
confirmDescription:
|
|
||||||
"Finance confirms the uploaded proof and marks the booking as paid.",
|
|
||||||
variant: "default",
|
|
||||||
icon: Check,
|
|
||||||
primary: true,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
break;
|
||||||
case "PAID":
|
case "PAID":
|
||||||
case "PNR_GENERATED":
|
actions = [
|
||||||
return [
|
|
||||||
{
|
{
|
||||||
id: "startTransit",
|
id: "startTransit",
|
||||||
label: "Start transit",
|
label: "Start transit",
|
||||||
@@ -234,8 +296,9 @@ export function getBookingActions(ctx: BookingActionContext): BookingActionDef[]
|
|||||||
primary: true,
|
primary: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
break;
|
||||||
case "IN_TRANSIT":
|
case "IN_TRANSIT":
|
||||||
return [
|
actions = [
|
||||||
{
|
{
|
||||||
id: "complete",
|
id: "complete",
|
||||||
label: "Complete booking",
|
label: "Complete booking",
|
||||||
@@ -249,20 +312,39 @@ export function getBookingActions(ctx: BookingActionContext): BookingActionDef[]
|
|||||||
primary: true,
|
primary: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
break;
|
||||||
|
case "CHANGES_REQUESTED":
|
||||||
|
actions = [CANCEL_ACTION];
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return [];
|
actions = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user === undefined) return actions;
|
||||||
|
return filterActionsByUser(actions, user, approvalSteps);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listRowHasActions(row: {
|
/** Opens contract page without confirmation dialog. */
|
||||||
status: BookingStatus;
|
export function isContractNavAction(id: BookingActionId): boolean {
|
||||||
paymentCurrency: string;
|
return id === "viewContract" || id === "signContractStaff";
|
||||||
}): boolean {
|
}
|
||||||
const actions = getBookingActions({
|
|
||||||
status: row.status,
|
export function listRowHasActions(
|
||||||
paymentCurrency: row.paymentCurrency,
|
row: {
|
||||||
reference: "",
|
status: BookingStatus;
|
||||||
});
|
paymentCurrency: string;
|
||||||
if (actions.length > 0) return true;
|
approvalSteps?: BookingApprovalStep[] | null;
|
||||||
return row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD";
|
},
|
||||||
|
user?: AuthUser | null,
|
||||||
|
): boolean {
|
||||||
|
const actions = getBookingActions(
|
||||||
|
{
|
||||||
|
status: row.status,
|
||||||
|
paymentCurrency: row.paymentCurrency,
|
||||||
|
reference: "",
|
||||||
|
approvalSteps: row.approvalSteps,
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
return actions.length > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,20 +199,31 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const BOOKING_LIST_TABS = [
|
export const BOOKING_LIST_TABS = [
|
||||||
{ key: "all", label: "All bookings", status: null },
|
{ key: "all", label: "All bookings", statuses: null as string[] | null },
|
||||||
{ key: "SUBMITTED", label: "Submitted", status: "SUBMITTED" },
|
{ key: "intake", label: "Submitted", statuses: ["SUBMITTED"] },
|
||||||
{ key: "PENDING_APPROVAL", label: "Pending Approval", status: "PENDING_APPROVAL" },
|
|
||||||
{
|
{
|
||||||
key: "APPROVED_PENDING_SIGNATURE",
|
key: "in_approval",
|
||||||
label: "Pending Signature",
|
label: "In approval",
|
||||||
status: "APPROVED_PENDING_SIGNATURE",
|
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE"],
|
||||||
},
|
},
|
||||||
{ key: "SIGNED_CUSTOMER", label: "Customer Signed", status: "SIGNED_CUSTOMER" },
|
|
||||||
{
|
{
|
||||||
key: "PAYMENT_VERIFICATION_IN_PROGRESS",
|
key: "approved_contract",
|
||||||
label: "Payment Verification",
|
label: "Approved & contract",
|
||||||
status: "PAYMENT_VERIFICATION_IN_PROGRESS",
|
statuses: [
|
||||||
|
"APPROVED",
|
||||||
|
"CONTRACT_READY",
|
||||||
|
"SIGNED_CUSTOMER",
|
||||||
|
"FULLY_EXECUTED",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "payment",
|
||||||
|
label: "Payment",
|
||||||
|
statuses: ["FULLY_EXECUTED", "PAID"],
|
||||||
|
},
|
||||||
|
{ key: "operations", label: "Operations", statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"] },
|
||||||
|
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
|
||||||
|
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"];
|
export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"];
|
||||||
@@ -229,11 +240,7 @@ export const WORKFLOW_STAGES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Payment",
|
label: "Payment",
|
||||||
statuses: [
|
statuses: ["FULLY_EXECUTED", "PAID"],
|
||||||
"PNR_GENERATED",
|
|
||||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
|
||||||
"PAID",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Operations",
|
label: "Operations",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
|||||||
return {
|
return {
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
reference: booking.reference,
|
reference: booking.reference,
|
||||||
|
approvalSteps: booking.approvalSteps,
|
||||||
customerLabel: labelFromRef(booking.company, booking.companyId),
|
customerLabel: labelFromRef(booking.company, booking.companyId),
|
||||||
// customerLabel: labelFromRef(booking.customer, booking.customerId),
|
// customerLabel: labelFromRef(booking.customer, booking.customerId),
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ export function useBookingList(filter?: BookingListFilter, enabled = true) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useBookingListSummary(filter?: BookingListFilter, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEYS.BOOKINGS.listSummary(filter),
|
||||||
|
queryFn: () => bookingsService.getListSummary(filter),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useBookingDetail(id: string | undefined) {
|
export function useBookingDetail(id: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? ""),
|
queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? ""),
|
||||||
@@ -103,23 +111,10 @@ export function useBookingMutations(bookingId: string) {
|
|||||||
onError: () => toast.error("Failed to sign contract"),
|
onError: () => toast.error("Failed to sign contract"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const generatePnr = useMutation({
|
const payBooking = useMutation({
|
||||||
mutationFn: () => api.bookings.generatePnr.call({ id: bookingId }),
|
mutationFn: () => api.bookings.payBooking.call({ id: bookingId }),
|
||||||
onSuccess: (data) => onSuccess(data, "PNR generated"),
|
onSuccess: (data) => onSuccess(data, "Payment completed"),
|
||||||
onError: () => toast.error("Failed to generate PNR"),
|
onError: () => toast.error("Failed to complete payment"),
|
||||||
});
|
|
||||||
|
|
||||||
const submitPaymentProof = useMutation({
|
|
||||||
mutationFn: (file: File) =>
|
|
||||||
bookingsService.submitPaymentProof(bookingId, file),
|
|
||||||
onSuccess: (data) => onSuccess(data, "Payment proof uploaded"),
|
|
||||||
onError: () => toast.error("Failed to upload payment proof"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const verifyPayment = useMutation({
|
|
||||||
mutationFn: () => api.bookings.verifyPayment.call({ id: bookingId }),
|
|
||||||
onSuccess: (data) => onSuccess(data, "Payment verified"),
|
|
||||||
onError: () => toast.error("Failed to verify payment"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const startTransit = useMutation({
|
const startTransit = useMutation({
|
||||||
@@ -149,9 +144,7 @@ export function useBookingMutations(bookingId: string) {
|
|||||||
rejectStep.isPending ||
|
rejectStep.isPending ||
|
||||||
generateContract.isPending ||
|
generateContract.isPending ||
|
||||||
signContract.isPending ||
|
signContract.isPending ||
|
||||||
generatePnr.isPending ||
|
payBooking.isPending ||
|
||||||
submitPaymentProof.isPending ||
|
|
||||||
verifyPayment.isPending ||
|
|
||||||
startTransit.isPending ||
|
startTransit.isPending ||
|
||||||
complete.isPending ||
|
complete.isPending ||
|
||||||
cancel.isPending;
|
cancel.isPending;
|
||||||
@@ -164,15 +157,11 @@ export function useBookingMutations(bookingId: string) {
|
|||||||
rejectStep,
|
rejectStep,
|
||||||
generateContract,
|
generateContract,
|
||||||
signContract,
|
signContract,
|
||||||
generatePnr,
|
payBooking,
|
||||||
submitPaymentProof,
|
|
||||||
verifyPayment,
|
|
||||||
startTransit,
|
startTransit,
|
||||||
complete,
|
complete,
|
||||||
cancel,
|
cancel,
|
||||||
isPending,
|
isPending,
|
||||||
downloadContract: () => bookingsService.downloadContract(bookingId),
|
downloadContract: () => bookingsService.downloadContract(bookingId),
|
||||||
downloadPaymentLetter: () =>
|
|
||||||
bookingsService.downloadPaymentRequestLetter(bookingId),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
82
apps/edr-freight-web/backoffice/src/lib/permissions.ts
Normal file
82
apps/edr-freight-web/backoffice/src/lib/permissions.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { AuthUser } from "@/auth/types";
|
||||||
|
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
||||||
|
|
||||||
|
export const FREIGHT_PERMS = {
|
||||||
|
bookings: {
|
||||||
|
view: "edr_freight_app:bookings:view",
|
||||||
|
staffAccept: "edr_freight_app:bookings:staff_accept",
|
||||||
|
requestChanges: "edr_freight_app:bookings:request_changes",
|
||||||
|
reject: "edr_freight_app:bookings:reject",
|
||||||
|
approveLineStaff: "edr_freight_app:bookings:approve_line_staff",
|
||||||
|
approveDirector: "edr_freight_app:bookings:approve_director",
|
||||||
|
approveCeo: "edr_freight_app:bookings:approve_ceo",
|
||||||
|
rejectApproval: "edr_freight_app:bookings:reject_approval",
|
||||||
|
generateContract: "edr_freight_app:bookings:generate_contract",
|
||||||
|
signStaff: "edr_freight_app:bookings:sign_staff",
|
||||||
|
operations: "edr_freight_app:bookings:operations",
|
||||||
|
cancel: "edr_freight_app:bookings:cancel",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
|
||||||
|
slug.replace(/-/g, "_");
|
||||||
|
|
||||||
|
export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
|
||||||
|
if (!user) return [];
|
||||||
|
if (user.permissionKeys?.length) return user.permissionKeys;
|
||||||
|
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const p of user.permissions ?? []) {
|
||||||
|
if (p.key) keys.add(p.key);
|
||||||
|
}
|
||||||
|
for (const emp of user.employee ?? []) {
|
||||||
|
for (const pos of emp.positions ?? []) {
|
||||||
|
for (const p of pos.permissions ?? []) {
|
||||||
|
if (p.key) keys.add(p.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...keys];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSuperAdmin(user: AuthUser | null | undefined): boolean {
|
||||||
|
if (user?.isSuperAdmin) return true;
|
||||||
|
return Boolean(user?.roles?.some((r) => r.key === "super_admin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPermission(
|
||||||
|
user: AuthUser | null | undefined,
|
||||||
|
key: string,
|
||||||
|
): boolean {
|
||||||
|
if (!user) return false;
|
||||||
|
if (isSuperAdmin(user)) return true;
|
||||||
|
return getPermissionKeys(user).includes(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessBookings(user: AuthUser | null | undefined): boolean {
|
||||||
|
return hasPermission(user, FREIGHT_PERMS.bookings.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
|
||||||
|
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ruleEngineManageKey(slug: RuleEngineResourceSlug): string {
|
||||||
|
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessRuleEngineResource(
|
||||||
|
user: AuthUser | null | undefined,
|
||||||
|
slug: RuleEngineResourceSlug,
|
||||||
|
mode: "view" | "manage",
|
||||||
|
): boolean {
|
||||||
|
const key = mode === "manage" ? ruleEngineManageKey(slug) : ruleEngineViewKey(slug);
|
||||||
|
return hasPermission(user, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAccessAnyRuleEngineView(
|
||||||
|
user: AuthUser | null | undefined,
|
||||||
|
slugs: RuleEngineResourceSlug[],
|
||||||
|
): boolean {
|
||||||
|
return slugs.some((slug) => canAccessRuleEngineResource(user, slug, "view"));
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +17,6 @@ import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
|||||||
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
|
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
|
||||||
import {
|
import {
|
||||||
bookingsService,
|
bookingsService,
|
||||||
type ContractView,
|
|
||||||
type SignContractPayload,
|
type SignContractPayload,
|
||||||
} from "@/services/bookings.service";
|
} from "@/services/bookings.service";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -37,6 +36,7 @@ export default function BookingContractPage() {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const [signOpen, setSignOpen] = useState(false);
|
const [signOpen, setSignOpen] = useState(false);
|
||||||
const [signerName, setSignerName] = useState("");
|
const [signerName, setSignerName] = useState("");
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
@@ -82,7 +82,10 @@ export default function BookingContractPage() {
|
|||||||
}
|
}
|
||||||
}, [id, data?.reference]);
|
}, [id, data?.reference]);
|
||||||
|
|
||||||
const handlePrint = () => window.print();
|
const handlePrint = () => {
|
||||||
|
iframeRef.current?.contentWindow?.focus();
|
||||||
|
iframeRef.current?.contentWindow?.print();
|
||||||
|
};
|
||||||
|
|
||||||
const openSign = () => {
|
const openSign = () => {
|
||||||
setSignerName("");
|
setSignerName("");
|
||||||
@@ -135,7 +138,7 @@ export default function BookingContractPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sticky top-0 z-10 mb-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-background/95 p-4 shadow-sm backdrop-blur print:hidden">
|
<div style={{boxShadow:"3px 3px 20px 1px lightgrey"}} className="sticky top-0 z-10 mb-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-background/95 p-4 shadow-sm backdrop-blur print:hidden">
|
||||||
<Button variant="ghost" size="sm" className="gap-2" onClick={() => navigate(-1)}>
|
<Button variant="ghost" size="sm" className="gap-2" onClick={() => navigate(-1)}>
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
Back
|
Back
|
||||||
@@ -158,9 +161,19 @@ export default function BookingContractPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<article
|
{!data.hasContractDocument && (
|
||||||
className="contract-document mx-auto max-w-[210mm] rounded-xl border bg-white p-8 shadow-sm print:border-0 print:shadow-none"
|
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 print:hidden">
|
||||||
dangerouslySetInnerHTML={{ __html: extractBodyHtml(data.html) }}
|
A PDF has not been stored yet. Download will generate the latest
|
||||||
|
contract document automatically.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
title={`Contract ${data.reference}`}
|
||||||
|
srcDoc={data.html}
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
className="mx-auto block min-h-[297mm] w-full max-w-[210mm] rounded-xl border bg-white shadow-sm print:h-[297mm] print:border-0 print:shadow-none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog open={signOpen} onOpenChange={setSignOpen}>
|
<Dialog open={signOpen} onOpenChange={setSignOpen}>
|
||||||
@@ -210,9 +223,3 @@ export default function BookingContractPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render server HTML body content inside our layout wrapper. */
|
|
||||||
function extractBodyHtml(fullHtml: string): string {
|
|
||||||
const match = fullHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
|
||||||
return match ? match[1] : fullHtml;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,17 +13,20 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Train,
|
Train,
|
||||||
Truck,
|
Truck,
|
||||||
Weight,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
|
import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
|
||||||
|
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
||||||
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
||||||
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
|
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
|
||||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
|
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
|
||||||
import { bookingSurface } from "@/components/bookings/booking-ui.styles";
|
import {
|
||||||
|
bookingGlass,
|
||||||
|
bookingSurface,
|
||||||
|
} from "@/components/bookings/booking-ui.styles";
|
||||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||||
import {
|
import {
|
||||||
@@ -49,7 +52,7 @@ export default function BookingRequestDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className={bookingSurface.page}>
|
<div className={bookingSurface.page}>
|
||||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-8">
|
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-8">
|
||||||
<Loader2 className="size-10 animate-spin text-primary" />
|
<Loader2 className="size-10 animate-spin text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
Loading booking…
|
Loading booking…
|
||||||
</p>
|
</p>
|
||||||
@@ -68,8 +71,13 @@ export default function BookingRequestDetailPage() {
|
|||||||
"mx-auto max-w-md p-12 text-center",
|
"mx-auto max-w-md p-12 text-center",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex size-16 items-center justify-center rounded-2xl bg-muted">
|
<div
|
||||||
<Package className="size-8 text-muted-foreground" />
|
className={cn(
|
||||||
|
"mx-auto flex size-16 items-center justify-center rounded-2xl",
|
||||||
|
bookingGlass.iconWellGreen,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Package className="size-8" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-6 text-xl font-bold text-foreground">
|
<h1 className="mt-6 text-xl font-bold text-foreground">
|
||||||
Booking not found
|
Booking not found
|
||||||
@@ -107,31 +115,54 @@ export default function BookingRequestDetailPage() {
|
|||||||
|
|
||||||
<div className={bookingSurface.detailHero}>
|
<div className={bookingSurface.detailHero}>
|
||||||
<div className={bookingSurface.heroGlow} />
|
<div className={bookingSurface.heroGlow} />
|
||||||
|
<div className={bookingSurface.heroSheen} />
|
||||||
<div className="relative p-6 sm:p-8">
|
<div className="relative p-6 sm:p-8">
|
||||||
|
<div className="mb-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="-ml-2 gap-2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => navigate("/dashboard/booking-requests")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
Back to list
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="flex size-16 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg shadow-primary/20">
|
<div
|
||||||
<Package className="size-7" />
|
className={cn(
|
||||||
|
"flex size-16 shrink-0 items-center justify-center rounded-2xl",
|
||||||
|
bookingGlass.iconWellGreen,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Package className="size-7" strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 space-y-3">
|
<div className="min-w-0 space-y-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Booking reference
|
||||||
|
</p>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
<h1 className="text-2xl font-semibold tracking-tight text-foreground sm:text-[1.75rem]">
|
||||||
{booking.reference}
|
{booking.reference}
|
||||||
</h1>
|
</h1>
|
||||||
<BookingStatusBadge status={booking.status} />
|
<BookingStatusBadge status={booking.status} />
|
||||||
<BookingPriorityBadge score={booking.priorityScore} />
|
<BookingPriorityBadge score={booking.priorityScore} />
|
||||||
</div>
|
</div>
|
||||||
|
{booking.nextStep && (
|
||||||
|
<NextStepBanner nextStep={booking.nextStep} className="max-w-xl" />
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
|
||||||
<span className="inline-flex items-center gap-1.5 font-medium text-foreground">
|
<span className="inline-flex items-center gap-1.5 font-medium text-foreground">
|
||||||
<Building2 className="size-4 text-primary" />
|
<Building2 className="size-4 opacity-70" />
|
||||||
{row.customerLabel}
|
{row.customerLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Calendar className="size-4" />
|
<Calendar className="size-4 opacity-70" />
|
||||||
Scheduled {booking.scheduledDate}
|
Scheduled {booking.scheduledDate}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Clock className="size-4" />
|
<Clock className="size-4 opacity-70" />
|
||||||
Created{" "}
|
Created{" "}
|
||||||
{new Date(booking.createdAt).toLocaleDateString(undefined, {
|
{new Date(booking.createdAt).toLocaleDateString(undefined, {
|
||||||
dateStyle: "medium",
|
dateStyle: "medium",
|
||||||
@@ -142,11 +173,11 @@ export default function BookingRequestDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-stretch gap-3 sm:items-end">
|
<div className="flex flex-col items-stretch gap-3 sm:items-end">
|
||||||
<div className="rounded-xl border border-primary/20 bg-background/80 px-5 py-4 text-right shadow-sm backdrop-blur-sm">
|
<div className={cn(bookingSurface.valueCard, "min-w-[12rem] text-right")}>
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
Total value
|
Total value
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 font-mono text-2xl font-bold tabular-nums text-foreground">
|
<p className="mt-1 font-mono text-2xl font-semibold tabular-nums text-foreground">
|
||||||
{booking.paymentCurrency}{" "}
|
{booking.paymentCurrency}{" "}
|
||||||
{amount.toLocaleString(undefined, {
|
{amount.toLocaleString(undefined, {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
@@ -159,7 +190,7 @@ export default function BookingRequestDetailPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-2 self-end"
|
className="gap-2 self-end border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
|
||||||
disabled={isFetching}
|
disabled={isFetching}
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
>
|
>
|
||||||
@@ -191,7 +222,7 @@ export default function BookingRequestDetailPage() {
|
|||||||
title="Contract summary"
|
title="Contract summary"
|
||||||
subtitle="Generated terms"
|
subtitle="Generated terms"
|
||||||
>
|
>
|
||||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-lg border border-border/60 bg-muted/20 p-4 font-mono text-xs leading-relaxed text-muted-foreground">
|
<pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-lg border border-border/50 bg-muted/10 p-4 font-mono text-xs leading-relaxed text-muted-foreground backdrop-blur-sm">
|
||||||
{booking.contractSummary}
|
{booking.contractSummary}
|
||||||
</pre>
|
</pre>
|
||||||
</SectionShell>
|
</SectionShell>
|
||||||
@@ -213,7 +244,8 @@ export default function BookingRequestDetailPage() {
|
|||||||
<div className={bookingSurface.sectionCard}>
|
<div className={bookingSurface.sectionCard}>
|
||||||
<div className="px-5 py-4">
|
<div className="px-5 py-4">
|
||||||
<Button
|
<Button
|
||||||
className="w-full gap-2"
|
className="w-full gap-2 shadow-sm"
|
||||||
|
variant="default"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
|
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
|
||||||
}
|
}
|
||||||
@@ -249,9 +281,7 @@ function SectionShell({
|
|||||||
return (
|
return (
|
||||||
<div className={bookingSurface.sectionCard}>
|
<div className={bookingSurface.sectionCard}>
|
||||||
<div className={bookingSurface.sectionHeader}>
|
<div className={bookingSurface.sectionHeader}>
|
||||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
<div className={bookingSurface.sectionIcon}>{icon}</div>
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
@@ -277,14 +307,22 @@ function RouteCard({
|
|||||||
title="Route & service"
|
title="Route & service"
|
||||||
subtitle="Corridor and service level"
|
subtitle="Corridor and service level"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-stretch gap-6 rounded-xl border border-dashed border-border bg-muted/15 p-5 md:flex-row md:items-center md:justify-between">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-stretch gap-6 rounded-xl border border-dashed border-emerald-500/20 p-5 backdrop-blur-sm md:flex-row md:items-center md:justify-between",
|
||||||
|
bookingGlass.activeTab,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<RouteEndpoint label="Origin" station={row.originLabel} />
|
<RouteEndpoint label="Origin" station={row.originLabel} />
|
||||||
<div className="flex flex-col items-center gap-2 px-4">
|
<div className="flex flex-col items-center gap-2 px-4">
|
||||||
<div className="flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
<div className={cn("flex size-10 items-center justify-center rounded-full", bookingGlass.iconWellGreen)}>
|
||||||
<Train className="size-5" />
|
<Train className="size-5 text-black" strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight className="size-5 rotate-90 text-muted-foreground md:rotate-0" />
|
<ArrowRight className="size-5 rotate-90 text-muted-foreground md:rotate-0" />
|
||||||
<Badge variant="outline" className="text-[10px] font-semibold uppercase">
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-border/50 bg-background/50 text-[10px] font-medium uppercase backdrop-blur-sm"
|
||||||
|
>
|
||||||
{booking.serviceType?.label ??
|
{booking.serviceType?.label ??
|
||||||
booking.serviceType?.code ??
|
booking.serviceType?.code ??
|
||||||
"Rail service"}
|
"Rail service"}
|
||||||
@@ -369,10 +407,10 @@ function CargoCard({ booking }: { booking: BookingDetail }) {
|
|||||||
{containers.length > 0 && (
|
{containers.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Separator className="my-5" />
|
<Separator className="my-5" />
|
||||||
<div className="overflow-hidden rounded-lg border border-border">
|
<div className="overflow-hidden rounded-lg border border-border/50 backdrop-blur-sm">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/30 text-left text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
<tr className="border-b border-border/50 bg-muted/20 text-left text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
<th className="px-4 py-3">Container type</th>
|
<th className="px-4 py-3">Container type</th>
|
||||||
<th className="px-4 py-3">Qty</th>
|
<th className="px-4 py-3">Qty</th>
|
||||||
<th className="px-4 py-3">VGM / unit</th>
|
<th className="px-4 py-3">VGM / unit</th>
|
||||||
@@ -415,14 +453,14 @@ function RouteEndpoint({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 items-center gap-3 md:max-w-[14rem]">
|
<div className="flex min-w-0 items-center gap-3 md:max-w-[14rem]">
|
||||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm">
|
<div className={bookingSurface.sectionIconLg}>
|
||||||
<MapPin className="size-5" />
|
<MapPin className="size-5" strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-sm font-bold text-foreground">{station}</p>
|
<p className="truncate text-sm font-semibold text-foreground">{station}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -444,10 +482,10 @@ function MetricTile({
|
|||||||
highlight && "border-amber-300/50 bg-amber-50/50 dark:bg-amber-950/20",
|
highlight && "border-amber-300/50 bg-amber-50/50 dark:bg-amber-950/20",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1.5 text-sm font-semibold leading-snug text-foreground">
|
<p className="mt-1.5 text-sm font-medium leading-snug text-foreground">
|
||||||
{value}
|
{value}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -23,12 +23,18 @@ import {
|
|||||||
} from "@/components/bookings/BookingStatusTabs";
|
} from "@/components/bookings/BookingStatusTabs";
|
||||||
import { BookingStatGrid } from "@/components/bookings/BookingStatGrid";
|
import { BookingStatGrid } from "@/components/bookings/BookingStatGrid";
|
||||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
|
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
|
||||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||||
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
|
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
|
||||||
import { bookingInput, bookingSurface } from "@/components/bookings/booking-ui.styles";
|
import {
|
||||||
|
bookingGlass,
|
||||||
|
bookingInput,
|
||||||
|
bookingSurface,
|
||||||
|
bookingTable,
|
||||||
|
} from "@/components/bookings/booking-ui.styles";
|
||||||
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
|
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
|
||||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||||
import { useBookingList } from "@/hooks/bookings/useBookings";
|
import { useBookingList, useBookingListSummary } from "@/hooks/bookings/useBookings";
|
||||||
import type { BookingListFilter } from "@/services/bookings.service";
|
import type { BookingListFilter } from "@/services/bookings.service";
|
||||||
import type { BookingListRow } from "@/types/booking";
|
import type { BookingListRow } from "@/types/booking";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -42,16 +48,26 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
function getStatusForTab(tab: BookingStatusTabKey): string | undefined {
|
function getStatusesForTab(tab: BookingStatusTabKey): string | undefined {
|
||||||
const match = BOOKING_LIST_TABS.find((t) => t.key === tab);
|
const match = BOOKING_LIST_TABS.find((t) => t.key === tab);
|
||||||
return match?.status ?? undefined;
|
if (!match?.statuses?.length) return undefined;
|
||||||
|
return match.statuses.join(",");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BookingRequestsPage() {
|
export default function BookingRequestsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("SUBMITTED");
|
const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("in_approval");
|
||||||
|
const suppressRowClickRef = useRef(false);
|
||||||
|
const suppressRowClick = useCallback(() => {
|
||||||
|
suppressRowClickRef.current = true;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
suppressRowClickRef.current = false;
|
||||||
|
}, 400);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tabStatuses = getStatusesForTab(activeTab);
|
||||||
|
|
||||||
const filter: BookingListFilter = useMemo(
|
const filter: BookingListFilter = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -59,12 +75,18 @@ export default function BookingRequestsPage() {
|
|||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
sortBy: "createdAt",
|
sortBy: "createdAt",
|
||||||
sortOrder: "DESC",
|
sortOrder: "DESC",
|
||||||
...(getStatusForTab(activeTab) ? { status: getStatusForTab(activeTab) } : {}),
|
tab: activeTab,
|
||||||
|
...(tabStatuses ? { statuses: tabStatuses } : {}),
|
||||||
}),
|
}),
|
||||||
[pagination.pageIndex, pagination.pageSize, activeTab],
|
[pagination.pageIndex, pagination.pageSize, activeTab, tabStatuses],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
|
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
|
||||||
|
const {
|
||||||
|
data: summary,
|
||||||
|
isLoading: summaryLoading,
|
||||||
|
refetch: refetchSummary,
|
||||||
|
} = useBookingListSummary(filter);
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const items = (data?.items ?? []).map(toBookingListRow);
|
const items = (data?.items ?? []).map(toBookingListRow);
|
||||||
@@ -82,26 +104,39 @@ export default function BookingRequestsPage() {
|
|||||||
const hasSearch = query.trim().length > 0;
|
const hasSearch = query.trim().length > 0;
|
||||||
const showEmpty = !isLoading && !isError && rows.length === 0;
|
const showEmpty = !isLoading && !isError && rows.length === 0;
|
||||||
|
|
||||||
const pendingCount = rows.filter(
|
const metrics = summary?.metrics;
|
||||||
(b) => b.status === "SUBMITTED" || b.status === "PENDING_APPROVAL",
|
const tabCounts = summary?.tabs;
|
||||||
).length;
|
const statValue = (value: number | undefined) =>
|
||||||
const urgentCount = rows.filter((b) => b.priorityScore >= 1000).length;
|
summaryLoading ? "—" : (value ?? 0);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
void refetch();
|
||||||
|
void refetchSummary();
|
||||||
|
}, [refetch, refetchSummary]);
|
||||||
|
|
||||||
|
const handleRowClick = useCallback(
|
||||||
|
(row: BookingListRow) => {
|
||||||
|
if (suppressRowClickRef.current) return;
|
||||||
|
navigate(`/dashboard/booking-requests/${row.id}`);
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
);
|
||||||
|
|
||||||
const columns: ColumnDef<BookingListRow>[] = [
|
const columns: ColumnDef<BookingListRow>[] = [
|
||||||
{
|
{
|
||||||
id: "booking",
|
id: "booking",
|
||||||
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Booking</span>,
|
header: () => <span className={bookingTable.headerCell}>Booking</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const b = row.original;
|
const b = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 py-1">
|
<div className="flex items-center gap-3 py-1.5">
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-primary/80 text-primary-foreground shadow-sm">
|
<div className={bookingTable.rowIcon}>
|
||||||
<Package className="size-4" />
|
<Package className="size-4" strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-semibold text-foreground">{b.reference}</p>
|
<p className="truncate font-medium text-foreground">{b.reference}</p>
|
||||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||||
<User className="size-3 shrink-0" />
|
<User className="size-3 shrink-0 opacity-70" />
|
||||||
{b.customerLabel}
|
{b.customerLabel}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,7 +146,7 @@ export default function BookingRequestsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "route",
|
id: "route",
|
||||||
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Route</span>,
|
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const b = row.original;
|
const b = row.original;
|
||||||
return (
|
return (
|
||||||
@@ -122,10 +157,16 @@ export default function BookingRequestsPage() {
|
|||||||
<span className="max-w-[8rem] truncate">{b.destinationLabel}</span>
|
<span className="max-w-[8rem] truncate">{b.destinationLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<Badge variant="outline" className="h-5 px-1.5 text-[10px] font-semibold uppercase">
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
|
||||||
|
>
|
||||||
{b.tradeDirection}
|
{b.tradeDirection}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="secondary" className="h-5 px-1.5 text-[10px] font-medium">
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
|
||||||
|
>
|
||||||
{b.freightType}
|
{b.freightType}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,12 +176,19 @@ export default function BookingRequestsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Status</span>,
|
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||||
cell: ({ row }) => <BookingStatusBadge status={row.original.status} />,
|
cell: ({ row }) => <BookingStatusBadge status={row.original.status} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "approval",
|
||||||
|
header: () => (
|
||||||
|
<span className={bookingTable.headerCell}>Approval</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <BookingApprovalProgressCell row={row.original} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "scheduled",
|
id: "scheduled",
|
||||||
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Scheduled</span>,
|
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
<Calendar className="size-3.5" />
|
<Calendar className="size-3.5" />
|
||||||
@@ -150,7 +198,7 @@ export default function BookingRequestsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "priority",
|
id: "priority",
|
||||||
header: () => <span className="text-xs font-semibold uppercase tracking-wider">Priority</span>,
|
header: () => <span className={bookingTable.headerCell}>Priority</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<BookingPriorityBadge score={row.original.priorityScore} />
|
<BookingPriorityBadge score={row.original.priorityScore} />
|
||||||
),
|
),
|
||||||
@@ -158,7 +206,7 @@ export default function BookingRequestsPage() {
|
|||||||
{
|
{
|
||||||
id: "amount",
|
id: "amount",
|
||||||
header: () => (
|
header: () => (
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider">Amount</span>
|
<span className={bookingTable.headerCell}>Amount</span>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const b = row.original;
|
const b = row.original;
|
||||||
@@ -176,12 +224,14 @@ export default function BookingRequestsPage() {
|
|||||||
id: "actions",
|
id: "actions",
|
||||||
size: 140,
|
size: 140,
|
||||||
header: () => (
|
header: () => (
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider">
|
<span className={bookingTable.headerCell}>Actions</span>
|
||||||
Actions
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<BookingActionsMenu row={row.original} variant="table" />
|
<BookingActionsMenu
|
||||||
|
row={row.original}
|
||||||
|
variant="table"
|
||||||
|
onSuppressRowClick={suppressRowClick}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -193,13 +243,22 @@ export default function BookingRequestsPage() {
|
|||||||
|
|
||||||
<div className={bookingSurface.hero}>
|
<div className={bookingSurface.hero}>
|
||||||
<div className={bookingSurface.heroGlow} />
|
<div className={bookingSurface.heroGlow} />
|
||||||
|
<div className={bookingSurface.heroSheen} />
|
||||||
<div className="relative flex flex-col gap-6 p-6 sm:flex-row sm:items-center sm:justify-between sm:p-8">
|
<div className="relative flex flex-col gap-6 p-6 sm:flex-row sm:items-center sm:justify-between sm:p-8">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex size-14 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg shadow-primary/25">
|
<div
|
||||||
<Inbox className="size-7" />
|
className={cn(
|
||||||
|
"flex size-14 shrink-0 items-center justify-center rounded-2xl",
|
||||||
|
bookingGlass.iconWellGreen,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Inbox className="size-6" strokeWidth={1.75} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Operations
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-foreground sm:text-[1.75rem]">
|
||||||
Booking requests
|
Booking requests
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1.5 max-w-xl text-sm leading-relaxed text-muted-foreground">
|
<p className="mt-1.5 max-w-xl text-sm leading-relaxed text-muted-foreground">
|
||||||
@@ -211,9 +270,9 @@ export default function BookingRequestsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-2"
|
className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
|
||||||
disabled={isFetching}
|
disabled={isFetching}
|
||||||
onClick={() => refetch()}
|
onClick={handleRefresh}
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={cn("size-4", isFetching && "animate-spin")}
|
className={cn("size-4", isFetching && "animate-spin")}
|
||||||
@@ -228,29 +287,33 @@ export default function BookingRequestsPage() {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: "In queue",
|
label: "In queue",
|
||||||
value: total,
|
value: statValue(metrics?.inQueue),
|
||||||
hint: "Total matching filter",
|
hint: "Total matching filter",
|
||||||
icon: LayoutList,
|
icon: LayoutList,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "On this page",
|
label: "On this page",
|
||||||
value: rows.length,
|
value: statValue(metrics?.onThisPage),
|
||||||
hint: "Current view",
|
hint: "Current page",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Needs action",
|
label: "Needs action",
|
||||||
value: pendingCount,
|
value: statValue(metrics?.needsAction),
|
||||||
hint: "Submitted or pending approval",
|
hint: "Submitted or pending approval",
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
accent: "amber",
|
accent:
|
||||||
|
!summaryLoading && (metrics?.needsAction ?? 0) > 0
|
||||||
|
? "amber"
|
||||||
|
: "default",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Urgent",
|
label: "Urgent",
|
||||||
value: urgentCount,
|
value: statValue(metrics?.urgent),
|
||||||
hint: "High priority score",
|
hint: "High priority score",
|
||||||
icon: AlertCircle,
|
icon: AlertCircle,
|
||||||
accent: urgentCount > 0 ? "rose" : "default",
|
accent:
|
||||||
|
!summaryLoading && (metrics?.urgent ?? 0) > 0 ? "rose" : "default",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -261,9 +324,7 @@ export default function BookingRequestsPage() {
|
|||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
}}
|
}}
|
||||||
counts={{
|
counts={tabCounts}
|
||||||
[activeTab]: total,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={bookingSurface.panel}>
|
<div className={bookingSurface.panel}>
|
||||||
@@ -290,7 +351,11 @@ export default function BookingRequestsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="hidden text-xs text-muted-foreground sm:inline">
|
<span
|
||||||
|
className={cn(
|
||||||
|
"hidden rounded-md border border-border/50 bg-background/50 px-2.5 py-1 text-xs text-muted-foreground backdrop-blur-sm sm:inline",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{total} record{total !== 1 ? "s" : ""}
|
{total} record{total !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,7 +365,7 @@ export default function BookingRequestsPage() {
|
|||||||
<BookingTableEmpty
|
<BookingTableEmpty
|
||||||
isError={isError}
|
isError={isError}
|
||||||
hasSearch={hasSearch}
|
hasSearch={hasSearch}
|
||||||
onRetry={() => refetch()}
|
onRetry={handleRefresh}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={bookingSurface.tableWrap}>
|
<div className={bookingSurface.tableWrap}>
|
||||||
@@ -308,9 +373,7 @@ export default function BookingRequestsPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
data={rows}
|
data={rows}
|
||||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||||
onRowClick={(row) =>
|
onRowClick={handleRowClick}
|
||||||
navigate(`/dashboard/booking-requests/${row.id}`)
|
|
||||||
}
|
|
||||||
pagination={{
|
pagination={{
|
||||||
pageIndex: pagination.pageIndex,
|
pageIndex: pagination.pageIndex,
|
||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
@@ -323,7 +386,13 @@ export default function BookingRequestsPage() {
|
|||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
pageCount,
|
pageCount,
|
||||||
}}
|
}}
|
||||||
containerClassName="border-0 shadow-none [&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/40"
|
containerClassName={cn(
|
||||||
|
"border-0 shadow-none",
|
||||||
|
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
|
||||||
|
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
|
||||||
|
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
|
||||||
|
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
|
||||||
|
)}
|
||||||
footer={DataTableFooter}
|
footer={DataTableFooter}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Navigate, useLocation, useParams } from "react-router-dom";
|
import { Navigate, useLocation, useParams } from "react-router-dom";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { canAccessRuleEngineResource } from "@/lib/permissions";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
@@ -54,6 +56,7 @@ const pathCategory = (pathname: string): RuleEngineNavCategory | undefined => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RuleEngineResourcePage = () => {
|
const RuleEngineResourcePage = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
const { resource: resourceSlug } = useParams<{ resource: string }>();
|
const { resource: resourceSlug } = useParams<{ resource: string }>();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const category = pathCategory(location.pathname);
|
const category = pathCategory(location.pathname);
|
||||||
@@ -75,6 +78,13 @@ const RuleEngineResourcePage = () => {
|
|||||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const canView = Boolean(
|
||||||
|
config && canAccessRuleEngineResource(user, config.slug, "view"),
|
||||||
|
);
|
||||||
|
const canManage = Boolean(
|
||||||
|
config && canAccessRuleEngineResource(user, config.slug, "manage"),
|
||||||
|
);
|
||||||
|
|
||||||
const listParams = useMemo(
|
const listParams = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
search: config?.supportsSearch ? search.trim() || undefined : undefined,
|
search: config?.supportsSearch ? search.trim() || undefined : undefined,
|
||||||
@@ -207,6 +217,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
<RuleEngineRecordActions
|
<RuleEngineRecordActions
|
||||||
record={row.original}
|
record={row.original}
|
||||||
config={config}
|
config={config}
|
||||||
|
readOnly={!canManage}
|
||||||
onEdit={(record) => {
|
onEdit={(record) => {
|
||||||
setEditing(record);
|
setEditing(record);
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
@@ -215,15 +226,15 @@ const RuleEngineResourcePage = () => {
|
|||||||
onViewChain={
|
onViewChain={
|
||||||
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
||||||
}
|
}
|
||||||
onSubmitRate={(id) => submit.mutate(id)}
|
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||||
onApproveRate={handleApproveRate}
|
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
}, [config, submit, handleApproveRate]);
|
}, [canManage, config, submit, handleApproveRate]);
|
||||||
|
|
||||||
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||||
|
|
||||||
@@ -235,6 +246,10 @@ const RuleEngineResourcePage = () => {
|
|||||||
return <Navigate to={defaultPath} replace />;
|
return <Navigate to={defaultPath} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!canView) {
|
||||||
|
return <Navigate to="/dashboard/overview" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
@@ -279,7 +294,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
}}
|
}}
|
||||||
searchPlaceholder={config.searchPlaceholder}
|
searchPlaceholder={config.searchPlaceholder}
|
||||||
onAdd={openCreate}
|
onAdd={canManage ? openCreate : undefined}
|
||||||
addLabel={`Add ${config.label.replace(/s$/, "")}`}
|
addLabel={`Add ${config.label.replace(/s$/, "")}`}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
@@ -333,13 +348,14 @@ const RuleEngineResourcePage = () => {
|
|||||||
itemLabel={itemLabel}
|
itemLabel={itemLabel}
|
||||||
table={cardTable}
|
table={cardTable}
|
||||||
pagination={paginationState}
|
pagination={paginationState}
|
||||||
onEdit={openEdit}
|
readOnly={!canManage}
|
||||||
onDelete={setDeleteTarget}
|
onEdit={canManage ? openEdit : undefined}
|
||||||
|
onDelete={canManage ? setDeleteTarget : undefined}
|
||||||
onViewChain={
|
onViewChain={
|
||||||
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
||||||
}
|
}
|
||||||
onSubmitRate={(id) => submit.mutate(id)}
|
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||||
onApproveRate={handleApproveRate}
|
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -305,16 +305,10 @@ export const api = {
|
|||||||
bookingsService.signContract(id, payload),
|
bookingsService.signContract(id, payload),
|
||||||
),
|
),
|
||||||
|
|
||||||
generatePnr: endpoint<{ id: string }, BookingDetail>(
|
payBooking: endpoint<{ id: string }, BookingDetail>(
|
||||||
"bookings",
|
"bookings",
|
||||||
"generatePnr",
|
"payBooking",
|
||||||
({ id }) => bookingsService.generatePnr(id),
|
({ id }) => bookingsService.payBooking(id),
|
||||||
),
|
|
||||||
|
|
||||||
verifyPayment: endpoint<{ id: string }, BookingDetail>(
|
|
||||||
"bookings",
|
|
||||||
"verifyPayment",
|
|
||||||
({ id }) => bookingsService.verifyPayment(id),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
startTransit: endpoint<{ id: string }, BookingDetail>(
|
startTransit: endpoint<{ id: string }, BookingDetail>(
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ const B = URL_CONSTANTS.BOOKINGS;
|
|||||||
|
|
||||||
export interface BookingListFilter {
|
export interface BookingListFilter {
|
||||||
status?: string;
|
status?: string;
|
||||||
|
/** Comma-separated statuses for grouped tabs */
|
||||||
|
statuses?: string;
|
||||||
|
/** Tab key for React Query cache (not sent to API) */
|
||||||
|
tab?: string;
|
||||||
// customerId?: string;
|
// customerId?: string;
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
freightType?: string;
|
freightType?: string;
|
||||||
@@ -23,6 +27,29 @@ export interface PaginatedBookings {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BookingListSummaryMetrics {
|
||||||
|
inQueue: number;
|
||||||
|
onThisPage: number;
|
||||||
|
needsAction: number;
|
||||||
|
urgent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingListSummaryTabs {
|
||||||
|
all: number;
|
||||||
|
intake: number;
|
||||||
|
in_approval: number;
|
||||||
|
approved_contract: number;
|
||||||
|
payment: number;
|
||||||
|
operations: number;
|
||||||
|
completed: number;
|
||||||
|
closed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingListSummary {
|
||||||
|
metrics: BookingListSummaryMetrics;
|
||||||
|
tabs: BookingListSummaryTabs;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApproveStepPayload {
|
export interface ApproveStepPayload {
|
||||||
id: string;
|
id: string;
|
||||||
stepId: string;
|
stepId: string;
|
||||||
@@ -66,9 +93,41 @@ async function postBooking<T>(url: string, body?: unknown): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const bookingsService = {
|
export const bookingsService = {
|
||||||
|
getListSummary: async (filter?: BookingListFilter): Promise<BookingListSummary> => {
|
||||||
|
const params: Record<string, string | number | boolean | undefined> = {};
|
||||||
|
if (filter) {
|
||||||
|
if (filter.statuses) params.statuses = filter.statuses;
|
||||||
|
else if (filter.status) params.status = filter.status;
|
||||||
|
if (filter.page != null) params.page = filter.page;
|
||||||
|
if (filter.pageSize != null) params.pageSize = filter.pageSize;
|
||||||
|
if (filter.companyId) params.companyId = filter.companyId;
|
||||||
|
if (filter.freightType) params.freightType = filter.freightType;
|
||||||
|
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||||
|
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
||||||
|
}
|
||||||
|
const response = await client.get<BookingListSummary>(B.LIST_SUMMARY, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return unwrap(response.data) as BookingListSummary;
|
||||||
|
},
|
||||||
|
|
||||||
list: async (filter?: BookingListFilter): Promise<PaginatedBookings> => {
|
list: async (filter?: BookingListFilter): Promise<PaginatedBookings> => {
|
||||||
|
const params: Record<string, string | number | boolean | undefined> = {};
|
||||||
|
if (filter) {
|
||||||
|
if (filter.statuses) params.statuses = filter.statuses;
|
||||||
|
else if (filter.status) params.status = filter.status;
|
||||||
|
// filter.tab is intentionally omitted from API params
|
||||||
|
if (filter.page != null) params.page = filter.page;
|
||||||
|
if (filter.pageSize != null) params.pageSize = filter.pageSize;
|
||||||
|
if (filter.sortBy) params.sortBy = filter.sortBy;
|
||||||
|
if (filter.sortOrder) params.sortOrder = filter.sortOrder;
|
||||||
|
if (filter.companyId) params.companyId = filter.companyId;
|
||||||
|
if (filter.freightType) params.freightType = filter.freightType;
|
||||||
|
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||||
|
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
||||||
|
}
|
||||||
const response = await client.get<PaginatedBookings>(B.BASE, {
|
const response = await client.get<PaginatedBookings>(B.BASE, {
|
||||||
params: filter,
|
params,
|
||||||
});
|
});
|
||||||
const data = unwrap(response.data);
|
const data = unwrap(response.data);
|
||||||
return {
|
return {
|
||||||
@@ -112,14 +171,14 @@ export const bookingsService = {
|
|||||||
const response = await client.get(B.CONTRACT_DOWNLOAD(id), {
|
const response = await client.get(B.CONTRACT_DOWNLOAD(id), {
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
});
|
});
|
||||||
return response.data as Blob;
|
return ensurePdfBlob(response.data as Blob);
|
||||||
},
|
},
|
||||||
|
|
||||||
downloadContractDocument: async (id: string): Promise<Blob> => {
|
downloadContractDocument: async (id: string): Promise<Blob> => {
|
||||||
const response = await client.get(B.CONTRACT_DOCUMENT(id), {
|
const response = await client.get(B.CONTRACT_DOCUMENT(id), {
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
});
|
});
|
||||||
return response.data as Blob;
|
return ensurePdfBlob(response.data as Blob);
|
||||||
},
|
},
|
||||||
|
|
||||||
signContract: (id: string, payload: SignContractPayload) =>
|
signContract: (id: string, payload: SignContractPayload) =>
|
||||||
@@ -142,26 +201,7 @@ export const bookingsService = {
|
|||||||
role: "STAFF",
|
role: "STAFF",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
generatePnr: (id: string) => postBooking<BookingDetail>(B.PAYMENT_PNR(id)),
|
payBooking: (id: string) => postBooking<BookingDetail>(B.PAYMENT_PAY(id)),
|
||||||
|
|
||||||
submitPaymentProof: async (id: string, file: File): Promise<BookingDetail> => {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append("file", file);
|
|
||||||
const response = await client.post<BookingDetail>(B.PAYMENT_PROOF(id), form, {
|
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
|
||||||
});
|
|
||||||
return unwrap(response.data) as BookingDetail;
|
|
||||||
},
|
|
||||||
|
|
||||||
verifyPayment: (id: string) =>
|
|
||||||
postBooking<BookingDetail>(B.PAYMENT_VERIFY(id)),
|
|
||||||
|
|
||||||
downloadPaymentRequestLetter: async (id: string): Promise<Blob> => {
|
|
||||||
const response = await client.get(B.PAYMENT_REQUEST_LETTER(id), {
|
|
||||||
responseType: "blob",
|
|
||||||
});
|
|
||||||
return response.data as Blob;
|
|
||||||
},
|
|
||||||
|
|
||||||
startTransit: (id: string) =>
|
startTransit: (id: string) =>
|
||||||
postBooking<BookingDetail>(B.START_TRANSIT(id)),
|
postBooking<BookingDetail>(B.START_TRANSIT(id)),
|
||||||
@@ -171,3 +211,11 @@ export const bookingsService = {
|
|||||||
cancel: (id: string, reason: string) =>
|
cancel: (id: string, reason: string) =>
|
||||||
postBooking<BookingDetail>(B.CANCEL(id), { reason }),
|
postBooking<BookingDetail>(B.CANCEL(id), { reason }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function ensurePdfBlob(blob: Blob): Promise<Blob> {
|
||||||
|
if (blob.type.includes("application/json")) {
|
||||||
|
const body = JSON.parse(await blob.text()) as { message?: string };
|
||||||
|
throw new Error(body.message ?? "Contract PDF download failed");
|
||||||
|
}
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,11 +48,27 @@ export interface BookingApprovalStep {
|
|||||||
id: string;
|
id: string;
|
||||||
stepOrder: number;
|
stepOrder: number;
|
||||||
requiredRole: string;
|
requiredRole: string;
|
||||||
|
blocksRole?: string | null;
|
||||||
status: "PENDING" | "APPROVED" | "REJECTED" | "SKIPPED";
|
status: "PENDING" | "APPROVED" | "REJECTED" | "SKIPPED";
|
||||||
actionedAt?: string | null;
|
actionedAt?: string | null;
|
||||||
remarks?: string | null;
|
remarks?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BookingNextStep {
|
||||||
|
action: string;
|
||||||
|
description: string;
|
||||||
|
requiredRole?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InAppPaymentReceipt {
|
||||||
|
success: boolean;
|
||||||
|
provider: string;
|
||||||
|
providerRef: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
paidAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BookingReviewNote {
|
export interface BookingReviewNote {
|
||||||
id: string;
|
id: string;
|
||||||
note: string;
|
note: string;
|
||||||
@@ -90,6 +106,8 @@ export interface BookingDetail {
|
|||||||
equipmentReturn?: string;
|
equipmentReturn?: string;
|
||||||
contractSummary?: string | null;
|
contractSummary?: string | null;
|
||||||
latestChangeRequestNote?: string | null;
|
latestChangeRequestNote?: string | null;
|
||||||
|
nextStep?: BookingNextStep | null;
|
||||||
|
paymentReceipt?: InAppPaymentReceipt;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
// customer?: BookingNamedRef & { companyName?: string };
|
// customer?: BookingNamedRef & { companyName?: string };
|
||||||
@@ -114,6 +132,7 @@ export interface BookingListRow {
|
|||||||
id: string;
|
id: string;
|
||||||
reference: string;
|
reference: string;
|
||||||
customerLabel: string;
|
customerLabel: string;
|
||||||
|
approvalSteps?: BookingApprovalStep[];
|
||||||
status: BookingStatus;
|
status: BookingStatus;
|
||||||
scheduledDate: string;
|
scheduledDate: string;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import {
|
|||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
Injectable,
|
Injectable,
|
||||||
NestInterceptor,
|
NestInterceptor,
|
||||||
|
StreamableFile,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { Readable } from "stream";
|
||||||
import { Observable } from "rxjs";
|
import { Observable } from "rxjs";
|
||||||
import { map } from "rxjs/operators";
|
import { map } from "rxjs/operators";
|
||||||
|
|
||||||
@@ -16,18 +18,28 @@ export interface StandardResponse<T> {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ResponseTransformInterceptor<T> implements NestInterceptor<
|
export class ResponseTransformInterceptor<T> implements NestInterceptor<
|
||||||
T,
|
T,
|
||||||
StandardResponse<T>
|
StandardResponse<T> | T
|
||||||
> {
|
> {
|
||||||
intercept(
|
intercept(
|
||||||
_context: ExecutionContext,
|
_context: ExecutionContext,
|
||||||
next: CallHandler<T>,
|
next: CallHandler<T>,
|
||||||
): Observable<StandardResponse<T>> {
|
): Observable<StandardResponse<T> | T> {
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
map((data) => ({
|
map((data) => {
|
||||||
success: true,
|
if (
|
||||||
data,
|
data instanceof StreamableFile ||
|
||||||
timestamp: new Date().toISOString(),
|
data instanceof Buffer ||
|
||||||
})),
|
data instanceof Readable
|
||||||
|
) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,30 @@ export function DataTable<TData, TValue>({
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={row.getIsSelected() && "selected"}
|
data-state={row.getIsSelected() && "selected"}
|
||||||
onClick={() => onRowClick?.(row.original)}
|
onClick={(event) => {
|
||||||
|
if (!onRowClick) return;
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
target.closest(
|
||||||
|
[
|
||||||
|
"button",
|
||||||
|
"a",
|
||||||
|
"input",
|
||||||
|
"textarea",
|
||||||
|
"select",
|
||||||
|
"[role='menu']",
|
||||||
|
"[role='menuitem']",
|
||||||
|
"[data-slot='dialog-content']",
|
||||||
|
"[data-slot='dialog-overlay']",
|
||||||
|
"[data-slot='dropdown-menu-content']",
|
||||||
|
"[data-stop-row-click]",
|
||||||
|
].join(","),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onRowClick(row.original);
|
||||||
|
}}
|
||||||
role={onRowClick ? "button" : ""}
|
role={onRowClick ? "button" : ""}
|
||||||
className={
|
className={
|
||||||
onRowClick
|
onRowClick
|
||||||
|
|||||||
Reference in New Issue
Block a user