From 7af3ded7d719fe0d30a0fa61e4cbc0ad0c0be23d Mon Sep 17 00:00:00 2001 From: marshal Date: Tue, 8 Sep 2026 08:49:33 +0000 Subject: [PATCH] feat(bookings): add agent-driven clearance flow with forwarder/transit-agent panels and BookingClearedByAgent migration --- .../3970000000000-BookingClearedByAgent.ts | 45 ++ .../modules/bookings/bookings.controller.ts | 32 +- .../bookings/entities/booking.entity.ts | 10 + .../contracts/booking-clearance.service.ts | 168 ++++- .../contracts/contract-booking.service.ts | 48 +- .../modules/contracts/contracts.controller.ts | 78 ++- .../forwarder/AssignedBookingDetailPage.tsx | 78 ++- .../pages/forwarder/ForwarderBookingTabs.tsx | 461 +++++++++++++ .../forwarder/ForwarderClearancePanel.tsx | 637 ++++++++++++++++++ .../forwarder/ForwarderDocumentReview.tsx | 13 +- .../TransitClearanceActionPanel.tsx | 65 +- .../transit-agent/TransitDocumentsPanel.tsx | 32 +- .../services/transit-assignments.service.ts | 94 +++ packages/types/src/freight/index.ts | 12 + 14 files changed, 1716 insertions(+), 57 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3970000000000-BookingClearedByAgent.ts create mode 100644 apps/edr-freight-web/portal/src/pages/forwarder/ForwarderBookingTabs.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/forwarder/ForwarderClearancePanel.tsx diff --git a/apps/edr-freight-api/src/migrations/3970000000000-BookingClearedByAgent.ts b/apps/edr-freight-api/src/migrations/3970000000000-BookingClearedByAgent.ts new file mode 100644 index 000000000..d726b781d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3970000000000-BookingClearedByAgent.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * A without-customs booking the customer handed to a registered clearing agent + * (a freight forwarder on the platform) now runs the phased clearance workflow, + * with the forwarder doing the GL Ethiopia steps and the Djibouti agent it + * names doing the Djibouti steps. The flag marks such a booking so every + * "phased customs" gate admits it alongside GL-cleared customs bookings. + */ +export class BookingClearedByAgent3970000000000 implements MigrationInterface { + name = "BookingClearedByAgent3970000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS cleared_by_agent boolean NOT NULL DEFAULT false + `); + // Bookings already handed to a registered Ethiopian agent before the flag + // existed join the workflow too. Their milestones are seeded lazily the + // first time the clearance view is read (BookingClearanceService), since + // the catalog lives in code. + await queryRunner.query(` + UPDATE freight.bookings b + SET cleared_by_agent = true + WHERE b.customs_clearing_enabled = false + AND b.contract_id IS NOT NULL + AND b.deleted_at IS NULL + AND EXISTS ( + SELECT 1 + FROM freight.transit_assignments ta + JOIN freight.transit_agents a ON a.id = ta.transit_agent_id + WHERE ta.booking_id = b.id + AND ta.deleted_at IS NULL + AND a.deleted_at IS NULL + AND a.country = 'ET' + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings DROP COLUMN IF EXISTS cleared_by_agent + `); + } +} 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 1ce5da23d..470e4db0e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -1800,8 +1800,10 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + // Also the clearing agent (forwarder) on an agent-cleared booking — see + // assertStaffOrClearingAgent; it does GL Ethiopia's part of the workflow. @Post(":id/clearance/declaration") - @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") @ApiOperation({ @@ -1812,6 +1814,7 @@ export class BookingsController { @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { + await this.assertStaffOrClearingAgent(id, user, FREIGHT_PERMS.contracts.clearanceEtActions); const booking = await this.bookingClearanceService.uploadDeclaration( id, files ?? [], @@ -1853,8 +1856,10 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + // Also the clearing agent (forwarder) on an agent-cleared booking — see + // assertStaffOrClearingAgent; it does GL Ethiopia's part of the workflow. @Post(":id/clearance/draft-declaration") - @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") @ApiOperation({ @@ -1868,6 +1873,7 @@ export class BookingsController { @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { + await this.assertStaffOrClearingAgent(id, user, FREIGHT_PERMS.contracts.clearanceEtActions); const booking = await this.bookingClearanceService.uploadDraftDeclaration( id, files ?? [], @@ -1878,8 +1884,10 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + // Also the clearing agent (forwarder) on an agent-cleared booking — see + // assertStaffOrClearingAgent; it does GL Ethiopia's part of the workflow. @Post(":id/clearance/draft-declaration/skip") - @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: "GL ET skips the draft-declaration round: no estimate is sent to the customer, the real declaration is filed directly and duty & tax passes by default", @@ -1888,6 +1896,7 @@ export class BookingsController { @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { + await this.assertStaffOrClearingAgent(id, user, FREIGHT_PERMS.contracts.clearanceEtActions); const booking = await this.bookingClearanceService.skipDraftDeclaration( id, resolveAuthUserId(user), @@ -1932,13 +1941,16 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + // Also the clearing agent (forwarder) on an agent-cleared booking — see + // assertStaffOrClearingAgent; it does GL Ethiopia's part of the workflow. @Post(":id/clearance/finalize-pre-clearance") - @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: "GL ET finalizes import pre-clearance on booking" }) async finalizeBookingPreClearance( @Param("id", ParseUUIDPipe) id: string, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { + await this.assertStaffOrClearingAgent(id, user, FREIGHT_PERMS.contracts.clearanceEtActions); const booking = await this.bookingClearanceService.finalizePreClearance( id, resolveAuthUserId(user), @@ -1966,8 +1978,10 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + // Also the clearing agent (forwarder) on an agent-cleared booking — see + // assertStaffOrClearingAgent; it does GL Ethiopia's part of the workflow. @Post(":id/clearance/transit-permit") - @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") async uploadBookingTransitPermit( @@ -1975,6 +1989,7 @@ export class BookingsController { @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { + await this.assertStaffOrClearingAgent(id, user, FREIGHT_PERMS.contracts.clearanceEtActions); const booking = await this.bookingClearanceService.uploadTransitPermit( id, files ?? [], @@ -2101,12 +2116,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + // Also the clearing agent (forwarder) on an agent-cleared booking — see + // assertStaffOrClearingAgent; it does GL Ethiopia's part of the workflow. @Post(":id/clearance/export-release") - @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions) async confirmBookingExportRelease( @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { + await this.assertStaffOrClearingAgent(id, user, FREIGHT_PERMS.contracts.clearanceEtActions); const booking = await this.bookingClearanceService.confirmExportRelease( id, resolveAuthUserId(user), 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 b457dd259..48a62bb94 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 @@ -370,6 +370,16 @@ export class Booking extends BaseEntity { @Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false }) customsClearingEnabled!: boolean; + /** + * A without-customs booking the customer handed to a registered clearing + * agent (a freight forwarder on the platform). It runs the phased clearance + * workflow like a customs booking, except the forwarder performs the GL + * Ethiopia steps from its portal and the Djibouti agent it names performs + * the Djibouti steps; the customer still creates the booking itself. + */ + @Column({ name: 'cleared_by_agent', type: 'boolean', default: false }) + clearedByAgent!: boolean; + @Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true }) customsClearingAgent?: string | null; 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 d0cdedc00..d72440924 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 @@ -87,6 +87,8 @@ export interface BookingClearanceView { metadata?: Record | null; sortOrder: number; }>; + /** Handed to a registered clearing agent: the forwarder does GL Ethiopia's steps. */ + clearedByAgent?: boolean; nextAction?: { actor: string; action: string; @@ -188,8 +190,10 @@ export class BookingClearanceService { ) {} private async assertPhasedCustoms(booking: Booking): Promise { - if (!booking.customsClearingEnabled) { - throw new BadRequestException('Phased clearance applies only to customs bookings.'); + if (!booking.customsClearingEnabled && !booking.clearedByAgent) { + throw new BadRequestException( + 'Phased clearance applies only to customs bookings and bookings cleared by a registered agent.', + ); } if (!booking.contractId) { throw new BadRequestException('Booking is not linked to a contract.'); @@ -301,6 +305,83 @@ export class BookingClearanceService { const allApproved = await this.isClearanceFullyApproved(booking); let milestones = await this.workflowService.listMilestonesForBooking(bookingId); + // An agent-cleared booking that predates the flag (or was flagged by the + // migration) has no timeline yet. Seed the pre-booking half now, exactly + // as initiation would have, with the duty round off — idempotent, since + // ensure* only adds codes that are missing. + if (booking.clearedByAgent && milestones.length === 0) { + const direction = booking.tradeDirection ?? 'IMPORT'; + await this.milestoneService.seedPreBookingMilestonesOnBooking(bookingId, direction); + if (direction === 'IMPORT') { + for (const code of ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']) { + await this.milestoneService.skipForBooking(bookingId, code).catch(() => undefined); + } + if (booking.dutyRequired !== false) { + await this.bookingsRepository.update(bookingId, { dutyRequired: false } as never); + } + } + milestones = await this.workflowService.listMilestonesForBooking(bookingId); + } + + // An agent-cleared booking completed before it joined the workflow has + // only the pre-booking half of its timeline: the post-booking milestones + // are seeded at completion, and that ran before the flag existed. Add + // them now (ensure* only adds what is missing), duty round off, and stamp + // the wagon allocation the scheduler already made — the payment stamp + // follows in the self-heal below. + if ( + booking.clearedByAgent && + milestones.length > 0 && + Number(booking.totalAmount ?? 0) > 0 && + !milestones.some((m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED') + ) { + const direction = booking.tradeDirection ?? 'IMPORT'; + await this.milestoneService.ensureBookingMilestones(bookingId, direction); + if (direction === 'IMPORT') { + for (const code of ['SECOND_DUTY_ADVISED', 'SECOND_DUTY_PAID']) { + await this.milestoneService.skipForBooking(bookingId, code).catch(() => undefined); + } + } + milestones = await this.workflowService.listMilestonesForBooking(bookingId); + } + if ( + booking.clearedByAgent && + (booking.schedulingStatus === 'SCHEDULED' || + booking.schedulingStatus === 'DISPATCHED' || + Boolean(booking.trainScheduleId)) && + milestones.some( + (m) => m.milestoneCode === 'WAGON_ALLOCATED' && m.status === 'PENDING', + ) + ) { + await this.workflowService.completeMilestoneForBooking(bookingId, 'WAGON_ALLOCATED'); + milestones = await this.workflowService.listMilestonesForBooking(bookingId); + } + + // Self-heal the document milestones on an agent-cleared booking: the + // forwarder may have uploaded, reviewed and approved before the booking + // joined the workflow (flagged by migration, or milestones seeded just + // above), so the review path never stamped them. Completing is idempotent. + if (booking.clearedByAgent && milestones.length > 0) { + const state = (code: string) => + milestones.find((m) => m.milestoneCode === code)?.status; + const isDone = (code: string) => + state(code) === 'COMPLETED' || state(code) === 'SKIPPED'; + const direction = booking.tradeDirection ?? 'IMPORT'; + const hasCustomerFiles = files.length > 0; + let healed = false; + if (hasCustomerFiles && !isDone('PENDING_DOCUMENT_REVIEW')) { + await this.workflowService.onCustomerDocsUploadedForBooking(bookingId, direction); + healed = true; + } + if (allApproved && !isDone('DOCUMENTS_APPROVED')) { + await this.workflowService.onAllDocsApprovedForBooking(bookingId); + healed = true; + } + if (healed) { + milestones = await this.workflowService.listMilestonesForBooking(bookingId); + } + } + // Self-heal: a booking that has settled its freight payment must have // FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an // export FCFS booking (linked to its train at booking time) paid via the @@ -396,6 +477,7 @@ export class BookingClearanceService { sortOrder: m.sortOrder, })), nextAction, + clearedByAgent: Boolean(booking.clearedByAgent), dutyRequired: booking.dutyRequired ?? null, roHold: Boolean(booking.roHoldReason), roHoldReason: booking.roHoldReason ?? null, @@ -541,10 +623,15 @@ export class BookingClearanceService { ); } - /** Any contract booking (ONE_TIME or GENERAL) whose service bundles customs. */ + /** + * Any contract booking (ONE_TIME or GENERAL) whose service bundles customs — + * or one the customer handed to a registered clearing agent, which runs the + * same phased workflow with the forwarder in GL Ethiopia's place. + */ isPhasedCustomsBooking(booking: Booking): boolean { return ( - Boolean(booking.customsClearingEnabled) && Boolean(booking.contractId) + (Boolean(booking.customsClearingEnabled) || Boolean(booking.clearedByAgent)) && + Boolean(booking.contractId) ); } @@ -660,8 +747,14 @@ export class BookingClearanceService { } // Import only: the declaration is filed against whoever physically handles // the shipment in Djibouti, so that name must be in first. Exports have no - // such handshake — their Djibouti steps come after the declaration. - if (tradeDirection === 'IMPORT' && !booking.transitAssigneeName) { + // such handshake — their Djibouti steps come after the declaration. An + // agent-cleared booking has no GL Djibouti desk to ask: the forwarder names + // the Djibouti agent itself, whenever it likes, so the gate does not apply. + if ( + tradeDirection === 'IMPORT' && + !booking.transitAssigneeName && + !booking.clearedByAgent + ) { throw new BadRequestException( booking.transitAssigneeRequestedAt ? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.' @@ -700,6 +793,30 @@ export class BookingClearanceService { metadata: { fileNames: files.map((f) => f.originalname) }, }); + // Agent-cleared export: the Djibouti agent's Release Order may already be + // secured (it does not wait for the declaration there), in which case this + // declaration is the last pre-operation step — release the export now so + // the customer can create the booking. See uploadReleaseOrder for the + // mirror case. + if (booking.clearedByAgent && tradeDirection === 'EXPORT') { + const after = await this.workflowService.listMilestonesForBooking(bookingId); + const isDone = (code: string) => + after.some( + (m) => + m.milestoneCode === code && + (m.status === 'COMPLETED' || m.status === 'SKIPPED'), + ); + if (isDone('RELEASE_ORDER_SECURED') && !isDone('EXPORT_RELEASED')) { + await this.workflowService.onExportReleasedForBooking(bookingId, userId); + await this.clearanceEvents.record({ + bookingId, + action: 'EXPORT_RELEASE_CONFIRMED', + label: 'Export released — declaration and Release Order both on file', + actorId: userId ?? null, + }); + } + } + return this.bookingsService.findById(bookingId); } @@ -1141,11 +1258,24 @@ export class BookingClearanceService { if (booking.tradeDirection !== 'EXPORT') { throw new BadRequestException('Release Order applies only to export bookings.'); } - await this.workflowService.assertPriorCompleteForBooking( - bookingId, - 'EXPORT', - 'RELEASE_ORDER_SECURED', - ); + if (booking.clearedByAgent) { + // The Djibouti agent may secure the RO as soon as the customer's + // documents are approved — it does not wait for the forwarder's + // declaration, which can be filed before or after it. + const milestones = await this.workflowService.listMilestonesForBooking(bookingId); + const approved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); + if (approved?.status !== 'COMPLETED' && approved?.status !== 'SKIPPED') { + throw new BadRequestException( + 'The customer documents must be approved before the Release Order can be uploaded.', + ); + } + } else { + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'EXPORT', + 'RELEASE_ORDER_SECURED', + ); + } if (!vesselDepartureDate?.trim()) { throw new BadRequestException('Vessel departure date is required'); @@ -1195,8 +1325,20 @@ export class BookingClearanceService { ); // Release Order is now the last GL DJ pre-operation action (it follows the // declaration) — release immediately so booking creation unlocks without a - // separate confirm click. - await this.workflowService.onExportReleasedForBooking(bookingId, userId); + // separate confirm click. On an agent-cleared booking the RO may come + // FIRST, so the export is released by whichever of the two lands second: + // here only once the declaration is already on file, otherwise from the + // declaration upload. + const declared = booking.clearedByAgent + ? (await this.workflowService.listMilestonesForBooking(bookingId)).some( + (m) => + m.milestoneCode === 'DECLARED' && + (m.status === 'COMPLETED' || m.status === 'SKIPPED'), + ) + : true; + if (declared) { + await this.workflowService.onExportReleasedForBooking(bookingId, userId); + } return { booking: await this.bookingsService.findById(bookingId), hold: false }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index f15bd41ce..0e21ea473 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -552,6 +552,11 @@ export class ContractBookingService { paymentCurrency: this.resolveShipmentCurrency(contract, null), contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, + // Handed to a registered clearing agent: the phased workflow runs on + // this booking with the forwarder in GL Ethiopia's place. Duty is + // never advised on that path, so it is settled up front. + clearedByAgent: Boolean(clearingAgent?.assigned), + ...(clearingAgent?.assigned ? { dutyRequired: false } : {}), customsClearingAgent: clearingAgent?.fields.customsClearingAgent ?? contract.customsClearingAgent ?? @@ -580,12 +585,20 @@ export class ContractBookingService { // Customs: the instance runs the phased ET/DJ workflow, so its pre-booking // milestones exist from initiation (the post-booking half is seeded when the - // booking is completed). Self-clearance has no milestone timeline. - if (contract.customsClearingEnabled) { + // booking is completed). Self-clearance has no milestone timeline — unless + // a registered clearing agent was named, which runs the same workflow with + // the forwarder in GL Ethiopia's place and no duty round. + if (contract.customsClearingEnabled || clearingAgent?.assigned) { await this.milestoneService.seedPreBookingMilestonesOnBooking( booking.id, contract.tradeDirection, ); + if (clearingAgent?.assigned && contract.tradeDirection === 'IMPORT') { + await this.skipAgentClearedDutyMilestones(booking.id, [ + 'DUTY_TAXES_ADVISED', + 'DUTY_TAX_PAID', + ]); + } } const result = await this.bookingsRepository.findByIdWithFiles(booking.id); @@ -609,6 +622,28 @@ export class ContractBookingService { return { booking: result ?? booking, warnings: [] }; } + /** + * Duty is never advised on an agent-cleared import: the forwarder settles it + * with customs outside the platform. The milestones are skipped rather than + * left pending, or every later step would wait on them. + */ + private async skipAgentClearedDutyMilestones( + bookingId: string, + codes: string[], + ): Promise { + for (const code of codes) { + try { + await this.milestoneService.skipForBooking(bookingId, code); + } catch (err) { + this.logger.warn( + `Could not skip ${code} on agent-cleared booking ${bookingId}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + } + /** * The booking company as its own clearing agent — when it holds the transit * agent or freight forwarder role, it clears its own customs, and its own @@ -1398,11 +1433,18 @@ export class ContractBookingService { // a consolidation pairing replay must not duplicate the timeline. The // contract itself is never moved to ACTIVE_SHIPMENT_IN_PROGRESS any more; it // holds no clearance state at all. - if (contract.customsClearingEnabled) { + if (contract.customsClearingEnabled || booking.clearedByAgent) { await this.milestoneService.ensureBookingMilestones( bookingId, contract.tradeDirection, ); + // The post-arrival duty round is off the agent-cleared path as well. + if (booking.clearedByAgent && contract.tradeDirection === 'IMPORT') { + await this.skipAgentClearedDutyMilestones(bookingId, [ + 'SECOND_DUTY_ADVISED', + 'SECOND_DUTY_PAID', + ]); + } } // Contract bookings are born past the billable gate (the contract is already diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index a9fc77bdf..00f43332b 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -1360,13 +1360,20 @@ export class ContractsController { // ── GL operational actions on a booking (doc §11–§13) ────────────────────── @Post('bookings/:bookingId/risk') - @BookingStaff(FREIGHT_PERMS.bookings.operations) - @ApiOperation({ summary: 'GL ET assigns a customs risk level (GREEN/YELLOW/RED)' }) - assignRisk( + @MixedAudience(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ + summary: 'GL ET — or the assigned clearing agent — assigns a customs risk level (GREEN/YELLOW/RED)', + }) + async assignRisk( @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignRiskDto, @CurrentUser() user: TCurrentUser, ) { + await this.assertStaffOrAgentOnAgentClearedBooking( + bookingId, + user, + FREIGHT_PERMS.bookings.operations, + ); return this.milestoneService.assignRisk( bookingId, dto.riskLevel, @@ -1408,17 +1415,46 @@ export class ContractsController { } @Post('bookings/:bookingId/transport-document') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL ET uploads export transit permit documents (multi-file)' }) - uploadTransportDocument( + @ApiOperation({ + summary: 'GL ET — or the assigned clearing agent — uploads export transport documents (multi-file)', + }) + async uploadTransportDocument( @Param('bookingId', ParseUUIDPipe) bookingId: string, @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, ) { + await this.assertStaffOrAgentOnAgentClearedBooking( + bookingId, + user, + FREIGHT_PERMS.contracts.clearanceEtActions, + ); return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []); } + /** + * Staff pass on their permission. A portal caller must be an agent assigned + * to THIS booking (the forwarder or the Djibouti agent it named) and the + * booking must be agent-cleared — on a GL-cleared customs booking these + * steps stay with the desks. Hidden behind a NotFound like ownership checks. + */ + private async assertStaffOrAgentOnAgentClearedBooking( + bookingId: string, + user: TCurrentUser, + staffPermission: string, + ): Promise { + if (staffPermission && hasFreightPermission(user, staffPermission)) return; + const booking = await this.bookingsService.findById(bookingId); + if ( + !booking.clearedByAgent || + !(await this.bookingsService.isTransitAgentForBooking(user?.id, bookingId)) + ) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + } + // Also filed by the transit agent assigned to the shipment — T1 is their own // transit paperwork. Any other portal caller is rejected below. @Post('bookings/:bookingId/t1-documents') @@ -1450,19 +1486,27 @@ export class ContractsController { ); } + // On an agent-cleared booking the assigned agents close it in the desks' + // place: the forwarder after arrival (import), the Djibouti agent (export). @Post('bookings/:bookingId/t1-close') - @BookingStaff([ + @MixedAudience([ FREIGHT_PERMS.contracts.clearanceEtActions, FREIGHT_PERMS.contracts.clearanceDjActions, ]) @ApiOperation({ summary: - 'Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)', + 'Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export); also the assigned agents on an agent-cleared booking', }) - closeT1( + async closeT1( @Param('bookingId', ParseUUIDPipe) bookingId: string, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) + ) { + await this.assertStaffOrAgentOnAgentClearedBooking(bookingId, user, ''); + } return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user)); } @@ -1575,17 +1619,25 @@ export class ContractsController { return this.glOperationsService.uploadSecondDutySlip(bookingId, file); } + // Also the clearing agent (forwarder) on an agent-cleared booking, for the + // documents GL Ethiopia would otherwise file (import release). @Post('bookings/:bookingId/documents') - @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) + @MixedAudience(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ - summary: 'GL uploads post-booking operational documents (DO/RO/T1/…)', + summary: 'GL — or the assigned clearing agent — uploads post-booking operational documents (DO/RO/T1/…)', }) - uploadGlDocuments( + async uploadGlDocuments( @Param('bookingId', ParseUUIDPipe) bookingId: string, @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, ) { + await this.assertStaffOrAgentOnAgentClearedBooking( + bookingId, + user, + FREIGHT_PERMS.bookings.uploadClearanceOutput, + ); return this.glOperationsService.uploadDocuments(bookingId, files ?? []); } diff --git a/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingDetailPage.tsx index 6ccd75eaa..a9eeaa686 100644 --- a/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingDetailPage.tsx @@ -23,6 +23,7 @@ import { import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, + AlertTriangle, ArrowLeft, ArrowRight, Building2, @@ -36,6 +37,7 @@ import { History, MessageSquare, PackageCheck, + Share2, ShipWheel, UserCheck, } from "lucide-react"; @@ -52,6 +54,12 @@ import { } from "@/services/transit-assignments.service"; import type { Freight } from "@edr/types"; +import { + ExchangeDocumentsPanel, + IncidentsPanel, + WorkflowDocumentsPanel, +} from "./ForwarderBookingTabs"; +import { ForwarderClearancePanel } from "./ForwarderClearancePanel"; import { ForwarderDocumentReview } from "./ForwarderDocumentReview"; const LIST_PATH = "/forwarder/assigned-bookings"; @@ -190,6 +198,9 @@ export default function AssignedBookingDetailPage() { void bookingQuery.refetch(); void clearanceQuery.refetch(); }; + const workflowFileCount = (clearance?.workflowFiles ?? []).filter( + (f) => f.file, + ).length; const kpis = [ { label: "Approved", value: stats.approved, icon: CheckCircle2, color: "edr-green" }, @@ -344,9 +355,28 @@ export default function AssignedBookingDetailPage() { }> Clearance + } + rightSection={ + workflowFileCount > 0 ? ( + + {workflowFileCount} + + ) : undefined + } + > + Documents + + }> + Document exchange + }> History + }> + Incidents + @@ -397,8 +427,15 @@ export default function AssignedBookingDetailPage() { /> ) : null} + {assignedBookingsUnlocked && booking && clearance?.phase ? ( + + ) : null} - {assignedBookingsUnlocked && clearance ? ( + {assignedBookingsUnlocked && clearance && !clearance.phase ? ( @@ -431,13 +468,38 @@ export default function AssignedBookingDetailPage() { + + {assignedBookingsUnlocked ? ( + + ) : ( + + )} + + + + {assignedBookingsUnlocked && bookingId ? ( + + ) : ( + + )} + + {assignedBookingsUnlocked && bookingId ? ( ) : ( - - The clearance history opens once your role is approved. - + + )} + + + + {assignedBookingsUnlocked && bookingId ? ( + + ) : ( + )} @@ -446,6 +508,14 @@ export default function AssignedBookingDetailPage() { ); } +function LockedNote({ what }: { what: string }) { + return ( + + {what} open once your transit agent role is approved. + + ); +} + function BackButton({ onClick }: { onClick: () => void }) { return (
diff --git a/apps/edr-freight-web/portal/src/pages/forwarder/ForwarderBookingTabs.tsx b/apps/edr-freight-web/portal/src/pages/forwarder/ForwarderBookingTabs.tsx new file mode 100644 index 000000000..601124bfe --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/forwarder/ForwarderBookingTabs.tsx @@ -0,0 +1,461 @@ +import { + Alert, + Badge, + Box, + Button, + Card, + Checkbox, + Group, + Loader, + Modal, + Paper, + Stack, + Text, + TextInput, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + AlertCircle, + AlertTriangle, + CheckCircle2, + Download, + Eye, + EyeOff, + FileText, + Share2, + Upload, +} from "lucide-react"; +import { useState } from "react"; +import toast from "react-hot-toast"; + +import { useFileViewer } from "@/hooks/useFileViewer"; +import { ClearanceWorkflowFilesPanel } from "@/pages/transit-agent/ClearanceWorkflowFilesPanel"; +import { downloadStoredFile, fetchViewableFile } from "@/services/files.service"; +import { transitAssignmentsService } from "@/services/transit-assignments.service"; +import type { Freight } from "@edr/types"; +import { isViewable } from "@edr/ui-common"; + +const formatDateTime = (value?: string | null): string => + value ? new Date(value).toLocaleString() : "—"; + +function formatBytes(bytes: number): string { + if (!bytes) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); + return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`; +} + +const prettyStatus = (s?: string | null) => + (s ?? "") + .toLowerCase() + .replace(/_/g, " ") + .replace(/^\w/, (c) => c.toUpperCase()); + +const SIDES: Record = { + ET: { label: "GL Ethiopia", color: "edr-green" }, + DJ: { label: "GL Djibouti", color: "blue" }, + TRANSIT: { label: "Agent", color: "grape" }, +}; + +function EmptyPanel({ children }: { children: React.ReactNode }) { + return ( + + + + + + + {children} + + + + ); +} + +/** + * Every customs workflow document on the booking, grouped by step — the + * customer's paperwork, the forwarder's declarations and permits, and the + * Djibouti agent's DO/RO/T1 — the GL page's "Documents" tab. + */ +export function WorkflowDocumentsPanel({ + clearance, + loading, +}: { + clearance: Freight.ClearanceView | undefined; + loading: boolean; +}) { + const { view, viewer } = useFileViewer(); + const files = (clearance?.workflowFiles ?? []).filter((f) => f.file); + if (loading) { + return ( + + + Loading documents… + + ); + } + return ( + <> + {files.length > 0 ? ( + void downloadStoredFile(f.id, f.name)} + /> + ) : ( + + No customs workflow documents uploaded yet. Your declarations and + permits, and the Djibouti agent's DO/RO/T1 uploads, appear here. + + )} + {viewer} + + ); +} + +/** + * The document exchange thread on the booking: what both Global Logistics + * desks and the assigned agents shared, plus sharing a document from here. + * Same thread the GL page and the Djibouti agent's page show. + */ +export function ExchangeDocumentsPanel({ bookingId }: { bookingId: string }) { + const { view, viewer } = useFileViewer(); + const [shareOpen, setShareOpen] = useState(false); + const exchangeQuery = useQuery({ + queryKey: ["forwarder-exchange", bookingId], + queryFn: () => transitAssignmentsService.glExchange(bookingId), + }); + const docs = exchangeQuery.data ?? []; + const stats = { + et: docs.filter((d) => d.side === "ET").length, + dj: docs.filter((d) => d.side === "DJ").length, + transit: docs.filter((d) => d.side === "TRANSIT").length, + shared: docs.filter((d) => d.visibleToCustomer).length, + }; + + return ( + + + + + + + + + + Document exchange + + + Documents shared between Global Logistics, you and the Djibouti + agent for this shipment. Anything you share here is visible to + them immediately. + + + + + + {docs.length > 0 ? ( + + + {stats.et} from GL Ethiopia + + + {stats.dj} from GL Djibouti + + + {stats.transit} from agents + + + {stats.shared} visible to customer + + + ) : null} + + + {exchangeQuery.isPending ? ( + + + + Loading shared documents… + + + ) : exchangeQuery.isError ? ( + }> + Could not load the shared documents. + + ) : docs.length === 0 ? ( + + Nothing shared yet. Scans, correspondence and corrected forms posted + by any party appear here. + + ) : ( + + {docs.map((doc) => { + const side = SIDES[doc.side]; + const canPreview = isViewable({ name: doc.file.name, url: "" }); + return ( + + + + + + + + + + {doc.title} + + + {side.label} + + : } + > + {doc.visibleToCustomer ? "Visible to customer" : "Agents & GL only"} + + + + {doc.file.name} · {formatBytes(doc.file.size)} ·{" "} + {doc.uploadedByName ?? "Global Logistics"} · {formatDateTime(doc.uploadedAt)} + + + + + {canPreview ? ( + + + + ) : null} + + + + + + + ); + })} + + )} + + setShareOpen(false)} + onShared={() => void exchangeQuery.refetch()} + /> + {viewer} + + ); +} + +function ShareExchangeModal({ + opened, + bookingId, + onClose, + onShared, +}: { + opened: boolean; + bookingId: string; + onClose: () => void; + onShared: () => void; +}) { + const [file, setFile] = useState(null); + const [title, setTitle] = useState(""); + const [visibleToCustomer, setVisibleToCustomer] = useState(false); + + const close = () => { + setFile(null); + setTitle(""); + setVisibleToCustomer(false); + onClose(); + }; + + const submit = useMutation({ + mutationFn: () => + transitAssignmentsService.shareExchangeDocument(bookingId, { + file: file!, + title, + visibleToCustomer, + }), + onSuccess: () => { + toast.success("Document shared"); + onShared(); + close(); + }, + onError: (e: unknown) => + toast.error(e instanceof Error ? e.message : "Could not share document"), + }); + + return ( + + + Share a document + + } + > + + + Global Logistics and the Djibouti agent see this immediately. Only you + can edit or remove what you post. + + setTitle(e.currentTarget.value)} + required + withAsterisk + /> + + + File + + setFile(e.currentTarget.files?.[0] ?? null)} + style={{ + border: "1px dashed var(--mantine-color-gray-4)", + borderRadius: 8, + padding: 10, + fontSize: 12.5, + background: "var(--mantine-color-gray-0)", + }} + /> + {file ? ( + + {file.name} · {formatBytes(file.size)} + + ) : null} + + setVisibleToCustomer(e.currentTarget.checked)} + /> + + + + + + + ); +} + +/** Cargo exception reports logged against the shipment — read-only. */ +export function IncidentsPanel({ bookingId }: { bookingId: string }) { + const incidentsQuery = useQuery({ + queryKey: ["forwarder-incidents", bookingId], + queryFn: () => transitAssignmentsService.incidents(bookingId), + }); + const incidents = incidentsQuery.data ?? []; + return ( + + + + + + + + Incident reports + + + Container or seal issues found during handling. Logged by the + operations desks — read-only here. + + + + {incidentsQuery.isPending ? ( + + + + ) : incidentsQuery.isError ? ( + }> + Incident reports are not available for this shipment. + + ) : incidents.length > 0 ? ( + + {incidents.map((inc: Freight.IClearanceIncident) => ( + + + + + + + + {prettyStatus(inc.incidentType)} + + {inc.description ? ( + + {inc.description} + + ) : null} + + {formatDateTime(inc.createdAt)} + + + + + ))} + + ) : ( + + + + No incidents reported for this shipment. + + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/forwarder/ForwarderClearancePanel.tsx b/apps/edr-freight-web/portal/src/pages/forwarder/ForwarderClearancePanel.tsx new file mode 100644 index 000000000..9d25b4809 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/forwarder/ForwarderClearancePanel.tsx @@ -0,0 +1,637 @@ +import { + Alert, + Box, + Button, + Card, + Group, + NumberInput, + Progress, + Select, + Stack, + Stepper, + Text, + Textarea, + ThemeIcon, +} from "@mantine/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { ArrowRight, CheckCircle2, Ship, Upload } from "lucide-react"; +import { useState } from "react"; +import toast from "react-hot-toast"; + +import { PortalMultiFileDropzone } from "@/components/contracts/PortalMultiFileDropzone"; +import { api } from "@/services/api"; +import { transitAssignmentsService } from "@/services/transit-assignments.service"; +import type { Freight } from "@edr/types"; +import { isReleaseOrderFileCode } from "@edr/types"; + +/** Who does the step: the forwarder (this page), the Djibouti agent it named, the customer, or the system. */ +type Owner = "forwarder" | "djibouti" | "customer" | "system"; + +interface Step { + key: string; + label: string; + description: string; + owner: Owner; + done: boolean; +} + +type MilestoneRow = { milestoneCode?: string | null; status?: string | null }; + +function isMilestoneDone(ms: MilestoneRow[] | undefined, code: string): boolean { + const m = ms?.find((x) => x.milestoneCode === code); + return m?.status === "COMPLETED" || m?.status === "SKIPPED"; +} + +const formatStamp = (value: string): string => new Date(value).toLocaleString(); + +function trainLegDescription(train: Freight.ClearanceView["train"] | undefined): string { + if (train?.arrivedAt) return `Arrived ${formatStamp(train.arrivedAt)}`; + if (train?.departedAt) return `Departed ${formatStamp(train.departedAt)} · in transit`; + return "Departure and arrival"; +} + +const OWNER_LABEL: Record = { + forwarder: "You", + djibouti: "Djibouti agent", + customer: "Customer", + system: "Operations", +}; + +function apiMessage(e: unknown, fallback: string): string { + const err = e as { response?: { data?: { message?: string | string[] } }; message?: string }; + const m = err.response?.data?.message; + const text = Array.isArray(m) ? m.join(", ") : m; + return text || err.message || fallback; +} + +/** + * The phased clearance wizard for a booking the customer handed to this + * forwarder — the GL desk's clearance action panel, with the forwarder in GL + * Ethiopia's place and the Djibouti agent it named in GL Djibouti's. + * + * Differences from the GL flow, by design: no "request transit assignee" + * step (the forwarder names the Djibouti agent from the card beside this), + * no duty & tax rounds (the forwarder settles duty with customs off the + * platform), and "Create booking" is the customer's — once pre-clearance is + * done the customer completes the booking itself. + * + * Every step's `done` reads off the clearance payload so the wizard can never + * claim a step the server does not consider complete. The action for the + * current step, when it is the forwarder's, renders under the stepper. + */ +export function ForwarderClearancePanel({ + booking, + clearance, + onChanged, +}: { + booking: Freight.IBooking; + clearance: Freight.ClearanceView; + onChanged?: () => void; +}) { + const queryClient = useQueryClient(); + const bookingId = booking.id; + const isImport = booking.tradeDirection === "IMPORT"; + const ms = clearance.milestones; + const done = (code: string) => isMilestoneDone(ms, code); + const workflowFiles = clearance.workflowFiles ?? []; + const hasRo = workflowFiles.some((f) => isReleaseOrderFileCode(f.code) && f.file); + const hasT1Docs = workflowFiles.some( + (f) => /t1/i.test(f.code) && !/djibouti/i.test(f.code) && f.file, + ); + const bookingCreated = + Number(booking.totalAmount ?? 0) > 0 || + done("FREIGHT_PAYMENT_SETTLED") || + Boolean(clearance.t1); + const draftUploaded = done("DRAFT_DECLARATION_UPLOADED"); + const draftAccepted = done("DRAFT_DECLARATION_ACCEPTED"); + + const steps: Step[] = isImport + ? [ + { + key: "docs", + label: "Customer documents", + description: "Review and approve in the panel on the left", + owner: "forwarder", + done: done("DOCUMENTS_APPROVED") || Boolean(clearance.allApproved), + }, + { + key: "draft", + label: "Draft declaration", + description: draftUploaded + ? draftAccepted + ? "Customer accepted the estimated price" + : "Sent — waiting for the customer to accept" + : "Send the customer a draft declaration with an estimated price", + owner: "forwarder", + done: draftAccepted || done("DECLARED"), + }, + { + key: "declaration", + label: "Customs declaration", + description: "Upload the declaration documents", + owner: "forwarder", + done: done("DECLARED"), + }, + { + key: "permit", + label: "Transit permit", + description: "Upload the transit permit documents", + owner: "forwarder", + done: done("TRANSIT_PERMIT_UPLOADED"), + }, + { + key: "finalize", + label: "Finalize pre-clearance", + description: "Hand off to the Djibouti transit agent", + owner: "forwarder", + done: Boolean(clearance.preClearanceFinalized), + }, + { + key: "do", + label: "Delivery Order", + description: "The Djibouti agent uploads the DO", + owner: "djibouti", + done: done("DO_COLLECTED"), + }, + { + key: "create", + label: "Create booking", + description: "The customer completes the booking", + owner: "customer", + done: bookingCreated, + }, + { + key: "payment", + label: "Freight payment", + description: "Customer pays the train and service charges", + owner: "customer", + done: done("FREIGHT_PAYMENT_SETTLED"), + }, + { + key: "gatepass", + label: "Gate pass", + description: "Secured on the train schedule after payment and wagon allocation", + owner: "system", + done: Boolean(clearance.gatepassGranted), + }, + { + key: "offload", + label: "Offload", + description: "Cargo comes off the train at its destination", + owner: "system", + done: Boolean(clearance.offloaded ?? clearance.offload?.offloaded), + }, + { + key: "t1docs", + label: "T1 transport documents", + description: "The Djibouti agent uploads after the train departs", + owner: "djibouti", + done: hasT1Docs || Boolean(clearance.t1Closed), + }, + { + key: "t1close", + label: "Close T1", + description: "Close once the train has arrived", + owner: "forwarder", + done: Boolean(clearance.t1Closed), + }, + { + key: "risk", + label: "Customs risk", + description: clearance.riskLevel + ? `Assigned: ${clearance.riskLevel}` + : "Assign Green / Yellow / Red", + owner: "forwarder", + done: Boolean(clearance.riskLevel), + }, + { + key: "release", + label: "Import release", + description: "Upload the release document", + owner: "forwarder", + done: Boolean(clearance.importReleaseGranted), + }, + ] + : [ + { + key: "docs", + label: "Customer documents", + description: "Review and approve in the panel on the left", + owner: "forwarder", + done: done("DOCUMENTS_APPROVED") || Boolean(clearance.allApproved), + }, + { + key: "ro", + label: "Release Order", + description: "The Djibouti agent uploads the RO with the vessel date", + owner: "djibouti", + done: done("RELEASE_ORDER_SECURED") || hasRo, + }, + { + key: "declaration", + label: "Customs declaration", + description: done("RELEASE_ORDER_SECURED") + ? "Upload the declaration — releases the export" + : "Upload the declaration; the export is released once the Release Order is in too", + owner: "forwarder", + done: done("DECLARED"), + }, + { + key: "create", + label: "Create booking", + description: "The customer completes the booking", + owner: "customer", + done: bookingCreated, + }, + { + key: "payment", + label: "Payment & wagon allocation", + description: "Customer pays; operations allocates wagons", + owner: "customer", + done: + done("FREIGHT_PAYMENT_SETTLED") && + (done("WAGON_ALLOCATED") || Boolean(clearance.train?.wagonAllocated)), + }, + { + key: "transport", + label: "Transport document", + description: "Upload after wagon allocation", + owner: "forwarder", + done: done("EXPORT_TRANSPORT_ISSUED"), + }, + { + key: "train", + label: "Train to Djibouti", + description: trainLegDescription(clearance.train), + owner: "system", + done: Boolean(clearance.train?.arrivedAt), + }, + { + key: "t1close", + label: "Accept T1", + description: "The Djibouti agent closes once the train arrives", + owner: "djibouti", + done: Boolean(clearance.t1Closed), + }, + { + key: "gatepass", + label: "Gate pass", + description: "Secured on the train schedule after arrival", + owner: "system", + done: Boolean(clearance.gatepassGranted), + }, + { + key: "offload", + label: "Offload", + description: "Cargo comes off the train at its destination", + owner: "system", + done: Boolean(clearance.offloaded ?? clearance.offload?.offloaded), + }, + ]; + + const firstPending = steps.findIndex((s) => !s.done); + const activeStep = firstPending === -1 ? steps.length : firstPending; + const percent = Math.round((activeStep / steps.length) * 100); + const current = steps[activeStep] ?? null; + + const refresh = () => { + void queryClient.invalidateQueries({ + queryKey: api.bookings.getClearance.queryKey({ id: bookingId }), + }); + void queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: bookingId }), + }); + void queryClient.invalidateQueries({ + queryKey: ["forwarder-clearance-history", bookingId], + }); + onChanged?.(); + }; + + return ( + + + + + + + + + {isImport ? "Import clearance" : "Export clearance"} + + + Step {Math.min(activeStep + 1, steps.length)} of {steps.length} + {current ? ` · ${current.label}` : " · complete"} + + + + + + + {percent}% + + + + + {current ? ( + + + + {OWNER_LABEL[current.owner].toUpperCase()} + + + {current.description} + + + ) : null} + + + {clearance.roHoldReason ? ( + + {clearance.roHoldReason} + + ) : null} + + + {steps.map((s) => ( + : undefined} + /> + ))} + + + {current?.owner === "forwarder" ? ( + + ) : current ? ( + + Waiting on {OWNER_LABEL[current.owner].toLowerCase()}: {current.description}. + + ) : ( + }> + Every step is complete. + + )} + + + ); +} + +/** The control for the forwarder's current step. */ +function StepAction({ + step, + bookingId, + isImport, + draftUploaded, + onDone, +}: { + step: string; + bookingId: string; + isImport: boolean; + draftUploaded: boolean; + onDone: () => void; +}) { + const [files, setFiles] = useState([]); + const [price, setPrice] = useState(""); + const [currency, setCurrency] = useState("ETB"); + const [risk, setRisk] = useState<"GREEN" | "YELLOW" | "RED" | null>(null); + const [note, setNote] = useState(""); + + const run = useMutation({ + mutationFn: async () => { + switch (step) { + case "draft": + await transitAssignmentsService.uploadDraftDeclaration( + bookingId, + files, + Number(price), + currency, + ); + return "Draft declaration sent to the customer"; + case "declaration": + // On export the API releases the export itself once both the + // declaration and the Release Order are on file. + await transitAssignmentsService.uploadDeclaration(bookingId, files); + return isImport ? "Declaration uploaded" : "Declaration uploaded — the export is released once the Release Order is in too"; + case "permit": + await transitAssignmentsService.uploadTransitPermit(bookingId, files); + return "Transit permit uploaded"; + case "finalize": + await transitAssignmentsService.finalizePreClearance(bookingId); + return "Pre-clearance finalized — handed to the Djibouti agent"; + case "transport": + await transitAssignmentsService.uploadTransportDocument(bookingId, files); + return "Transport document uploaded"; + case "t1close": + await transitAssignmentsService.closeT1(bookingId); + return "T1 closed"; + case "risk": + await transitAssignmentsService.assignRisk(bookingId, risk!, note); + return "Customs risk assigned"; + case "release": + await transitAssignmentsService.uploadImportRelease(bookingId, files); + return "Import release uploaded"; + default: + return "Done"; + } + }, + onSuccess: (message) => { + toast.success(message); + setFiles([]); + setNote(""); + setRisk(null); + onDone(); + }, + onError: (e: unknown) => toast.error(apiMessage(e, "The step could not be completed")), + }); + + const skipDraft = useMutation({ + mutationFn: () => transitAssignmentsService.skipDraftDeclaration(bookingId), + onSuccess: () => { + toast.success("Draft declaration skipped — file the declaration directly"); + onDone(); + }, + onError: (e: unknown) => toast.error(apiMessage(e, "Could not skip the draft")), + }); + + if (step === "docs") { + return ( + + Approve every required customer document in the review panel to move on. + + ); + } + + if (step === "draft") { + if (draftUploaded) { + return ( + + The draft declaration is with the customer. The next step unlocks when + they accept it, or when they send it back for changes. + + ); + } + return ( + + + + + setRisk((v as "GREEN" | "YELLOW" | "RED" | null) ?? null)} + radius="md" + /> +