diff --git a/apps/edr-freight-api/src/migrations/1820000000002-CreateBookingDocumentReview.ts b/apps/edr-freight-api/src/migrations/1820000000002-CreateBookingDocumentReview.ts new file mode 100644 index 000000000..0237648f7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000002-CreateBookingDocumentReview.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +/** + * Per-document GL review for the post-counter-sign clearance gate. One row per + * required clearance document; GL marks each APPROVED or QUERIED before the + * booking can proceed to operations. + */ +export class CreateBookingDocumentReview1820000000002 + implements MigrationInterface +{ + name = 'CreateBookingDocumentReview1820000000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'booking_document_review', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'booking_id', type: 'uuid' }, + { name: 'setting_code', type: 'varchar', length: '128' }, + { name: 'file_key', type: 'varchar', length: '128' }, + { name: 'file_record_id', type: 'uuid', isNullable: true }, + { name: 'status', type: 'varchar', length: '20', default: "'PENDING'" }, + { name: 'note', type: 'text', isNullable: true }, + { name: 'reviewed_by_staff_id', type: 'uuid', isNullable: true }, + { name: 'reviewed_at', type: 'timestamptz', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['booking_id'], + referencedSchema: 'freight', + referencedTableName: 'bookings', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.booking_document_review', + new TableIndex({ name: 'idx_booking_document_review_booking', columnNames: ['booking_id'] }), + ); + await queryRunner.createIndex( + 'freight.booking_document_review', + new TableIndex({ name: 'idx_booking_document_review_status', columnNames: ['status'] }), + ); + await queryRunner.createIndex( + 'freight.booking_document_review', + new TableIndex({ + name: 'uq_booking_document_review_doc', + columnNames: ['booking_id', 'setting_code', 'file_key'], + isUnique: true, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.booking_document_review', true); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts index 6601ca704..abd6377db 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -19,6 +19,7 @@ import { FileRecord } from '../files/entities/file.entity'; import { BookingsRepository } from './bookings.repository'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; +import { clearanceSettingCode } from './clearance.util'; import { ContractViewDto } from './dto/contract-view.dto'; import { SignContractDto } from './dto/sign-contract.dto'; import { ContractSignerRole } from './entities/booking-contract-signature.entity'; @@ -222,19 +223,31 @@ export class BookingContractService { const updates: Record = {}; + // Whether a document-clearance gate applies (IMPORT/EXPORT bookings). When it + // does, the counter-signed booking goes to AWAITING_DOCUMENTS for the customer + // to upload clearance documents instead of straight into the batch pipeline. + const includesCustoms = booking.serviceType?.includesCustoms ?? false; + const clearanceCode = clearanceSettingCode( + booking.tradeDirection, + booking.freightType, + includesCustoms, + ); + if (role === 'CUSTOMER') { updates.status = 'SIGNED_CUSTOMER'; updates.customerSignedAt = now; } else { - updates.status = 'FULLY_EXECUTED'; updates.fullyExecutedAt = now; updates.marketingApprovedAt = now; updates.marketingApprovedById = options.signerUserId ?? null; updates.lockedAt = now; + updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED'; } const updated = await this.bookingsRepository.update(bookingId, updates as never); - if (role === 'STAFF' && updated?.trainScheduleId) { + // Only the non-clearance (legacy/domestic) path enters the batch pipeline now; + // clearance bookings enter operations after the GL document gate. + if (role === 'STAFF' && !clearanceCode && updated?.trainScheduleId) { this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId); } try { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts index b79c4ef20..43e70e5c9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts @@ -57,6 +57,26 @@ export function computeNextStep( action: 'AWAIT_PAYMENT', description: 'Awaiting customer payment', }; + case 'AWAITING_DOCUMENTS': + return { + action: 'UPLOAD_DOCUMENTS', + description: 'Upload the clearance documents for your shipment', + }; + case 'DOCUMENTS_UNDER_REVIEW': + return { + action: 'AWAIT_DOCUMENT_REVIEW', + description: 'Global Logistics is reviewing your documents', + }; + case 'CLEARANCE_READY': + return { + action: 'PROCEED_TO_OPERATION', + description: 'Clearance is ready — proceed to operation', + }; + case 'OPERATION_REQUESTED': + return { + action: 'AWAIT_OPERATION', + description: 'Operation requested; an operator will take it forward', + }; case 'PAID': return { action: 'START_TRANSIT', diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts new file mode 100644 index 000000000..af81b888a --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -0,0 +1,76 @@ +import { BadRequestException } from '@nestjs/common'; +import { BookingTransitionService } from './booking-transition.service'; + +/** + * Focused tests for the clearance 100%-approved gate in finalizeClearance. + * Uses minimal stubs for the service's collaborators. + */ +describe('BookingTransitionService — finalizeClearance gate', () => { + const booking = { + id: 'b-1', + status: 'DOCUMENTS_UNDER_REVIEW', + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + serviceType: { includesCustoms: false }, // no output set → only the input gate + }; + + // Input set has two required docs. + const inputSetting = { + code: 'clearance_import_container_without_customs', + fields: [ + { fileKey: 'commercial_invoice', isRequired: true }, + { fileKey: 'packing_list', isRequired: true }, + ], + }; + + function makeService(reviews: Array<{ settingCode: string; fileKey: string; status: string }>) { + const bookingsRepository = { + findDocumentReviews: jest.fn().mockResolvedValue(reviews), + update: jest.fn().mockResolvedValue({ id: 'b-1' }), + }; + const bookingsService = { + findById: jest.fn().mockResolvedValue(booking), + }; + const fileUploadSettingsService = { + getByCode: jest.fn().mockResolvedValue(inputSetting), + }; + const filesService = { findByResource: jest.fn().mockResolvedValue([]) }; + + const service = new BookingTransitionService( + bookingsRepository as never, + {} as never, // ruleEngineService + {} as never, // pricingService + {} as never, // contractService + filesService as never, + fileUploadSettingsService as never, + bookingsService as never, + ); + return { service, bookingsRepository }; + } + + it('rejects when a required document is not APPROVED', async () => { + const { service } = makeService([ + { + settingCode: inputSetting.code, + fileKey: 'commercial_invoice', + status: 'APPROVED', + }, + // packing_list is still PENDING (missing approval) + ]); + await expect(service.finalizeClearance('b-1')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('moves to CLEARANCE_READY when all required documents are APPROVED', async () => { + const { service, bookingsRepository } = makeService([ + { settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' }, + { settingCode: inputSetting.code, fileKey: 'packing_list', status: 'APPROVED' }, + ]); + await service.finalizeClearance('b-1'); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ status: 'CLEARANCE_READY' }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 38cf0fca6..7adbc56f4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -8,10 +8,13 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; +import { FilesService } from '../files/files.service'; +import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { BookingContractService } from './booking-contract.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; +import { clearanceCodesForBooking } from './clearance.util'; import { computeNextStep, type BookingNextStep } from './booking-next-step.util'; import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { PriceLineItemDto } from './dto/generate-price-response.dto'; @@ -25,6 +28,8 @@ export class BookingTransitionService { private readonly ruleEngineService: RuleEngineService, private readonly pricingService: BookingPricingService, private readonly contractService: BookingContractService, + private readonly filesService: FilesService, + private readonly fileUploadSettingsService: FileUploadSettingsService, @Inject(forwardRef(() => BookingsService)) private readonly bookingsService: BookingsService, ) {} @@ -446,6 +451,289 @@ export class BookingTransitionService { return this.bookingsService.findById(updated!.id); } + // ── Document clearance gate (post counter-sign) ─────────────────────────── + + /** + * The clearance document grid for a booking: each required field from the + * resolved customer-input set (and the GL-output set for customs) with its + * uploaded file and GL review status. Drives both portals' clearance UI. + */ + async getClearanceView(bookingId: string): Promise<{ + status: string; + includesCustoms: boolean; + inputCode: string | null; + outputCode: string | null; + documents: Array<{ + fileKey: string; + label: string; + required: boolean; + uploadedBy: 'customer' | 'gl'; + settingCode: string; + file: { id: string; name: string; url: string } | null; + reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null; + note: string | null; + }>; + allApproved: boolean; + }> { + const booking = await this.bookingsService.findById(bookingId); + const { inputCode, outputCode, includesCustoms } = + clearanceCodesForBooking(booking); + + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const fileByCode = new Map(files.map((f) => [f.code, f])); + const reviews = await this.bookingsRepository.findDocumentReviews(bookingId); + const reviewByKey = new Map( + reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]), + ); + + const documents: Awaited< + ReturnType + >['documents'] = []; + + const pushSetting = async ( + code: string | null, + uploadedBy: 'customer' | 'gl', + ) => { + if (!code) return; + let setting; + try { + setting = await this.fileUploadSettingsService.getByCode(code); + } catch { + return; // setting not seeded — skip gracefully + } + for (const field of setting.fields ?? []) { + const file = fileByCode.get(field.fileKey) ?? null; + const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null; + documents.push({ + fileKey: field.fileKey, + label: field.fileLabel, + required: field.isRequired, + uploadedBy, + settingCode: code, + file: file + ? { id: file.id, name: file.name, url: file.url } + : null, + reviewStatus: review?.status ?? null, + note: review?.note ?? null, + }); + } + }; + + await pushSetting(inputCode, 'customer'); + await pushSetting(outputCode, 'gl'); + + // Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set. + for (const f of files) { + if (!f.code?.startsWith('custom_')) continue; + const review = reviewByKey.get(`custom:${f.code}`) ?? null; + documents.push({ + fileKey: f.code, + label: f.name, + required: false, + uploadedBy: 'customer', + settingCode: 'custom', + file: { id: f.id, name: f.name, url: f.url }, + reviewStatus: review?.status ?? null, + note: review?.note ?? null, + }); + } + + const allApproved = await this.isClearanceFullyApproved(booking); + + return { + status: booking.status, + includesCustoms, + inputCode, + outputCode, + documents, + allApproved, + }; + } + + /** + * True when every REQUIRED field of the booking's customer-input clearance set + * has an APPROVED review row. The 100% gate before clearance can be finalized. + */ + private async isClearanceFullyApproved(booking: Booking): Promise { + const { inputCode } = clearanceCodesForBooking(booking); + if (!inputCode) return true; // no gate applies (e.g. domestic) + let setting; + try { + setting = await this.fileUploadSettingsService.getByCode(inputCode); + } catch { + return false; + } + const required = (setting.fields ?? []).filter((f) => f.isRequired); + if (required.length === 0) return true; + const reviews = await this.bookingsRepository.findDocumentReviews(booking.id); + return required.every((field) => + reviews.some( + (r) => + r.settingCode === inputCode && + r.fileKey === field.fileKey && + r.status === 'APPROVED', + ), + ); + } + + /** + * Customer uploads clearance documents. Each multipart file's fieldname is the + * field's fileKey (or custom_ for ad-hoc). Saves FileRecords, refreshes the + * per-document review rows to PENDING, and moves the booking into review. + */ + async submitClearanceDocuments( + bookingId: string, + files: Express.Multer.File[], + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']); + const { inputCode } = clearanceCodesForBooking(booking); + if (!inputCode) { + throw new BadRequestException('This booking has no document-clearance step'); + } + if (files.length === 0) { + throw new BadRequestException('No documents uploaded'); + } + + for (const file of files) { + const record = await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: file.fieldname, + file, + }); + // Ad-hoc docs (custom_*) are not part of the required gate; still tracked. + const settingCode = file.fieldname.startsWith('custom_') + ? 'custom' + : inputCode; + await this.bookingsRepository.upsertDocumentReviewPending({ + bookingId, + settingCode, + fileKey: file.fieldname, + fileRecordId: record.id, + }); + } + + await this.bookingsRepository.update(bookingId, { + status: 'DOCUMENTS_UNDER_REVIEW', + } as never); + return this.bookingsService.findById(bookingId); + } + + /** GL reviews a single document: APPROVED or QUERIED (with a note). */ + async reviewDocument( + bookingId: string, + fileKey: string, + status: 'APPROVED' | 'QUERIED', + staffId: string, + note?: string, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + const { inputCode, outputCode } = clearanceCodesForBooking(booking); + + const existing = await this.bookingsRepository.findDocumentReviews(bookingId); + const match = existing.find((r) => r.fileKey === fileKey); + const settingCode = + match?.settingCode ?? + (fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom')); + + if (status === 'QUERIED' && !note?.trim()) { + throw new BadRequestException('A note is required when querying a document'); + } + + await this.bookingsRepository.setDocumentReviewStatus( + bookingId, + settingCode, + fileKey, + status, + staffId, + note, + ); + if (status === 'QUERIED') { + await this.bookingsRepository.createReviewNote( + bookingId, + `Document "${fileKey}" queried: ${note}`, + 'CHANGES_REQUESTED', + staffId, + ); + } + return this.bookingsService.findById(bookingId); + } + + /** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */ + async uploadClearanceOutputDocuments( + bookingId: string, + files: Express.Multer.File[], + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + const { outputCode } = clearanceCodesForBooking(booking); + if (!outputCode) { + throw new BadRequestException('This booking has no customs output documents'); + } + if (files.length === 0) { + throw new BadRequestException('No documents uploaded'); + } + for (const file of files) { + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: file.fieldname, + file, + }); + } + return this.bookingsService.findById(bookingId); + } + + /** + * GL confirms clearance: requires every customer document APPROVED (100% gate) + * and, for customs, the required output documents present → CLEARANCE_READY. + */ + async finalizeClearance(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + + const approved = await this.isClearanceFullyApproved(booking); + if (!approved) { + throw new BadRequestException( + 'All required documents must be approved before clearance can be finalized', + ); + } + + const { outputCode } = clearanceCodesForBooking(booking); + if (outputCode) { + const setting = await this.fileUploadSettingsService.getByCode(outputCode); + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const uploaded = new Set(files.map((f) => f.code)); + const missing = (setting.fields ?? []).filter( + (f) => f.isRequired && !uploaded.has(f.fileKey), + ); + if (missing.length > 0) { + throw new BadRequestException( + `Upload all required customs output documents first: ${missing + .map((m) => m.fileLabel) + .join(', ')}`, + ); + } + } + + await this.bookingsRepository.update(bookingId, { + status: 'CLEARANCE_READY', + } as never); + return this.bookingsService.findById(bookingId); + } + + /** Customer proceeds to operation once clearance is ready → OPERATION_REQUESTED. */ + async requestOperation(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ['CLEARANCE_READY']); + await this.bookingsRepository.update(bookingId, { + status: 'OPERATION_REQUESTED', + } as never); + return this.bookingsService.findById(bookingId); + } + async enrichBookingResponse(booking: Booking): Promise { return pending === 0; } + // ── Clearance document reviews ──────────────────────────────────────────── + + findDocumentReviews(bookingId: string): Promise { + return this.dataSource.getRepository(BookingDocumentReview).find({ + where: { bookingId }, + order: { createdAt: 'ASC' }, + }); + } + + findDocumentReview( + bookingId: string, + settingCode: string, + fileKey: string, + ): Promise { + return this.dataSource.getRepository(BookingDocumentReview).findOne({ + where: { bookingId, settingCode, fileKey }, + }); + } + + /** + * Upsert a document-review row to PENDING for a freshly uploaded file. Resets + * any prior QUERIED/APPROVED state so the GL re-reviews the new upload. + */ + async upsertDocumentReviewPending(input: { + bookingId: string; + settingCode: string; + fileKey: string; + fileRecordId: string; + }): Promise { + const repo = this.dataSource.getRepository(BookingDocumentReview); + const existing = await repo.findOne({ + where: { + bookingId: input.bookingId, + settingCode: input.settingCode, + fileKey: input.fileKey, + }, + }); + if (existing) { + await repo.update(existing.id, { + fileRecordId: input.fileRecordId, + status: 'PENDING', + note: null, + reviewedByStaffId: null, + reviewedAt: null, + }); + return; + } + await repo.save(repo.create({ ...input, status: 'PENDING' })); + } + + /** GL marks a document APPROVED or QUERIED (with an optional note). */ + async setDocumentReviewStatus( + bookingId: string, + settingCode: string, + fileKey: string, + status: DocumentReviewStatus, + staffId: string, + note?: string, + ): Promise { + const repo = this.dataSource.getRepository(BookingDocumentReview); + const existing = await repo.findOne({ + where: { bookingId, settingCode, fileKey }, + }); + const patch = { + status, + note: note ?? null, + reviewedByStaffId: staffId, + reviewedAt: new Date(), + }; + if (existing) { + await repo.update(existing.id, patch); + return; + } + await repo.save(repo.create({ bookingId, settingCode, fileKey, ...patch })); + } + /** Persist cargo modifiers linked to rate snapshots. */ async createCargoModifiers( rows: Array<{ diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts new file mode 100644 index 000000000..a7bd13c28 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts @@ -0,0 +1,49 @@ +import { + clearanceSettingCode, + clearanceOutputSettingCode, +} from './clearance.util'; + +describe('clearance.util — clearanceSettingCode', () => { + it('resolves import container with/without customs', () => { + expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe( + 'clearance_import_container_with_customs', + ); + expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe( + 'clearance_import_container_without_customs', + ); + }); + + it('resolves export bulk with/without customs', () => { + expect(clearanceSettingCode('EXPORT', 'BULK', true)).toBe( + 'clearance_export_bulk_with_customs', + ); + expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe( + 'clearance_export_bulk_without_customs', + ); + }); + + it('returns null for DOMESTIC (no clearance gate)', () => { + expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull(); + expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBeNull(); + }); +}); + +describe('clearance.util — clearanceOutputSettingCode', () => { + it('returns a container output code only for customs container bookings', () => { + expect(clearanceOutputSettingCode('IMPORT', 'CONTAINER', true)).toBe( + 'clearance_output_import_container', + ); + expect(clearanceOutputSettingCode('EXPORT', 'CONTAINER', true)).toBe( + 'clearance_output_export_container', + ); + }); + + it('returns null without customs', () => { + expect(clearanceOutputSettingCode('IMPORT', 'CONTAINER', false)).toBeNull(); + }); + + it('returns null for bulk (no container output set) and domestic', () => { + expect(clearanceOutputSettingCode('IMPORT', 'BULK', true)).toBeNull(); + expect(clearanceOutputSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts new file mode 100644 index 000000000..69a8232d7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -0,0 +1,70 @@ +import { Booking } from './entities/booking.entity'; + +/** + * Resolves which seeded clearance FileUploadSetting applies to a booking, from + * its trade direction, freight type and whether its service includes customs. + * Mirrors the codes seeded in file-upload-settings.seeder.ts. + */ + +type Op = 'import' | 'export'; +type Freight = 'container' | 'bulk'; + +/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */ +function operationFor(tradeDirection: string): Op | null { + if (tradeDirection === 'IMPORT') return 'import'; + if (tradeDirection === 'EXPORT') return 'export'; + return null; // DOMESTIC / intercity — no clearance gate +} + +function freightFor(freightType: string): Freight { + return freightType === 'BULK' ? 'bulk' : 'container'; +} + +/** The customer-input clearance setting code, or null when no gate applies. */ +export function clearanceSettingCode( + tradeDirection: string, + freightType: string, + includesCustoms: boolean, +): string | null { + const op = operationFor(tradeDirection); + if (!op) return null; + const freight = freightFor(freightType); + const customs = includesCustoms ? 'with_customs' : 'without_customs'; + return `clearance_${op}_${freight}_${customs}`; +} + +/** The GL-output (customs output) setting code; only container customs sets exist. */ +export function clearanceOutputSettingCode( + tradeDirection: string, + freightType: string, + includesCustoms: boolean, +): string | null { + if (!includesCustoms) return null; + const op = operationFor(tradeDirection); + if (!op) return null; + // Only container customs output sets are seeded for this phase. + if (freightFor(freightType) !== 'container') return null; + return `clearance_output_${op}_container`; +} + +/** Convenience: resolve both codes for a loaded booking (with its serviceType). */ +export function clearanceCodesForBooking(booking: Booking): { + inputCode: string | null; + outputCode: string | null; + includesCustoms: boolean; +} { + const includesCustoms = booking.serviceType?.includesCustoms ?? false; + return { + inputCode: clearanceSettingCode( + booking.tradeDirection, + booking.freightType, + includesCustoms, + ), + outputCode: clearanceOutputSettingCode( + booking.tradeDirection, + booking.freightType, + includesCustoms, + ), + includesCustoms, + }; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index 698c5f98c..6e6a52b03 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, MinLength } from 'class-validator'; +import { IsIn, IsOptional, IsString, MinLength } from 'class-validator'; export class RequestChangesDto { @ApiProperty({ description: 'Staff note explaining what the customer must fix' }) @@ -43,3 +43,19 @@ export class RejectBookingDto { @IsString() reason?: string; } + +export class ReviewDocumentDto { + @ApiProperty({ description: 'The document fileKey being reviewed' }) + @IsString() + @MinLength(1) + fileKey!: string; + + @ApiProperty({ enum: ['APPROVED', 'QUERIED'] }) + @IsIn(['APPROVED', 'QUERIED']) + status!: 'APPROVED' | 'QUERIED'; + + @ApiPropertyOptional({ description: 'Required when querying a document' }) + @IsOptional() + @IsString() + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-document-review.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-document-review.entity.ts new file mode 100644 index 000000000..532e46610 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-document-review.entity.ts @@ -0,0 +1,51 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from './booking.entity'; + +export const DOCUMENT_REVIEW_STATUSES = ['PENDING', 'APPROVED', 'QUERIED'] as const; +export type DocumentReviewStatus = (typeof DOCUMENT_REVIEW_STATUSES)[number]; + +/** + * Per-document GL review for the post-counter-sign clearance gate. One row per + * required clearance document (keyed by fileKey within a setting). GL marks each + * APPROVED or QUERIED (with a note); the booking can only proceed once every + * required customer document is APPROVED. A QUERIED row returns to PENDING when + * the customer re-uploads that file. + */ +@Entity({ schema: 'freight', name: 'booking_document_review' }) +@Index(['bookingId']) +@Index(['status']) +@Index(['bookingId', 'settingCode', 'fileKey'], { unique: true }) +export class BookingDocumentReview extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + /** The clearance setting this document belongs to (e.g. clearance_import_container_with_customs). */ + @Column({ name: 'setting_code', type: 'varchar', length: 128 }) + settingCode!: string; + + /** The required document's stable key within the setting (e.g. commercial_invoice). */ + @Column({ name: 'file_key', type: 'varchar', length: 128 }) + fileKey!: string; + + /** The uploaded FileRecord backing this review row (null until uploaded). */ + @Column({ name: 'file_record_id', type: 'uuid', nullable: true }) + fileRecordId?: string | null; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' }) + status!: DocumentReviewStatus; + + /** GL note explaining a QUERIED status. */ + @Column({ name: 'note', type: 'text', nullable: true }) + note?: string | null; + + @Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true }) + reviewedByStaffId?: string | null; + + @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) + reviewedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 1b5a9acae..3ec08eee2 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -43,6 +43,11 @@ export const BOOKING_STATUSES = [ 'CONSOLIDATED', 'CONTRACT_ACTIVE', 'CONTRACT_CLOSED', + // Post counter-sign document-clearance gate (GL workflow). + 'AWAITING_DOCUMENTS', + 'DOCUMENTS_UNDER_REVIEW', + 'CLEARANCE_READY', + 'OPERATION_REQUESTED', ] as const; export type BookingStatus = (typeof BOOKING_STATUSES)[number]; diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 340a291db..bb497f8f8 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -192,6 +192,169 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [ const COMPANY_ONBOARDING_DESCRIPTION = "Required documents for external company onboarding, by company nationality."; +// ── Clearance document settings ──────────────────────────────────────────── +// Operation/clearance documents collected after contract counter-sign, resolved +// at runtime from (operationType, freightType, includesCustoms). The `entity` +// is "booking_clearance" so the backoffice file-settings editor can filter them. +// Two kinds of set per customs category: a CUSTOMER-INPUT set (the customer +// uploads) and a GL-OUTPUT set (Global Logistics uploads the customs outputs). + +const JPG_EXTENSIONS = ["jpg", "jpeg", "png", "pdf"]; +const CLEARANCE_ENTITY = "booking_clearance"; + +/** Build a clearance field with sensible defaults; `critical` marks isRequired. */ +function clearanceField( + fileKey: string, + fileLabel: string, + displayOrder: number, + opts?: { required?: boolean; help?: string; extensions?: string[] }, +): OnboardingField { + return { + fileKey, + fileLabel, + helpText: opts?.help ?? "", + isRequired: opts?.required ?? true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: opts?.extensions ?? DOC_EXTENSIONS, + maxSizeMb: 10, + displayOrder, + }; +} + +/** Documents shared by every container import category (with/without customs). */ +const IMPORT_CONTAINER_FIELDS: OnboardingField[] = [ + clearanceField("commercial_invoice", "Commercial Invoice", 1), + clearanceField("packing_list", "Packing List", 2), + clearanceField("import_license", "Import License", 3), + clearanceField("certificate_of_origin", "Certificate of Origin", 4), + clearanceField( + "external_freight_cost", + "External Freight Cost / Checkup Documentation", + 5, + ), + clearanceField("bill_of_lading", "Bill of Lading / Railway Bill", 6), + clearanceField("vgm", "Verified Gross Mass (VGM)", 7, { required: true }), + clearanceField("release_order", "Release Order", 8, { required: true }), +]; + +/** Documents shared by every container export category (with/without customs). */ +const EXPORT_CONTAINER_FIELDS: OnboardingField[] = [ + clearanceField("booking_confirmation", "Booking Confirmation", 1), + clearanceField("commercial_invoice", "Commercial Invoice", 2), + clearanceField("packing_list", "Packing List", 3), + clearanceField("shipping_instruction", "Shipping Instruction", 4), + clearanceField("bank_permit", "Bank Permit", 5), + clearanceField("export_license", "Export License", 6), + clearanceField("vgm_letter", "VGM Letter", 7, { required: true }), + clearanceField("railway_bill", "Railway Bill", 8), + clearanceField("delegation_letter", "Delegation Letter / POA", 9, { + required: false, + help: "Required only if EDR manages all transit activity.", + }), +]; + +/** Bulk import documents (shorter, transit-focused set). */ +const IMPORT_BULK_FIELDS: OnboardingField[] = [ + clearanceField("packing_list", "Packing List", 1, { required: true }), + clearanceField("bill_of_loading", "Bill of Loading", 2, { required: true }), + clearanceField("port_invoice", "Port Invoice", 3), +]; + +/** Bulk export documents (transit/customs corridor docs). */ +const EXPORT_BULK_FIELDS: OnboardingField[] = [ + clearanceField("release_order_djibouti", "Release Order (Djibouti)", 1), + clearanceField("port_gate_pass", "Port Gate Pass", 2), + clearanceField("port_invoice", "Port Invoice", 3), +]; + +/** GL-uploaded customs output documents (import container). */ +const IMPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [ + clearanceField("im4", "IM4 — Permanent Import Document", 1), + clearanceField("im5", "IM5 — Temporary Import Document", 2, { + required: false, + }), + clearanceField("transit_permitted", "Transit Permitted Screenshot", 3, { + extensions: JPG_EXTENSIONS, + }), +]; + +/** GL-uploaded customs output documents (export container). */ +const EXPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [ + clearanceField("ex3", "EX3 — Permanent Export Document", 1), + clearanceField("ex8", "EX8 — Export Transit Document", 2), + clearanceField("export_release", "Export Release", 3), + clearanceField("t1", "T1 — Transport Document", 4), +]; + +const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ + // ── Customer-input sets ── + { + code: "clearance_import_container_with_customs", + label: "Import container clearance documents (with customs)", + entity: CLEARANCE_ENTITY, + fields: IMPORT_CONTAINER_FIELDS, + }, + { + code: "clearance_import_container_without_customs", + label: "Import container documents (without customs)", + entity: CLEARANCE_ENTITY, + fields: IMPORT_CONTAINER_FIELDS, + }, + { + code: "clearance_export_container_with_customs", + label: "Export container clearance documents (with customs)", + entity: CLEARANCE_ENTITY, + fields: EXPORT_CONTAINER_FIELDS, + }, + { + code: "clearance_export_container_without_customs", + label: "Export container documents (without customs)", + entity: CLEARANCE_ENTITY, + fields: EXPORT_CONTAINER_FIELDS, + }, + { + code: "clearance_import_bulk_with_customs", + label: "Import bulk clearance documents (with customs)", + entity: CLEARANCE_ENTITY, + fields: IMPORT_BULK_FIELDS, + }, + { + code: "clearance_import_bulk_without_customs", + label: "Import bulk documents (without customs)", + entity: CLEARANCE_ENTITY, + fields: IMPORT_BULK_FIELDS, + }, + { + code: "clearance_export_bulk_with_customs", + label: "Export bulk clearance documents (with customs)", + entity: CLEARANCE_ENTITY, + fields: EXPORT_BULK_FIELDS, + }, + { + code: "clearance_export_bulk_without_customs", + label: "Export bulk documents (without customs)", + entity: CLEARANCE_ENTITY, + fields: EXPORT_BULK_FIELDS, + }, + // ── GL-output sets (customs only) ── + { + code: "clearance_output_import_container", + label: "Customs output documents (import container)", + entity: CLEARANCE_ENTITY, + fields: IMPORT_CONTAINER_OUTPUT_FIELDS, + }, + { + code: "clearance_output_export_container", + label: "Customs output documents (export container)", + entity: CLEARANCE_ENTITY, + fields: EXPORT_CONTAINER_OUTPUT_FIELDS, + }, +]; + +const CLEARANCE_DESCRIPTION = + "Operation/clearance documents collected after contract execution, by operation, freight type and customs."; + @Injectable() export class FileUploadSettingsSeeder { private readonly logger = new Logger(FileUploadSettingsSeeder.name); @@ -203,12 +366,25 @@ export class FileUploadSettingsSeeder { const settingRepository = manager.getRepository(FileUploadSetting); const fieldRepository = manager.getRepository(FileUploadField); - for (const documentSetting of COMPANY_ONBOARDING_DOCUMENTS) { + const allSettings: Array< + OnboardingDocumentSetting & { description: string } + > = [ + ...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({ + ...s, + description: COMPANY_ONBOARDING_DESCRIPTION, + })), + ...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: CLEARANCE_DESCRIPTION, + })), + ]; + + for (const documentSetting of allSettings) { await settingRepository.upsert( { code: documentSetting.code, label: documentSetting.label, - description: COMPANY_ONBOARDING_DESCRIPTION, + description: documentSetting.description, entity: documentSetting.entity, }, { @@ -245,7 +421,7 @@ export class FileUploadSettingsSeeder { }); this.logger.log( - "Ensured company onboarding file upload settings for external companies", + "Ensured company onboarding + booking clearance file upload settings", ); } } diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index ed0a494ab..b7027a487 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -52,6 +52,9 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ 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'), + perm('a1000001-0001-4000-8000-000000000020', 'edr_freight_app:bookings:review_documents', 'Review clearance documents'), + perm('a1000001-0001-4000-8000-000000000021', 'edr_freight_app:bookings:upload_clearance_output', 'Upload customs output documents'), + perm('a1000001-0001-4000-8000-000000000022', 'edr_freight_app:bookings:finalize_clearance', 'Finalize document clearance'), perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'), perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'), perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'), @@ -107,6 +110,9 @@ export const FREIGHT_PERMS = { signStaff: 'edr_freight_app:bookings:sign_staff', operations: 'edr_freight_app:bookings:operations', cancel: 'edr_freight_app:bookings:cancel', + reviewDocuments: 'edr_freight_app:bookings:review_documents', + uploadClearanceOutput: 'edr_freight_app:bookings:upload_clearance_output', + finalizeClearance: 'edr_freight_app:bookings:finalize_clearance', }, trainScheduling: { view: 'edr_freight_app:train_scheduling:view', @@ -167,6 +173,14 @@ export const ROLE_PERMISSION_PRESETS = { ...allRuleEngineViewKeys(), ], finance: [FREIGHT_PERMS.bookings.view], + // Global Logistics: reviews post-counter-sign clearance documents, uploads + // customs output documents, and finalizes the clearance gate. + globalLogistics: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.reviewDocuments, + FREIGHT_PERMS.bookings.uploadClearanceOutput, + FREIGHT_PERMS.bookings.finalizeClearance, + ], // Marketing handles intake through contract (same as line staff here). marketing: [ FREIGHT_PERMS.bookings.view, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index e91a8c810..462c97f9a 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -12,6 +12,7 @@ import { Paperclip, Send, Settings, + ShieldCheck, SlidersHorizontal, Train, Truck, @@ -27,6 +28,7 @@ import LoginPage from "./pages/auth/LoginPage"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; +import GlClearancePage from "./pages/bookings/GlClearancePage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; @@ -109,6 +111,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { title: "Operations", items: [ + { + label: "Document Clearance", + href: "/dashboard/clearance", + icon: , + permission: FREIGHT_PERMS.bookings.reviewDocuments, + }, { label: "Train Schedules", href: "/dashboard/operations/train-scheduling-v2", @@ -376,6 +384,14 @@ const App = () => { path="booking-requests/:id/contract" element={} /> + + + + } + /> } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 7f3b2ac90..cd127fa61 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -15,6 +15,9 @@ export const FREIGHT_PERMS = { signStaff: "edr_freight_app:bookings:sign_staff", operations: "edr_freight_app:bookings:operations", cancel: "edr_freight_app:bookings:cancel", + reviewDocuments: "edr_freight_app:bookings:review_documents", + uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output", + finalizeClearance: "edr_freight_app:bookings:finalize_clearance", }, trainScheduling: { view: "edr_freight_app:train_scheduling:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx new file mode 100644 index 000000000..6888f71c6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx @@ -0,0 +1,399 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Alert, + Box, + Button, + Card, + FileButton, + Group, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { + AlertCircle, + CheckCircle2, + Clock, + Download, + FileText, + ShieldCheck, + Upload, +} from "lucide-react"; +import toast from "react-hot-toast"; +import type { Freight } from "@edr/types"; + +import { bookingsService } from "@/services/bookings.service"; + +const REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW"; + +export default function GlClearancePage() { + const qc = useQueryClient(); + const [selectedId, setSelectedId] = useState(null); + + // Bookings currently awaiting GL document review. + const { data: list, isLoading } = useQuery({ + queryKey: ["gl-clearance", "list"], + queryFn: () => bookingsService.list({ status: REVIEW_STATUS, pageSize: 100 }), + }); + + const bookings = list?.items ?? []; + const activeId = selectedId ?? bookings[0]?.id ?? null; + + return ( + + + + + Document Clearance + + + +
+ + + Awaiting review ({bookings.length}) + + {isLoading && ( + + Loading… + + )} + {!isLoading && bookings.length === 0 && ( + + No bookings awaiting document review. + + )} + + {bookings.map((b) => ( + + ))} + + + + + {activeId ? ( + + qc.invalidateQueries({ queryKey: ["gl-clearance", "list"] }) + } + /> + ) : ( + + Select a booking to review its documents. + + )} + +
+
+ ); +} + +function ClearanceReviewPanel({ + bookingId, + onChanged, +}: { + bookingId: string; + onChanged: () => void; +}) { + const qc = useQueryClient(); + const [queryNotes, setQueryNotes] = useState>({}); + const [outputFiles, setOutputFiles] = useState>({}); + + const { data: clearance, isLoading } = useQuery({ + queryKey: ["gl-clearance", bookingId], + queryFn: () => bookingsService.getClearance(bookingId), + }); + + const refresh = () => { + qc.invalidateQueries({ queryKey: ["gl-clearance", bookingId] }); + onChanged(); + }; + + const reviewMutation = useMutation({ + mutationFn: (p: { + fileKey: string; + status: "APPROVED" | "QUERIED"; + note?: string; + }) => bookingsService.reviewClearanceDocument(bookingId, p), + onSuccess: () => { + toast.success("Document updated"); + refresh(); + }, + onError: () => toast.error("Could not update document"), + }); + + const outputMutation = useMutation({ + mutationFn: () => bookingsService.uploadClearanceOutput(bookingId, outputFiles), + onSuccess: () => { + toast.success("Output documents uploaded"); + setOutputFiles({}); + refresh(); + }, + onError: () => toast.error("Upload failed"), + }); + + const finalizeMutation = useMutation({ + mutationFn: () => bookingsService.finalizeClearance(bookingId), + onSuccess: () => { + toast.success("Clearance finalized"); + refresh(); + }, + onError: (e) => + toast.error(e instanceof Error ? e.message : "Could not finalize clearance"), + }); + + const customerDocs = useMemo( + () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"), + [clearance], + ); + const glDocs = useMemo( + () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"), + [clearance], + ); + + if (isLoading || !clearance) { + return ( + + Loading clearance… + + ); + } + + return ( + + + + + Customer documents + + {clearance.allApproved ? ( + + + + All approved + + + ) : ( + + + + Review pending + + + )} + + + + {customerDocs.map((doc) => ( + + setQueryNotes((n) => ({ ...n, [doc.fileKey]: v })) + } + onApprove={() => + reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" }) + } + onQuery={() => + reviewMutation.mutate({ + fileKey: doc.fileKey, + status: "QUERIED", + note: queryNotes[doc.fileKey], + }) + } + busy={reviewMutation.isPending} + /> + ))} + + + + {clearance.outputCode && ( + + + Customs output documents + + + {glDocs.map((doc) => ( + + + + + {doc.label} + {doc.required ? " *" : ""} + + + + {doc.file ? ( + + + + ) : ( + + Not uploaded + + )} + + f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f })) + } + accept="application/pdf,image/*" + > + {(props) => ( + + )} + + + + ))} + + + + + + )} + + {finalizeMutation.isError && ( + }> + {finalizeMutation.error instanceof Error + ? finalizeMutation.error.message + : "Could not finalize clearance."} + + )} + + + + + + ); +} + +function DocReviewRow({ + doc, + note, + onNote, + onApprove, + onQuery, + busy, +}: { + doc: Freight.ClearanceDocument; + note: string; + onNote: (v: string) => void; + onApprove: () => void; + onQuery: () => void; + busy: boolean; +}) { + return ( + + + + + + + {doc.label} + {doc.required ? " *" : ""} + + + {doc.file ? doc.file.name : "Not uploaded"} + + + + + {doc.reviewStatus === "APPROVED" && ( + + Approved + + )} + {doc.reviewStatus === "QUERIED" && ( + + Queried + + )} + {doc.file && ( + + + + )} + + + + {doc.file && ( + + onNote(e.currentTarget.value)} + style={{ flex: 1 }} + radius="md" + size="xs" + /> + + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index 06dbb638b..ad71655bd 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -2,6 +2,7 @@ import { api as client } from "../auth/http"; import { unwrap } from "@/utils/endpoint"; import { URL_CONSTANTS } from "@/constants/URLS"; import type { BookingDetail } from "@/types/booking"; +import type { Freight } from "@edr/types"; const B = URL_CONSTANTS.BOOKINGS; @@ -169,6 +170,36 @@ export const bookingsService = { await client.delete(B.BY_ID(id)); }, + // ── Document clearance (GL workflow) ── + getClearance: async (id: string): Promise => { + const response = await client.get(`/bookings/${id}/clearance`); + return unwrap(response.data) as Freight.ClearanceView; + }, + + reviewClearanceDocument: ( + id: string, + payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string }, + ) => postBooking(`/bookings/${id}/clearance/review`, payload), + + uploadClearanceOutput: async ( + id: string, + files: Record, + ): Promise => { + const form = new FormData(); + for (const [key, file] of Object.entries(files)) { + if (file) form.append(key, file); + } + const response = await client.post( + `/bookings/${id}/clearance/output-documents`, + form, + { headers: { "Content-Type": "multipart/form-data" } }, + ); + return unwrap(response.data) as BookingDetail; + }, + + finalizeClearance: (id: string) => + postBooking(`/bookings/${id}/clearance/finalize`), + staffAccept: (id: string) => postBooking(B.STAFF_ACCEPT(id)), requestChanges: (id: string, note: string) => diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index ca7346a83..43f9a1c00 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -9,6 +9,7 @@ import { paymentsService, type PaymentMethod } from "@/services/payments.service import type { Freight } from "@edr/types"; import { ActivityCard } from "./components/ActivityCard"; +import { ClearanceCard } from "./components/ClearanceCard"; import { ContainersCard } from "./components/ContainersCard"; import { ContractCard } from "./components/ContractCard"; import { DocRow, IconSquare } from "./components/Documents"; @@ -62,6 +63,12 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) const showCountdown = canPay && !!booking.paymentDeadline; const isExpired = status === "EXPIRED"; const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; + const isClearance = [ + "AWAITING_DOCUMENTS", + "DOCUMENTS_UNDER_REVIEW", + "CLEARANCE_READY", + "OPERATION_REQUESTED", + ].includes(status); // Paired: a consolidation partner was found and the booking resumed the normal // flow. Surface the "partner found" reassurance only in the early stages, // before approval, so it doesn't linger for the rest of the booking's life. @@ -126,6 +133,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) + {isClearance && } + diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx new file mode 100644 index 000000000..59ab71ff9 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx @@ -0,0 +1,377 @@ +import { + Alert, + Box, + Button, + FileButton, + Group, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + AlertCircle, + CheckCircle2, + Clock, + Download, + FileText, + Plus, + Upload, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { api } from "@/services/api"; +import type { Freight } from "@edr/types"; + +import { CardTitle, SectionCard } from "./layout"; +import { IconSquare } from "./Documents"; + +const GREEN = "#0A6F4D"; + +function StatusPill({ doc }: { doc: Freight.ClearanceDocument }) { + if (doc.reviewStatus === "APPROVED") { + return ( + + + + Approved + + + ); + } + if (doc.reviewStatus === "QUERIED") { + return ( + + + + Queried + + + ); + } + if (doc.file) { + return ( + + + + Pending review + + + ); + } + return ( + + Not uploaded + + ); +} + +/** + * Customer-facing clearance section: shows the resolved document grid, lets the + * customer (re)upload pending/queried documents plus ad-hoc named documents, and + * proceed to operation once Global Logistics marks the booking CLEARANCE_READY. + */ +export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const status = booking.status as string; + + const { data: clearance, isLoading } = useQuery( + api.bookings.getClearance.queryOptions({ input: { id: booking.id } }), + ); + + // Pending uploads keyed by fileKey, plus ad-hoc rows (label + file). + const [pending, setPending] = useState>({}); + const [adHoc, setAdHoc] = useState>( + [], + ); + + const refresh = () => { + queryClient.invalidateQueries({ + queryKey: api.bookings.getClearance.queryKey({ id: booking.id }), + }); + queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: booking.id }), + }); + }; + + const uploadMutation = useMutation({ + ...api.bookings.submitClearanceDocuments.mutationOptions(), + onSuccess: () => { + setPending({}); + setAdHoc([]); + refresh(); + }, + }); + + const proceedMutation = useMutation({ + ...api.bookings.proceedToOperation.mutationOptions(), + onSuccess: () => refresh(), + }); + + // Only the customer-input documents are uploadable here; GL output docs are + // shown read-only. + const customerDocs = useMemo( + () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"), + [clearance], + ); + const glDocs = useMemo( + () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"), + [clearance], + ); + + if (status === "OPERATION_REQUESTED") { + return ( + + Operation + } mt="sm"> + Operation requested. An operator will take your shipment forward. + + + ); + } + + if (isLoading || !clearance) { + return ( + + Clearance documents + + Loading clearance… + + + ); + } + + const isReady = status === "CLEARANCE_READY"; + const canUpload = + status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW"; + + function handleSubmit() { + const files: Record = { ...pending }; + adHoc.forEach((row, i) => { + if (row.file) files[`custom_${Date.now()}_${i}`] = row.file; + }); + if (Object.keys(files).length === 0) return; + uploadMutation.mutate({ id: booking.id, files }); + } + + return ( + + + Clearance documents + {clearance.includesCustoms && ( + + Customs clearance + + )} + + + {isReady ? ( + } mb="md"> + Clearance is ready. You can now proceed to operation. + + ) : status === "DOCUMENTS_UNDER_REVIEW" ? ( + } mb="md"> + Global Logistics is reviewing your documents. Queried documents below + need to be re-uploaded. + + ) : ( + } mb="md"> + Upload the documents below to start the clearance review. + + )} + + + {customerDocs.map((doc) => ( + + + + + + + + + {doc.label} + {doc.required ? " *" : ""} + + {doc.file && ( + + {doc.file.name} + + )} + + + + + {doc.file && ( + } /> + )} + {canUpload && doc.reviewStatus !== "APPROVED" && ( + + f && setPending((p) => ({ ...p, [doc.fileKey]: f })) + } + accept="application/pdf,image/*" + > + {(props) => ( + + )} + + )} + + + {doc.reviewStatus === "QUERIED" && doc.note && ( + + Query: {doc.note} + + )} + {pending[doc.fileKey] && ( + + Ready to upload: {pending[doc.fileKey].name} + + )} + + ))} + + + {/* GL output documents (read-only to the customer). */} + {glDocs.length > 0 && ( + <> + + Customs output documents + + + {glDocs.map((doc) => ( + + + {doc.label} + + {doc.file ? ( + } /> + ) : ( + + Pending + + )} + + ))} + + + )} + + {/* Ad-hoc / additional documents. */} + {canUpload && ( + + + + Additional documents + + + + + {adHoc.map((row, i) => ( + + + setAdHoc((rows) => + rows.map((r, j) => + j === i ? { ...r, name: e.currentTarget.value } : r, + ), + ) + } + style={{ flex: 1 }} + radius="md" + /> + + setAdHoc((rows) => + rows.map((r, j) => (j === i ? { ...r, file: f } : r)), + ) + } + accept="application/pdf,image/*" + > + {(props) => ( + + )} + + + ))} + + + )} + + {uploadMutation.isError && ( + } mt="md"> + {uploadMutation.error instanceof Error + ? uploadMutation.error.message + : "Upload failed. Please try again."} + + )} + + + {canUpload && ( + + )} + {isReady && ( + + )} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 6048be28b..d00208d7b 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -256,6 +256,25 @@ export const api = { bookingsService.uploadDocuments(id, files), ), + getClearance: endpoint<{ id: string }, Freight.ClearanceView>( + "bookings", + "getClearance", + ({ id }) => bookingsService.getClearance(id), + ), + + submitClearanceDocuments: endpoint< + { id: string; files: Record }, + Freight.IBooking + >("bookings", "submitClearanceDocuments", ({ id, files }) => + bookingsService.submitClearanceDocuments(id, files), + ), + + proceedToOperation: endpoint<{ id: string }, Freight.IBooking>( + "bookings", + "proceedToOperation", + ({ id }) => bookingsService.proceedToOperation(id), + ), + checkPayment: endpoint<{ orderId: string }, { status: string }>( "bookings", "checkPayment", diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index e74f8281c..2cebf6fea 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -184,6 +184,33 @@ export const bookingsService = { return data.data; }, + // ── Document clearance ── + getClearance: async (id: string): Promise => { + const { data } = await client.get(`/api/bookings/${id}/clearance`); + return data.data ?? data; + }, + + submitClearanceDocuments: async ( + id: string, + files: Record, + ): Promise => { + const formData = new FormData(); + for (const [key, file] of Object.entries(files)) { + if (file) formData.append(key, file); + } + const { data } = await client.post( + `/api/bookings/${id}/clearance/documents`, + formData, + { headers: { "Content-Type": "multipart/form-data" } }, + ); + return data.data; + }, + + proceedToOperation: async (id: string): Promise => { + const { data } = await client.post(`/api/bookings/${id}/clearance/proceed`); + return data.data; + }, + getContractView: async (id: string): Promise => { const { data } = await client.get(B.CONTRACT_VIEW(id)); return data.data ?? data; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index b86e64dd8..62509f6e7 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -448,6 +448,34 @@ export interface PricingBreakdown { totalAmount: number; } +// ── Document clearance (post counter-sign GL workflow) ────────────────────── + +export type DocumentReviewStatus = "PENDING" | "APPROVED" | "QUERIED"; + +/** One row of the clearance document grid (a required doc + its file + review). */ +export interface ClearanceDocument { + fileKey: string; + label: string; + required: boolean; + /** Who supplies this document: the customer, or Global Logistics staff. */ + uploadedBy: "customer" | "gl"; + settingCode: string; + file: { id: string; name: string; url: string } | null; + reviewStatus: DocumentReviewStatus | null; + note: string | null; +} + +/** The clearance view for a booking, driving both portals' clearance UI. */ +export interface ClearanceView { + status: string; + includesCustoms: boolean; + inputCode: string | null; + outputCode: string | null; + documents: ClearanceDocument[]; + /** True once every required customer document is APPROVED (the 100% gate). */ + allApproved: boolean; +} + export interface IInvoice extends BaseEntity { bookingId: string; invoiceNumber: string;