diff --git a/apps/edr-freight-api/src/migrations/3600000000000-BookingClearanceEvent.ts b/apps/edr-freight-api/src/migrations/3600000000000-BookingClearanceEvent.ts new file mode 100644 index 000000000..ce7ef49ed --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3600000000000-BookingClearanceEvent.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** Per-booking clearance action history — drives the History tab. */ +export class BookingClearanceEvent3600000000000 implements MigrationInterface { + name = 'BookingClearanceEvent3600000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "freight"."booking_clearance_event" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + "booking_id" uuid NOT NULL, + "action" character varying(64) NOT NULL, + "label" character varying(500) NOT NULL, + "actor_type" character varying(16) NOT NULL DEFAULT 'STAFF', + "actor_id" uuid, + "actor_name" character varying(150), + "metadata" jsonb, + CONSTRAINT "pk_booking_clearance_event" PRIMARY KEY ("id"), + CONSTRAINT "fk_booking_clearance_event_booking" FOREIGN KEY ("booking_id") + REFERENCES "freight"."bookings"("id") ON DELETE CASCADE + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_booking_clearance_event_booking_created" + ON "freight"."booking_clearance_event" ("booking_id", "created_at") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS "freight"."booking_clearance_event"`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts index a0c82fa4d..deda35e1c 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -47,6 +47,10 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"], "POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"], "POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"], + "PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"], + "POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"], + "POST /api/bookings/:id/clearance/charges/miscellaneous": ["GL Ethiopia creates the miscellaneous clearance charge", "POST", "Booking"], "POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"], "POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"], "POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"], diff --git a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts index 88986735f..f41bff39d 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-clearance-charge.service.ts @@ -19,6 +19,7 @@ import { BookingClearanceCharge, ClearanceChargeType, } from './entities/booking-clearance-charge.entity'; +import { ClearanceEventService } from './clearance-event.service'; /** File-record codes the charge documents are stored under on the booking. */ const CHARGE_FILE_CODE: Record = { @@ -49,6 +50,7 @@ export class BookingClearanceChargeService { private readonly billing: BillingService, private readonly bookingsService: BookingsService, private readonly bookingsRepository: BookingsRepository, + private readonly clearanceEvents: ClearanceEventService, ) {} private repo() { @@ -165,6 +167,15 @@ export class BookingClearanceChargeService { }), ); } + await this.clearanceEvents.record({ + bookingId, + action: 'CHARGE_PORT_DOC_UPLOADED', + label: existing + ? 'Replaced the port-charges document' + : 'Uploaded the port-charges document', + actorId: staffId, + metadata: { fileName: file.originalname }, + }); return this.list(bookingId); } @@ -205,6 +216,20 @@ export class BookingClearanceChargeService { billedByStaffId: staffId, billedAt: new Date(), }); + await this.clearanceEvents.record({ + bookingId, + action: 'CHARGE_BILLED', + label: `${charge.status === 'SENT' ? 'Revised' : 'Billed'} ${CHARGE_LABEL[ + charge.type + ].toLowerCase()}: ${input.amount} ${input.currency.trim().toUpperCase()}`, + actorId: staffId, + metadata: { + chargeType: charge.type, + amount: input.amount, + currency: input.currency.trim().toUpperCase(), + revised: charge.status === 'SENT', + }, + }); return this.list(bookingId); } @@ -212,6 +237,7 @@ export class BookingClearanceChargeService { async sendCharge( bookingId: string, chargeId: string, + staffId?: string, ): Promise { const charge = await this.repo().findOne({ where: { id: chargeId, bookingId }, @@ -246,6 +272,18 @@ export class BookingClearanceChargeService { status: 'SENT', invoiceId: invoice.id, }); + await this.clearanceEvents.record({ + bookingId, + action: 'CHARGE_INVOICE_SENT', + label: `Sent ${CHARGE_LABEL[charge.type].toLowerCase()} invoice ${invoice.invoiceNumber} to the customer`, + actorId: staffId ?? null, + metadata: { + chargeType: charge.type, + invoiceNumber: invoice.invoiceNumber, + amount: Number(charge.amount), + currency: charge.currency, + }, + }); this.logger.log( `Clearance charge ${charge.type} on booking ${bookingId} sent as invoice ${invoice.invoiceNumber}`, ); @@ -311,6 +349,17 @@ export class BookingClearanceChargeService { billedAt: new Date(), }), ); + await this.clearanceEvents.record({ + bookingId, + action: 'CHARGE_MISC_CREATED', + label: `Created miscellaneous charge: ${input.amount} ${input.currency.trim().toUpperCase()}`, + actorId: staffId, + metadata: { + amount: input.amount, + currency: input.currency.trim().toUpperCase(), + fileName: file.originalname, + }, + }); return this.list(bookingId); } @@ -325,6 +374,16 @@ export class BookingClearanceChargeService { status: 'PAID', paidAt: new Date(), }); + await this.clearanceEvents.record({ + bookingId: charge.bookingId, + action: 'CHARGE_PAID', + label: `${CHARGE_LABEL[charge.type]} paid (invoice ${payload.invoiceNumber})`, + actorType: 'SYSTEM', + metadata: { + chargeType: charge.type, + invoiceNumber: payload.invoiceNumber, + }, + }); this.logger.log( `Clearance charge ${charge.type} on booking ${charge.bookingId} paid (invoice ${payload.invoiceNumber})`, ); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index 871d03c72..fd8e7192c 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -59,6 +59,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => { clearanceDocsUploadedToStaff: jest.fn(), dutySlipUploadedToStaff: jest.fn(), } as never, // notifier + { record: jest.fn() } as never, // clearanceEvents { emit: jest.fn() } as never, // events ); return { service, bookingsRepository, ruleEngineService, contractService }; 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 index b67366431..fbfb0daa4 100644 --- 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 @@ -68,6 +68,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => { clearanceDocsUploadedToStaff: jest.fn(), dutySlipUploadedToStaff: jest.fn(), } as never, // notifier + { record: jest.fn() } as never, // clearanceEvents { emit: jest.fn() } as never, // events ); return { service, bookingsRepository }; @@ -172,6 +173,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( clearanceDocsUploadedToStaff: jest.fn(), dutySlipUploadedToStaff: jest.fn(), } as never, // notifier + { record: jest.fn() } as never, // clearanceEvents { emit: jest.fn() } as never, // events ); return { service, bookingsRepository }; @@ -262,6 +264,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields clearanceDocsUploadedToStaff: jest.fn(), dutySlipUploadedToStaff: jest.fn(), } as never, // notifier + { record: jest.fn() } as never, // clearanceEvents { emit: jest.fn() } as never, // events ); return { service, bookingsRepository, filesService }; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index 0726445d5..b55c8d4b0 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -71,6 +71,7 @@ describe('BookingTransitionService — operation review', () => { clearanceDocsUploadedToStaff: jest.fn(), dutySlipUploadedToStaff: jest.fn(), } as never, // notifier + { record: jest.fn() } as never, // clearanceEvents { emit: jest.fn() } as never, // events ); return { service, bookingsRepository, bookingBatchService, invoiceService }; @@ -172,6 +173,7 @@ describe('BookingTransitionService — requestOperation export space gate', () = {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, notifier as never, + { record: jest.fn() } as never, // clearanceEvents { emit: jest.fn() } as never, // events ); return { service, bookingsRepository, bookingBatchService }; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts index 91b1bfd91..6496208e7 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts @@ -32,6 +32,7 @@ describe('BookingTransitionService — paired staff decisions', () => { {} as never, // invoiceService {} as never, // containerValidationService {} as never, // notifier + { record: jest.fn() } as never, // clearanceEvents {} as never, // events undefined, // milestoneService dataSource as never, 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 3dddb3bc1..0af0a657f 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 @@ -32,6 +32,7 @@ import { buildClearanceDocHistory, type ClearanceDocEvent, } from './clearance-doc-history.util'; +import { ClearanceEventService } from './clearance-event.service'; 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'; @@ -72,6 +73,7 @@ export class BookingTransitionService { private readonly invoiceService: BookingInvoiceService, private readonly containerValidationService: ContainerValidationService, private readonly notifier: BookingLifecycleNotifierService, + private readonly clearanceEvents: ClearanceEventService, private readonly events: EventEmitter2, @Optional() private readonly milestoneService?: ClearanceMilestoneService, // Optional + last so the hand-constructed service in *.spec.ts files keeps @@ -795,6 +797,7 @@ export class BookingTransitionService { async submitClearanceDocuments( bookingId: string, files: Express.Multer.File[], + userId?: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ @@ -853,6 +856,16 @@ export class BookingTransitionService { } as never); } + const fileKeys = files.map((f) => f.fieldname); + await this.clearanceEvents.record({ + bookingId, + action: 'DOCS_SUBMITTED', + label: `Customer submitted ${files.length} clearance document(s): ${fileKeys.join(', ')}`, + actorType: 'CUSTOMER', + actorId: userId ?? null, + metadata: { fileKeys }, + }); + const fresh = await this.bookingsService.findById(bookingId); this.notifier.clearanceDocsUploadedToStaff(fresh); return fresh; @@ -940,6 +953,16 @@ export class BookingTransitionService { staffId, note, ); + await this.clearanceEvents.record({ + bookingId, + action: status === 'APPROVED' ? 'DOC_APPROVED' : 'DOC_QUERIED', + label: + status === 'APPROVED' + ? `Approved document "${fileKey.replace(/_/g, ' ')}"` + : `Opened query on document "${fileKey.replace(/_/g, ' ')}"`, + actorId: staffId, + metadata: { fileKey, note: note ?? null }, + }); if (status === "QUERIED") { await this.bookingsRepository.createReviewNote( bookingId, @@ -980,6 +1003,7 @@ export class BookingTransitionService { async uploadClearanceOutputDocuments( bookingId: string, files: Express.Multer.File[], + userId?: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); @@ -1000,6 +1024,15 @@ export class BookingTransitionService { file, }); } + await this.clearanceEvents.record({ + bookingId, + action: 'OUTPUT_DOCS_UPLOADED', + label: `Uploaded customs output document(s): ${files + .map((f) => f.fieldname.replace(/_/g, ' ')) + .join(', ')}`, + actorId: userId ?? null, + metadata: { fileKeys: files.map((f) => f.fieldname) }, + }); return this.bookingsService.findById(bookingId); } @@ -1007,7 +1040,7 @@ export class BookingTransitionService { * 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 { + async finalizeClearance(bookingId: string, userId?: string): Promise { const booking = await this.bookingsService.findById(bookingId); if (this.isPhasedCustoms(booking)) { throw new BadRequestException( @@ -1063,6 +1096,12 @@ export class BookingTransitionService { await this.bookingsRepository.update(bookingId, { status: "CLEARANCE_READY", } as never); + await this.clearanceEvents.record({ + bookingId, + action: 'CLEARANCE_FINALIZED', + label: 'Finalized document approval — clearance ready', + actorId: userId ?? null, + }); const fresh = await this.bookingsService.findById(bookingId); this.notifier.clearanceReady(fresh); return fresh; @@ -1089,6 +1128,8 @@ export class BookingTransitionService { * the customer pools, so the gate here would wrongly reject them). */ bypassDayPool?: boolean; + /** Acting user, recorded in the clearance history. */ + userId?: string; }, ): Promise { const booking = await this.bookingsService.findById(bookingId); @@ -1203,6 +1244,14 @@ export class BookingTransitionService { scheduledDate: date, requestedTrainScheduleId: requestedId, } as never); + await this.clearanceEvents.record({ + bookingId, + action: 'OPERATION_REQUESTED', + label: `Requested operation for shipment day ${scheduledDate}`, + actorType: 'CUSTOMER', + actorId: opts?.userId ?? null, + metadata: { scheduledDate }, + }); const fresh = await this.bookingsService.findById(bookingId); this.notifier.operationRequestedToStaff(fresh); return fresh; diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 8c9ee3b41..b1be94016 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -40,6 +40,7 @@ import { import type { Response } from "express"; import { BookingClearanceChargeService } from './booking-clearance-charge.service'; +import { ClearanceEventService } from './clearance-event.service'; import { BillClearanceChargeDto } from './dto/clearance-charge.dto'; import { BookingContractService } from './booking-contract.service'; import { BookingPricingService } from './booking-pricing.service'; @@ -174,6 +175,7 @@ export class BookingsController { private readonly wagonCancellationService: BookingWagonCancellationService, private readonly consolidationApprovalService: ConsolidationApprovalService, private readonly clearanceChargeService: BookingClearanceChargeService, + private readonly clearanceEventService: ClearanceEventService, ) {} @Post() @@ -975,10 +977,12 @@ export class BookingsController { async submitClearanceDocuments( @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.submitClearanceDocuments( id, files ?? [], + resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @@ -995,11 +999,13 @@ export class BookingsController { async proceedToOperation( @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestOperationDto, + @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.requestOperation( id, dto.scheduledDate, dto.trainScheduleId ?? null, + { userId: resolveAuthUserId(user) }, ); return this.transitionService.enrichBookingResponse(booking); } @@ -1077,6 +1083,19 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Get(":id/clearance/history") + @BookingStaff([ + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.clearanceDjActions, + ]) + @ApiOperation({ + summary: + "Clearance action history for the booking — reviews, workflow steps, charges (newest first)", + }) + getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) { + return this.clearanceEventService.list(id); + } + // ── Clearance charges (post-finalization customer billing) ──────────────── @Get(":id/clearance/charges") @@ -1140,8 +1159,13 @@ export class BookingsController { sendClearanceCharge( @Param("id", ParseUUIDPipe) id: string, @Param("chargeId", ParseUUIDPipe) chargeId: string, + @CurrentUser() user: AuthUserPayload, ) { - return this.clearanceChargeService.sendCharge(id, chargeId); + return this.clearanceChargeService.sendCharge( + id, + chargeId, + resolveAuthUserId(user), + ); } @Post(":id/clearance/charges/miscellaneous") @@ -1175,10 +1199,12 @@ export class BookingsController { async uploadClearanceOutput( @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: AuthUserPayload, ) { const booking = await this.transitionService.uploadClearanceOutputDocuments( id, files ?? [], + resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); } @@ -1189,8 +1215,14 @@ export class BookingsController { summary: "GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", }) - async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) { - const booking = await this.transitionService.finalizeClearance(id); + async finalizeClearance( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + const booking = await this.transitionService.finalizeClearance( + id, + resolveAuthUserId(user), + ); return this.transitionService.enrichBookingResponse(booking); } @@ -1203,8 +1235,13 @@ export class BookingsController { async requestBookingTransitAssignee( @Param('id', ParseUUIDPipe) id: string, @Body('note') note: string | undefined, + @CurrentUser() user: AuthUserPayload, ) { - const booking = await this.bookingClearanceService.requestTransitAssignee(id, note); + const booking = await this.bookingClearanceService.requestTransitAssignee( + id, + note, + resolveAuthUserId(user), + ); return this.transitionService.enrichBookingResponse(booking); } @@ -1217,8 +1254,13 @@ export class BookingsController { async assignBookingTransitAssignee( @Param('id', ParseUUIDPipe) id: string, @Body('transitAgentId', ParseUUIDPipe) transitAgentId: string, + @CurrentUser() user: AuthUserPayload, ) { - const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId); + const booking = await this.bookingClearanceService.assignTransitAssignee( + id, + transitAgentId, + resolveAuthUserId(user), + ); return this.transitionService.enrichBookingResponse(booking); } @@ -1302,8 +1344,14 @@ export class BookingsController { summary: 'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia', }) - async acceptBookingDraftDeclaration(@Param('id', ParseUUIDPipe) id: string) { - const booking = await this.bookingClearanceService.acceptDraftDeclaration(id); + async acceptBookingDraftDeclaration( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + const booking = await this.bookingClearanceService.acceptDraftDeclaration( + id, + resolveAuthUserId(user), + ); return this.transitionService.enrichBookingResponse(booking); } @@ -1329,8 +1377,14 @@ export class BookingsController { @Post(':id/clearance/finalize-pre-clearance') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' }) - async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) { - const booking = await this.bookingClearanceService.finalizePreClearance(id); + async finalizeBookingPreClearance( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + const booking = await this.bookingClearanceService.finalizePreClearance( + id, + resolveAuthUserId(user), + ); return this.transitionService.enrichBookingResponse(booking); } @@ -1342,8 +1396,13 @@ export class BookingsController { async uploadBookingDutySlip( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: AuthUserPayload, ) { - const booking = await this.bookingClearanceService.uploadDutySlip(id, file); + const booking = await this.bookingClearanceService.uploadDutySlip( + id, + file, + resolveAuthUserId(user), + ); return this.transitionService.enrichBookingResponse(booking); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 5983f55f2..a522a6225 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -39,6 +39,8 @@ import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingDocumentReview } from './entities/booking-document-review.entity'; import { BookingClearanceCharge } from './entities/booking-clearance-charge.entity'; import { BookingClearanceChargeService } from './booking-clearance-charge.service'; +import { BookingClearanceEvent } from './entities/booking-clearance-event.entity'; +import { ClearanceEventService } from './clearance-event.service'; import { BookingContainer } from './entities/booking-container.entity'; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingContractSignature } from './entities/booking-contract-signature.entity'; @@ -79,6 +81,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; CustomerTruckContainer, ConsolidationApproval, BookingClearanceCharge, + BookingClearanceEvent, ]), BillingModule, DocumentsModule, @@ -115,6 +118,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingContractService, BookingInvoiceService, BookingClearanceChargeService, + ClearanceEventService, ContractTemplateResolver, ContractViewModelBuilder, ContractPricingScheduleBuilder, @@ -130,6 +134,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; exports: [ BookingsService, BookingsRepository, + ClearanceEventService, BookingPricingService, ContainerValidationService, BookingInvoiceService, diff --git a/apps/edr-freight-api/src/modules/bookings/clearance-event.service.ts b/apps/edr-freight-api/src/modules/bookings/clearance-event.service.ts new file mode 100644 index 000000000..cc5c1acab --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/clearance-event.service.ts @@ -0,0 +1,89 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { Freight } from '@edr/types'; + +import { resolveIamUserNames } from '../../common/utils/iam-user-name.util'; +import { + BookingClearanceEvent, + ClearanceEventActorType, +} from './entities/booking-clearance-event.entity'; + +export interface RecordClearanceEventInput { + bookingId: string; + action: string; + /** Human sentence for the History tab, frozen at write time. */ + label: string; + actorType?: ClearanceEventActorType; + /** IAM user id (staff or portal customer); name is resolved here. */ + actorId?: string | null; + metadata?: Record | null; + /** Join the caller's transaction so the event commits (or rolls back) with the action. */ + manager?: EntityManager; +} + +/** + * The clearance History tab's write/read path. Every clearance mutation calls + * {@link record} — document reviews, phased workflow steps, customer charges. + * Recording is deliberately NOT fire-and-forget: the insert shares the caller's + * transaction when a manager is passed, and otherwise a failed insert fails the + * action, because a silent gap in an audit trail is worse than a retry. + */ +@Injectable() +export class ClearanceEventService { + private readonly logger = new Logger(ClearanceEventService.name); + + constructor(private readonly dataSource: DataSource) {} + + async record(input: RecordClearanceEventInput): Promise { + const mg = input.manager ?? this.dataSource.manager; + const actorName = input.actorId + ? ((await resolveIamUserNames(this.dataSource, [input.actorId])).get( + input.actorId, + ) ?? null) + : null; + await mg.save( + mg.create(BookingClearanceEvent, { + bookingId: input.bookingId, + action: input.action, + label: input.label, + actorType: input.actorType ?? 'STAFF', + actorId: input.actorId ?? null, + actorName, + metadata: input.metadata ?? null, + }), + ); + this.logger.log( + `clearance-history ${input.action} on booking ${input.bookingId}${ + actorName ? ` by ${actorName}` : '' + }`, + ); + } + + /** History for one booking, newest first. */ + async list(bookingId: string): Promise { + const rows = await this.dataSource + .getRepository(BookingClearanceEvent) + .find({ where: { bookingId }, order: { createdAt: 'DESC' } }); + + // Rows whose actor name failed to resolve at write time get one more try. + const missing = rows + .filter((r) => !r.actorName && r.actorId) + .map((r) => r.actorId as string); + const names = missing.length + ? await resolveIamUserNames(this.dataSource, missing).catch( + () => new Map(), + ) + : new Map(); + + return rows.map((r) => ({ + id: r.id, + action: r.action, + label: r.label, + actorType: r.actorType, + actorName: + r.actorName ?? (r.actorId ? (names.get(r.actorId) ?? null) : null), + metadata: r.metadata ?? null, + at: r.createdAt.toISOString(), + })); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-event.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-event.entity.ts new file mode 100644 index 000000000..247ea12a6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-clearance-event.entity.ts @@ -0,0 +1,48 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from './booking.entity'; + +export const CLEARANCE_EVENT_ACTOR_TYPES = ['STAFF', 'CUSTOMER', 'SYSTEM'] as const; +export type ClearanceEventActorType = (typeof CLEARANCE_EVENT_ACTOR_TYPES)[number]; + +/** + * One row per action in a booking's clearance flow — the History tab's source + * of truth. Written explicitly (and, where the caller runs one, inside the + * caller's transaction) by every clearance mutation: document review, phased + * workflow steps (transit, declaration, duty, DO/RO, permits), and customer + * charges. `action` is a stable machine code; `label` is the human sentence + * rendered as written, so old rows survive later wording changes. + */ +@Entity({ schema: 'freight', name: 'booking_clearance_event' }) +@Index(['bookingId', 'createdAt']) +export class BookingClearanceEvent extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + /** Stable machine code, e.g. DOC_APPROVED, DECLARATION_UPLOADED. */ + @Column({ name: 'action', type: 'varchar', length: 64 }) + action!: string; + + /** Human sentence shown in the History tab, frozen at write time. */ + @Column({ name: 'label', type: 'varchar', length: 500 }) + label!: string; + + @Column({ name: 'actor_type', type: 'varchar', length: 16, default: 'STAFF' }) + actorType!: ClearanceEventActorType; + + /** IAM user id of the actor (null for SYSTEM events). */ + @Column({ name: 'actor_id', type: 'uuid', nullable: true }) + actorId?: string | null; + + /** Display name resolved at write time (iam.users); null when unresolvable. */ + @Column({ name: 'actor_name', type: 'varchar', length: 150, nullable: true }) + actorName?: string | null; + + /** Action details: fileKey, note, amount, currency, file names, … */ + @Column({ name: 'metadata', type: 'jsonb', nullable: true }) + metadata?: Record | null; +} diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index dd87184fd..420854f8f 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -115,6 +115,7 @@ function makeService(overrides?: { } as never, // transit agents { findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository { getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope + { record: jest.fn() } as never, // clearanceEvents ); return { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index b63aad054..78f6a3970 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -39,6 +39,7 @@ import { buildClearanceDocHistory, type ClearanceDocEvent, } from '../bookings/clearance-doc-history.util'; +import { ClearanceEventService } from '../bookings/clearance-event.service'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; @@ -169,6 +170,7 @@ export class BookingClearanceService { private readonly transitAgentsService: TransitAgentsService, private readonly contractsRepository: ContractsRepository, private readonly yardScope: YardScopeService, + private readonly clearanceEvents: ClearanceEventService, ) {} private async assertPhasedCustoms(booking: Booking): Promise { @@ -524,6 +526,7 @@ export class BookingClearanceService { async requestTransitAssignee( bookingId: string, note: string | undefined, + userId?: string, ): Promise { const booking = await this.loadBooking(bookingId); @@ -531,6 +534,13 @@ export class BookingClearanceService { transitAssigneeRequestedAt: new Date(), transitAssigneeRequestNote: note?.trim() || null, } as never); + await this.clearanceEvents.record({ + bookingId, + action: 'TRANSIT_ASSIGNEE_REQUESTED', + label: 'Requested a transit assignee from GL Djibouti', + actorId: userId ?? null, + metadata: { note: note?.trim() || null }, + }); this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null); return this.bookingsService.findById(bookingId); @@ -542,7 +552,11 @@ export class BookingClearanceService { * Answering unblocks the declaration for Ethiopia. A later call overwrites * the name (reassignment) and re-notifies. */ - async assignTransitAssignee(bookingId: string, transitAgentId: string): Promise { + async assignTransitAssignee( + bookingId: string, + transitAgentId: string, + userId?: string, + ): Promise { const booking = await this.loadBooking(bookingId); if (!booking.transitAssigneeRequestedAt) { throw new BadRequestException( @@ -556,6 +570,13 @@ export class BookingClearanceService { transitAssigneeName: agent.name, transitAssigneeAssignedAt: new Date(), } as never); + await this.clearanceEvents.record({ + bookingId, + action: 'TRANSIT_ASSIGNEE_ASSIGNED', + label: `Assigned transit officer "${agent.name}"`, + actorId: userId ?? null, + metadata: { transitAgentId, agentName: agent.name, previous }, + }); this.notifier.transitAssigneeAssigned(booking, agent.name, previous); return this.bookingsService.findById(bookingId); @@ -608,6 +629,13 @@ export class BookingClearanceService { ? ContractDocPhase.GlEtPostClearance : ContractDocPhase.CustomerDuty, } as never); + await this.clearanceEvents.record({ + bookingId, + action: 'DECLARATION_UPLOADED', + label: `Uploaded customs declaration (${files.length} file(s))`, + actorId: userId ?? null, + metadata: { fileNames: files.map((f) => f.originalname) }, + }); return this.bookingsService.findById(bookingId); } @@ -661,6 +689,19 @@ export class BookingClearanceService { ); this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB'); } + await this.clearanceEvents.record({ + bookingId, + action: 'DUTY_ADVISED', + label: dto.dutyRequired + ? `Advised duty/tax of ${dto.amount} ${dto.currency ?? 'ETB'}` + : 'Advised that no duty/tax applies', + actorId: userId ?? null, + metadata: { + dutyRequired: dto.dutyRequired, + amount: dto.amount ?? null, + currency: dto.currency ?? null, + }, + }); return this.bookingsService.findById(bookingId); } @@ -708,6 +749,14 @@ export class BookingClearanceService { clearanceCurrentPhase: ContractDocPhase.GlEtOutput, } as never); + await this.clearanceEvents.record({ + bookingId, + action: 'DRAFT_DECLARATION_SENT', + label: `Sent draft customs declaration (estimated ${price} ${currency})`, + actorId: userId ?? null, + metadata: { price, currency, fileNames: files.map((f) => f.originalname) }, + }); + const updated = await this.bookingsService.findById(bookingId); this.notifier.draftDeclarationReady(updated, price, currency); return updated; @@ -717,7 +766,7 @@ export class BookingClearanceService { * The customer accepts the draft declaration — GL Ethiopia may now file the * real customs declaration. */ - async acceptDraftDeclaration(bookingId: string): Promise { + async acceptDraftDeclaration(bookingId: string, userId?: string): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('Draft declaration applies only to import bookings.'); @@ -729,6 +778,13 @@ export class BookingClearanceService { } await this.workflowService.completeMilestoneForBooking(bookingId, 'DRAFT_DECLARATION_ACCEPTED'); + await this.clearanceEvents.record({ + bookingId, + action: 'DRAFT_DECLARATION_ACCEPTED', + label: 'Customer accepted the draft customs declaration', + actorType: 'CUSTOMER', + actorId: userId ?? null, + }); return this.bookingsService.findById(bookingId); } @@ -778,12 +834,25 @@ export class BookingClearanceService { clearanceCurrentPhase: ContractDocPhase.GlEtOutput, } as never); + await this.clearanceEvents.record({ + bookingId, + action: 'DRAFT_DECLARATION_CHANGE_REQUESTED', + label: 'Customer requested a change to the draft declaration', + actorType: 'CUSTOMER', + actorId: userId ?? null, + metadata: { note: note.trim() }, + }); + const updated = await this.bookingsService.findById(bookingId); this.notifier.draftDeclarationChangeRequested(updated, note.trim()); return updated; } - async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise { + async uploadDutySlip( + bookingId: string, + file: Express.Multer.File, + userId?: string, + ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('Duty slip upload applies only to import bookings.'); @@ -805,6 +874,15 @@ export class BookingClearanceService { clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, } as never); + await this.clearanceEvents.record({ + bookingId, + action: 'DUTY_SLIP_UPLOADED', + label: 'Customer uploaded the duty/tax payment slip', + actorType: 'CUSTOMER', + actorId: userId ?? null, + metadata: { fileName: file.originalname }, + }); + this.notifier.dutySlipUploadedToStaff(booking, 'first'); return this.bookingsService.findById(bookingId); } @@ -837,11 +915,18 @@ export class BookingClearanceService { await this.bookingsRepository.update(bookingId, { clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, } as never); + await this.clearanceEvents.record({ + bookingId, + action: 'TRANSIT_PERMIT_UPLOADED', + label: `Uploaded transit permit (${files.length} file(s))`, + actorId: userId ?? null, + metadata: { fileNames: files.map((f) => f.originalname) }, + }); return this.bookingsService.findById(bookingId); } - async finalizePreClearance(bookingId: string): Promise { + async finalizePreClearance(bookingId: string, userId?: string): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('Pre-clearance finalize applies only to import bookings.'); @@ -861,6 +946,12 @@ export class BookingClearanceService { preClearanceFinalizedAt: new Date(), clearanceCurrentPhase: ContractDocPhase.GlDjCollection, } as never); + await this.clearanceEvents.record({ + bookingId, + action: 'PRE_CLEARANCE_FINALIZED', + label: 'Finalized pre-clearance — handed over to GL Djibouti collection', + actorId: userId ?? null, + }); // GL Djibouti may have uploaded the DO early (un-gated) — count it now. const files = await this.filesService.findByResource(bookingId, 'bookings'); @@ -894,6 +985,17 @@ export class BookingClearanceService { vesselArrivalDate, doCollectedDate, } as never); + await this.clearanceEvents.record({ + bookingId, + action: 'DELIVERY_ORDER_UPLOADED', + label: 'Uploaded Delivery Order', + actorId: userId ?? null, + metadata: { + vesselArrivalDate: vesselArrivalDate ?? null, + doCollectedDate: doCollectedDate ?? null, + fileNames: (files ?? []).map((f) => f.originalname), + }, + }); if (booking.preClearanceFinalizedAt) { await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); @@ -951,6 +1053,16 @@ export class BookingClearanceService { vesselDepartureDate, roAmendmentRequestedAt: null, } as never); + await this.clearanceEvents.record({ + bookingId, + action: 'RELEASE_ORDER_UPLOADED', + label: `Uploaded Release Order (vessel departs ${vesselDepartureDate})`, + actorId: userId ?? null, + metadata: { + vesselDepartureDate, + fileNames: (files ?? []).map((f) => f.originalname), + }, + }); if (leadDays < minDays) { const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`; @@ -1010,6 +1122,13 @@ export class BookingClearanceService { userId, ); } + await this.clearanceEvents.record({ + bookingId, + action: 'RO_AMENDMENT_REQUESTED', + label: 'Requested a port amendment on the Release Order', + actorId: userId ?? null, + metadata: { note: reason }, + }); return this.bookingsService.findById(bookingId); } @@ -1025,6 +1144,12 @@ export class BookingClearanceService { 'EXPORT_RELEASED', ); await this.workflowService.onExportReleasedForBooking(bookingId, userId); + await this.clearanceEvents.record({ + bookingId, + action: 'EXPORT_RELEASE_CONFIRMED', + label: 'Confirmed export release', + actorId: userId ?? null, + }); return this.bookingsService.findById(bookingId); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceHistoryTab.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceHistoryTab.tsx new file mode 100644 index 000000000..c63558593 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceHistoryTab.tsx @@ -0,0 +1,116 @@ +import { useQuery } from "@tanstack/react-query"; +import { Badge, Group, Loader, Paper, Text, Timeline } from "@mantine/core"; +import { + CheckCircle2, + CircleDot, + FileText, + MessageSquareWarning, + Receipt, + Send, + Ship, + Upload, + UserCheck, +} from "lucide-react"; +import type { Freight } from "@edr/types"; + +import { bookingsService } from "@/services/bookings.service"; +import { formatDateTime } from "@/lib/format"; + +/** Icon + color per action family; unknown actions fall back to a neutral dot. */ +function eventMeta(action: string): { icon: typeof Upload; color: string } { + if (action === "DOC_APPROVED" || action.endsWith("_ACCEPTED") || action.endsWith("_FINALIZED") || action.endsWith("_CONFIRMED")) + return { icon: CheckCircle2, color: "edr-green" }; + if (action === "DOC_QUERIED" || action.includes("CHANGE_REQUESTED") || action.includes("AMENDMENT")) + return { icon: MessageSquareWarning, color: "red" }; + if (action.startsWith("CHARGE_")) + return { icon: Receipt, color: action === "CHARGE_PAID" ? "edr-green" : "orange" }; + if (action.includes("TRANSIT_ASSIGNEE")) return { icon: UserCheck, color: "blue" }; + if (action.includes("ORDER")) return { icon: Ship, color: "blue" }; + if (action.includes("SENT")) return { icon: Send, color: "blue" }; + if (action.includes("UPLOAD") || action.includes("SUBMITTED")) + return { icon: Upload, color: "blue" }; + if (action.includes("DOC")) return { icon: FileText, color: "gray" }; + return { icon: CircleDot, color: "gray" }; +} + +const ACTOR_BADGE: Record< + Freight.ClearanceHistoryEvent["actorType"], + { label: string; color: string } +> = { + STAFF: { label: "Staff", color: "blue" }, + CUSTOMER: { label: "Customer", color: "grape" }, + SYSTEM: { label: "System", color: "gray" }, +}; + +/** + * Full per-booking clearance action trail: document reviews, phased workflow + * steps (transit, declaration, duty, DO/RO, permits) and customer charges — + * every event with who did it and when, newest first. + */ +export function ClearanceHistoryTab({ bookingId }: { bookingId: string }) { + const { data: events, isLoading } = useQuery({ + queryKey: ["clearance-history", bookingId], + queryFn: () => bookingsService.getClearanceHistory(bookingId), + }); + + if (isLoading) { + return ( + + + Loading history… + + ); + } + + if (!events || events.length === 0) { + return ( + + + No clearance actions recorded yet. Actions from now on — approvals, + queries, workflow steps, charges — appear here automatically. + + + ); + } + + return ( + + + {events.map((ev) => { + const meta = eventMeta(ev.action); + const Icon = meta.icon; + const actor = ACTOR_BADGE[ev.actorType]; + const note = + typeof ev.metadata?.note === "string" ? ev.metadata.note : null; + return ( + } + title={ + + + {ev.label} + + + {actor.label} + + + } + > + + {ev.actorName ? `${ev.actorName} · ` : ""} + {formatDateTime(ev.at)} + + {note ? ( + + {note} + + ) : null} + + ); + })} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx index 95dc13d37..8672e60d3 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; import { Badge, Stack, Tabs, Text } from "@mantine/core"; -import { AlertTriangle, FileText, Receipt, Share2, ShieldAlert } from "lucide-react"; +import { AlertTriangle, FileText, History, Receipt, Share2, ShieldAlert } from "lucide-react"; import type { Freight } from "@edr/types"; import { useAuth } from "@/auth/useAuth"; @@ -10,6 +10,7 @@ import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab"; +import { ClearanceHistoryTab } from "@/components/contracts/ClearanceHistoryTab"; import { GlExchangePanel } from "@/components/contracts/GlExchangePanel"; export interface ClearanceOpsTabsProps { @@ -112,6 +113,11 @@ export function ClearanceOpsTabs({ Customer charges ) : null} + {bookingId && showExchange ? ( + }> + History + + ) : null} {showOpsTabs && canOps && riskMs ? ( }> Risk assignment @@ -153,6 +159,12 @@ export function ClearanceOpsTabs({ ) : null} + {bookingId && showExchange ? ( + + + + ) : null} + {showOpsTabs && canOps && riskMs && bookingId ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index 95e8ff296..e8041e271 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -20,6 +20,7 @@ import { AlertTriangle, ClipboardList, FileText, + History, Receipt, Share2, Upload, @@ -38,6 +39,7 @@ import { ContractClearanceReviewSection } from "@/components/contracts/ContractC import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel"; import { GlExchangePanel } from "@/components/contracts/GlExchangePanel"; import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab"; +import { ClearanceHistoryTab } from "@/components/contracts/ClearanceHistoryTab"; import { GlClearanceUploadModal, type GlClearanceUploadKind, @@ -254,6 +256,11 @@ export default function GlClearanceDetailPage() { Customer charges ) : null} + {data.kind === "booking" ? ( + }> + History + + ) : null} {incidentBookingId ? ( }> Incidents @@ -382,6 +389,12 @@ export default function GlClearanceDetailPage() { ) : null} + {data.kind === "booking" ? ( + + + + ) : null} + {incidentBookingId ? ( 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 bdfb46cd6..f2b6c3de6 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -432,6 +432,14 @@ export const bookingsService = { return unwrap(response.data) as Freight.ClearanceView; }, + /** Clearance action history — reviews, workflow steps, charges (newest first). */ + getClearanceHistory: async ( + id: string, + ): Promise => { + const response = await client.get(`/bookings/${id}/clearance/history`); + return unwrap(response.data) as Freight.ClearanceHistoryEvent[]; + }, + // ── Clearance charges (post-finalization customer billing) ── getClearanceCharges: async (id: string): Promise => { const response = await client.get(`/bookings/${id}/clearance/charges`); diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index fb3a51508..fdcfbb16b 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -807,6 +807,21 @@ export interface PricingBreakdown { export type DocumentReviewStatus = "PENDING" | "APPROVED" | "QUERIED"; +// ── Clearance history (per-booking action trail, History tab) ─────────────── + +/** One recorded clearance action — review, workflow step, or charge. */ +export interface ClearanceHistoryEvent { + id: string; + /** Stable machine code, e.g. DOC_APPROVED, DECLARATION_UPLOADED. */ + action: string; + /** Human sentence, frozen at write time. */ + label: string; + actorType: "STAFF" | "CUSTOMER" | "SYSTEM"; + actorName: string | null; + metadata: Record | null; + at: string; +} + // ── Clearance charges (post-finalization customer billing) ────────────────── export type ClearanceChargeType = "PORT_CHARGES" | "MISCELLANEOUS";