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 ee7b218ea..381510e7e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -6,6 +6,7 @@ import { ForbiddenException, Get, HttpCode, + NotFoundException, Param, ParseUUIDPipe, Patch, @@ -786,6 +787,64 @@ export class BookingsController { } /** Owner-or-staff gate shared by the per-cancellation actions. */ + /** + * Scope a clearance READ that a transit agent may be making. + * + * Transit agents are portal accounts holding no permission and belonging to + * no company, so the audience guards admit them but the usual company-based + * ownership check would 404 every booking. This narrows them to the shipments + * assigned to them and leaves every other caller — staff and owning customers + * — on the path they already had. Purely widening: nothing that passed before + * starts failing here. + */ + private async assertTransitAgentScope( + bookingId: string, + user: TCurrentUser, + ): Promise { + const userId = user?.id; + if (!userId) return; + if (!(await this.bookingsService.isTransitAgent(userId))) return; + if ( + !(await this.bookingsService.isTransitAgentForBooking(userId, bookingId)) + ) { + // Hidden behind a NotFound so booking ids stay unprobeable, matching the + // customer-ownership failure mode. + throw new NotFoundException(`Booking ${bookingId} not found`); + } + } + + /** + * Gate a formerly staff-only clearance route that is now MixedAudience. + * + * Staff still pass on their permission. A portal caller must be a transit + * agent assigned to THIS booking — an ordinary customer is rejected, because + * relaxing the guard must not hand the whole customer base a route that was + * previously staff-only. + * + * Used for the Djibouti-desk WRITES too (DO/RO upload, RO amendment): the + * assigned agent files them in the desk's place, and the assignment is the + * only thing standing between a portal token and the customs record. + */ + private async assertPortalClearanceAccess( + bookingId: string, + user: TCurrentUser, + ): Promise { + if ( + hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) || + hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) + ) { + return; + } + if ( + !(await this.bookingsService.isTransitAgentForBooking( + user?.id, + bookingId, + )) + ) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + } + private async assertWagonCancellationActor( cancellationId: string, user: TCurrentUser, @@ -1139,6 +1198,9 @@ export class BookingsController { } @Get(":id/clearance") + // A transit agent is a portal account, so MixedAudience admits them without a + // permission; `assertTransitAgentScope` below narrows them to the shipments + // actually assigned to them. @MixedAudience([ FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.reviewDocuments, @@ -1147,7 +1209,11 @@ export class BookingsController { summary: "Document-clearance grid (required docs + upload + GL review status)", }) - getClearance(@Param("id", ParseUUIDPipe) id: string) { + async getClearance( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + await this.assertTransitAgentScope(id, user); return this.transitionService.getClearanceView(id); } @@ -1289,8 +1355,11 @@ export class BookingsController { return { success: true }; } + // Was staff-only. Opened to the transit agent assigned to the shipment, who + // needs the clearance trail for the bookings they handle; every other portal + // account is still rejected by the scope check below. @Get(":id/clearance/history") - @BookingStaff([ + @MixedAudience([ FREIGHT_PERMS.contracts.clearanceEtActions, FREIGHT_PERMS.contracts.clearanceDjActions, ]) @@ -1298,7 +1367,11 @@ export class BookingsController { summary: "Clearance action history for the booking — reviews, workflow steps, charges (newest first)", }) - getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) { + async getClearanceHistory( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + await this.assertPortalClearanceAccess(id, user); return this.clearanceEventService.list(id); } @@ -1321,6 +1394,12 @@ export class BookingsController { hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) || hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions); if (isStaff) return this.clearanceChargeService.list(id); + // The transit agent handling this shipment sees the same customer-facing + // slice the customer does — charges actually sent, never the internal + // draft/billing view `list()` returns. + if (await this.bookingsService.isTransitAgentForBooking(user?.id, id)) { + return this.clearanceChargeService.listForCustomer(id); + } const booking = await this.bookingsService.findById(id); await this.bookingsService.assertCustomerCanAccessBooking( user?.id, @@ -1788,8 +1867,10 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + // Djibouti-desk write, also filed by the transit agent assigned to this + // shipment — `assertPortalClearanceAccess` rejects every other portal caller. @Post(":id/clearance/delivery-order") - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") async uploadBookingDeliveryOrder( @@ -1799,6 +1880,7 @@ export class BookingsController { @Body("doCollectedDate") doCollectedDate: string | undefined, @CurrentUser() user: TCurrentUser, ) { + await this.assertPortalClearanceAccess(id, user); const booking = await this.bookingClearanceService.uploadDeliveryOrder( id, files ?? [], @@ -1809,7 +1891,7 @@ export class BookingsController { } @Post(":id/clearance/release-order") - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") async uploadBookingReleaseOrder( @@ -1818,6 +1900,7 @@ export class BookingsController { @Body("vesselDepartureDate") vesselDepartureDate: string, @CurrentUser() user: TCurrentUser, ) { + await this.assertPortalClearanceAccess(id, user); const result = await this.bookingClearanceService.uploadReleaseOrder( id, files ?? [], @@ -1831,13 +1914,69 @@ export class BookingsController { }; } + // Transit-agent arrival paperwork (export): gate pass and Djibouti T1 sets. + // Same audience rule as the DO/RO uploads above — the desk, or the agent + // assigned to this shipment. + @Post(":id/clearance/gate-pass-documents") + @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes("multipart/form-data") + async uploadBookingGatePassDocuments( + @Param("id", ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + await this.assertPortalClearanceAccess(id, user); + return this.bookingClearanceService.uploadTransitArrivalDocuments( + id, + "gate_pass", + files ?? [], + resolveAuthUserId(user), + ); + } + + @Post(":id/clearance/djibouti-t1-documents") + @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes("multipart/form-data") + async uploadBookingDjiboutiT1Documents( + @Param("id", ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + await this.assertPortalClearanceAccess(id, user); + return this.bookingClearanceService.uploadTransitArrivalDocuments( + id, + "djibouti_t1", + files ?? [], + resolveAuthUserId(user), + ); + } + + @Delete(":id/clearance/transit-documents/:fileId") + @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) + @HttpCode(204) + async removeBookingTransitDocument( + @Param("id", ParseUUIDPipe) id: string, + @Param("fileId", ParseUUIDPipe) fileId: string, + @CurrentUser() user: TCurrentUser, + ) { + await this.assertPortalClearanceAccess(id, user); + await this.bookingClearanceService.removeTransitArrivalDocument( + id, + fileId, + resolveAuthUserId(user), + ); + } + @Post(":id/clearance/ro-amendment") - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) async requestBookingRoAmendment( @Param("id", ParseUUIDPipe) id: string, @Body() dto: RoAmendmentDto, @CurrentUser() user: TCurrentUser, ) { + await this.assertPortalClearanceAccess(id, user); const booking = await this.bookingClearanceService.requestRoAmendment( id, dto.note, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index d777c6414..c7294a004 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -2065,6 +2065,65 @@ ${footer} } } + /** + * True when `userId` is a transit agent currently assigned to this booking. + * + * Deliberately NOT folded into {@link assertCustomerCanAccessBooking}: that + * assertion guards ~29 call sites, including wagon cancellations, rebooking + * and customer-truck writes. A transit agent must reach the clearance READS + * for the shipments they handle and nothing else, so the two ownership rules + * stay separate and each caller opts in explicitly. + * + * Queried directly rather than through TransitAssignmentsService: that module + * imports BookingsModule, so injecting it here would close an import cycle. + */ + /** Is this portal account a transit agent at all? */ + async isTransitAgent(userId: string | undefined): Promise { + if (!userId) return false; + const rows: { one: number }[] = await this.dataSource.query( + `SELECT 1 AS one + FROM freight.transit_agents a + WHERE a.user_id = $1 AND a.deleted_at IS NULL + LIMIT 1`, + [userId], + ); + return rows.length > 0; + } + + async isTransitAgentForBooking( + userId: string | undefined, + bookingId: string, + ): Promise { + if (!userId) return false; + const rows: { one: number }[] = await this.dataSource.query( + `SELECT 1 AS one + FROM freight.transit_assignments ta + JOIN freight.transit_agents a ON a.id = ta.transit_agent_id + WHERE a.user_id = $1 + AND ta.booking_id = $2 + AND ta.deleted_at IS NULL + AND a.deleted_at IS NULL + LIMIT 1`, + [userId, bookingId], + ); + return rows.length > 0; + } + + /** + * Authorize a clearance READ on one booking for either audience a portal + * account can be: the owning customer, or a transit agent assigned to it. + * + * Read-only by contract — every caller is a GET. Writes keep using + * {@link assertCustomerCanAccessBooking}, which a transit agent never passes. + */ + async assertCanReadBookingClearance( + userId: string | undefined, + booking: Booking, + ): Promise { + if (await this.isTransitAgentForBooking(userId, booking.id)) return; + await this.assertCustomerCanAccessBooking(userId, booking); + } + /** * Build the customer-facing shipment tracking payload for a booking from the * train schedule it is assigned to and the live checkpoint log. The caller is 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 5cee12d3b..129789898 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 @@ -116,6 +116,7 @@ function makeService(overrides?: { .fn() .mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }), } as never, // transit agents + { ensureAssignment: jest.fn() } as never, // transit assignments { 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 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 b4cead816..b776a547c 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 @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { In } from 'typeorm'; import { ContractDocPhase, @@ -30,10 +30,12 @@ import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; import { GlExchangeService } from './gl-exchange.service'; import { TransitAgentsService } from '../transit-agents/transit-agents.service'; +import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service'; import { YardScopeService } from '../rule-engine/services/yard-scope.service'; import { ContractsRepository } from './contracts.repository'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; -import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitArrivalUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES, transitArrivalDocumentMatcher } from './phased-clearance.util'; +import type { TransitArrivalDocumentKind } from '@edr/types'; import { buildClearanceDocHistory, @@ -45,6 +47,8 @@ import { clearanceDocumentsOpen } from '../bookings/clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; export interface BookingClearanceView { + /** Booking creation stamp — the import DO is timed from it. */ + bookingCreatedAt?: string | null; bookingId: string; status: string; includesCustoms: boolean; @@ -176,6 +180,7 @@ export class BookingClearanceService { private readonly notifier: BookingLifecycleNotifierService, private readonly glExchangeService: GlExchangeService, private readonly transitAgentsService: TransitAgentsService, + private readonly transitAssignmentsService: TransitAssignmentsService, private readonly contractsRepository: ContractsRepository, private readonly yardScope: YardScopeService, private readonly clearanceEvents: ClearanceEventService, @@ -365,6 +370,7 @@ export class BookingClearanceService { return { bookingId, status: booking.status, + bookingCreatedAt: booking.createdAt ? new Date(booking.createdAt).toISOString() : null, includesCustoms, inputCode, outputCode, @@ -385,6 +391,7 @@ export class BookingClearanceService { status: m.status, ownerRegion: m.ownerRegion, metadata: (m.metadata ?? null) as Record | null, + triggeredAt: m.triggeredAt ? new Date(m.triggeredAt).toISOString() : null, sortOrder: m.sortOrder, })), nextAction, @@ -593,6 +600,17 @@ export class BookingClearanceService { transitAssigneeName: agent.name, transitAssigneeAssignedAt: new Date(), } as never); + + // The booking only stores the officer's NAME, which is what the clearance + // UI reads. The agent's own portal works off `transit_assignments` rows, so + // without this the shipment never reaches the officer's work list — the + // desk believes it handed the job over and nothing arrives. + await this.transitAssignmentsService.ensureAssignment( + bookingId, + transitAgentId, + userId, + ); + await this.clearanceEvents.record({ bookingId, action: 'TRANSIT_ASSIGNEE_ASSIGNED', @@ -1161,6 +1179,83 @@ export class BookingClearanceService { return { booking: await this.bookingsService.findById(bookingId), hold: false }; } + // ── Transit-agent arrival paperwork (export) ──────────────────────────── + // Gate pass and Djibouti T1 documents the assigned transit officer files at + // Djibouti around train arrival. Append-only sets with per-file removal — see + // `persistTransitArrivalUploads`. The clearance view stamps every file with + // its upload time, so the portal can measure it against train departure and + // arrival without a separate ledger. + + private static readonly TRANSIT_ARRIVAL_LABELS: Record< + TransitArrivalDocumentKind, + { name: string; uploaded: string; removed: string } + > = { + gate_pass: { + name: 'gate pass', + uploaded: 'GATE_PASS_DOCUMENTS_UPLOADED', + removed: 'GATE_PASS_DOCUMENT_REMOVED', + }, + djibouti_t1: { + name: 'Djibouti T1', + uploaded: 'DJIBOUTI_T1_DOCUMENTS_UPLOADED', + removed: 'DJIBOUTI_T1_DOCUMENT_REMOVED', + }, + }; + + async uploadTransitArrivalDocuments( + bookingId: string, + kind: TransitArrivalDocumentKind, + files: Express.Multer.File[], + userId?: string, + ): Promise<{ uploaded: number }> { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException( + 'Gate pass and Djibouti T1 documents apply only to export bookings.', + ); + } + const labels = BookingClearanceService.TRANSIT_ARRIVAL_LABELS[kind]; + await persistTransitArrivalUploads(this.filesService, bookingId, kind, files ?? [], userId); + await this.clearanceEvents.record({ + bookingId, + action: labels.uploaded, + label: `Uploaded ${files.length} ${labels.name} document(s)`, + actorId: userId ?? null, + metadata: { kind, fileNames: (files ?? []).map((f) => f.originalname) }, + }); + return { uploaded: files.length }; + } + + /** + * Remove ONE gate pass / Djibouti T1 file. Only those two code families are + * removable here: the route is reachable by the transit agent, and it must + * never become a way to delete a declaration or a Release Order. + */ + async removeTransitArrivalDocument( + bookingId: string, + fileId: string, + userId?: string, + ): Promise { + await this.loadBooking(bookingId); + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const file = files.find((f) => f.id === fileId); + const kind = (['gate_pass', 'djibouti_t1'] as const).find((k) => + transitArrivalDocumentMatcher(k)(file?.code), + ); + if (!file || !kind) { + throw new NotFoundException('Document not found on this booking.'); + } + await this.filesService.remove(fileId); + const labels = BookingClearanceService.TRANSIT_ARRIVAL_LABELS[kind]; + await this.clearanceEvents.record({ + bookingId, + action: labels.removed, + label: `Removed ${labels.name} document ${file.name}`, + actorId: userId ?? null, + metadata: { kind, fileName: file.name }, + }); + } + async requestRoAmendment( bookingId: string, note?: string, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index c95eb73b7..c15d20800 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -31,6 +31,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractNotifierService } from './contract-notifier.service'; import { GlOperationsService } from './gl-operations.service'; import { TransitAgentsService } from '../transit-agents/transit-agents.service'; +import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service'; import { ClearanceMilestone, type RiskAssignmentRecord, @@ -185,6 +186,7 @@ export class ContractClearanceService { private readonly glOperationsService: GlOperationsService, private readonly notifier: ContractNotifierService, private readonly transitAgentsService: TransitAgentsService, + private readonly transitAssignmentsService: TransitAssignmentsService, private readonly dataSource: DataSource, ) {} @@ -480,6 +482,7 @@ export class ContractClearanceService { status: m.status, ownerRegion: m.ownerRegion, metadata: (m.metadata ?? null) as Record | null, + triggeredAt: m.triggeredAt ? new Date(m.triggeredAt).toISOString() : null, sortOrder: m.sortOrder, })), nextAction, @@ -1230,6 +1233,18 @@ export class ContractClearanceService { transitAssigneeAssignedByUserId: userId ?? null, }); + // Mirror the name onto the officer's own work list, exactly as the + // per-booking path does. Contract-level clearance can be assigned before a + // booking exists; in that case there is nothing for the officer to work on + // yet, and the booking picks the assignment up when it is created. + if (cycle.bookingId) { + await this.transitAssignmentsService.ensureAssignment( + cycle.bookingId, + transitAgentId, + userId, + ); + } + const updated = await this.contractsService.findById(contractId); this.notifier.transitAssigneeAssigned(updated, agent.name, previous); return updated; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts index a7bd3cd7d..b35f9d844 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts @@ -63,6 +63,7 @@ describe('ContractClearanceService — duty dispute', () => { {} as never, // glOperationsService notifier as never, {} as never, // transitAgentsService + {} as never, // transitAssignmentsService {} as never, // dataSource ); build([ 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 c38576fff..7cf7a8398 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -4,6 +4,7 @@ import { Delete, Get, HttpCode, + NotFoundException, Param, ParseUUIDPipe, Patch, @@ -1359,19 +1360,35 @@ export class ContractsController { return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []); } + // 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') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs', }) - uploadT1Documents( + async uploadT1Documents( @Param('bookingId', ParseUUIDPipe) bookingId: string, @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, ) { - return this.glOperationsService.uploadT1Documents(bookingId, files ?? []); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) && + !(await this.bookingsService.isTransitAgentForBooking( + user?.id, + bookingId, + )) + ) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + return this.glOperationsService.uploadT1Documents( + bookingId, + files ?? [], + resolveAuthUserId(user), + ); } @Post('bookings/:bookingId/t1-close') @@ -1534,6 +1551,8 @@ export class ContractsController { @MixedAudience(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' }) listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + // Reads are open to both audiences (a transit agent assigned to the + // shipment included); reporting an incident stays staff-only below. return this.glOperationsService.listIncidents(bookingId); } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 2a07b02e5..a198a0282 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -18,6 +18,7 @@ import { BookingsModule } from '../bookings/bookings.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { ContractTemplatesModule } from '../contract-templates/contract-templates.module'; import { TransitAgentsModule } from '../transit-agents/transit-agents.module'; +import { TransitAssignmentsModule } from '../transit-assignments/transit-assignments.module'; import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; @@ -94,6 +95,10 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum // ContractDocumentViewModelBuilder when rendering contract PDFs. ContractTemplatesModule, TransitAgentsModule, + // Assigning a transit assignee must also land a row in the officer's own + // work list. This module is a leaf (it registers Booking as an entity + // rather than importing BookingsModule), so no cycle is closed here. + TransitAssignmentsModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). forwardRef(() => BookingsModule), diff --git a/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts b/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts index 4875d9e24..25f798e68 100644 --- a/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts @@ -50,6 +50,7 @@ describe('GlOperationsService — final invoice approval', () => { {} as never, // milestoneService billingService as never, notifier as never, + { record: jest.fn() } as never, // clearanceEvents ); }); diff --git a/apps/edr-freight-api/src/modules/contracts/gl-exchange.controller.ts b/apps/edr-freight-api/src/modules/contracts/gl-exchange.controller.ts index 6ea0dacbe..b387d9370 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-exchange.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-exchange.controller.ts @@ -4,6 +4,7 @@ import { Delete, Get, HttpCode, + NotFoundException, Param, ParseUUIDPipe, Patch, @@ -17,10 +18,11 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { actorLabel } from '../warehouses/current-actor.util'; -import { BookingStaff } from '../../common/booking-guards'; +import { BookingStaff, MixedAudience } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { hasFreightPermission } from '../../common/freight-permission.util'; import { resolveAuthUserId } from '../../common/resolve-auth-user-id'; +import { BookingsService } from '../bookings/bookings.service'; import { GlExchangeService, @@ -42,37 +44,61 @@ const asBool = (raw: string | boolean | undefined): boolean => @ApiBearerAuth() @Controller('gl-exchange') export class GlExchangeController { - constructor(private readonly exchangeService: GlExchangeService) {} + constructor( + private readonly exchangeService: GlExchangeService, + private readonly bookingsService: BookingsService, + ) {} + // Read opened to the transit agent assigned to the shipment; the POST/PATCH/ + // DELETE below stay staff-only, so an agent can read the desks' thread but + // never post to it. @Get(':entityId') - @BookingStaff(GL_EXCHANGE_PERMS) + @MixedAudience(GL_EXCHANGE_PERMS) @ApiOperation({ summary: 'GL ET ↔ GL DJ shared documents for a booking or contract', }) - list( + async list( @Param('entityId', ParseUUIDPipe) entityId: string, @CurrentUser() user: TCurrentUser, ) { + const isStaff = GL_EXCHANGE_PERMS.some((p) => + hasFreightPermission(user, p), + ); + if ( + !isStaff && + !(await this.bookingsService.isTransitAgentForBooking( + user?.id, + entityId, + )) + ) { + throw new NotFoundException(`Entity ${entityId} not found`); + } return this.exchangeService.list(entityId, resolveAuthUserId(user)); } + // Open to the transit agent assigned to the shipment as well as both desks: + // the officer on the ground is often the one holding the scan either desk + // needs. Their post is attributed to the TRANSIT side, never to a desk. @Post(':entityId') - @BookingStaff(GL_EXCHANGE_PERMS) + @MixedAudience(GL_EXCHANGE_PERMS) @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Share a document with the other GL desk' }) - upload( + @ApiOperation({ + summary: 'Share a document with the GL desks (either desk, or the assigned transit agent)', + }) + async upload( @Param('entityId', ParseUUIDPipe) entityId: string, @UploadedFile() file: Express.Multer.File | undefined, @Body('title') title: string, @Body('visibleToCustomer') visibleToCustomer: string | undefined, @CurrentUser() user: TCurrentUser, ) { + const actor = await this.resolveActor(entityId, user); return this.exchangeService.upload( entityId, file, { title, visibleToCustomer: asBool(visibleToCustomer) }, - this.actor(user), + actor, ); } @@ -118,6 +144,36 @@ export class GlExchangeController { * is Djibouti; everyone else (GL Ethiopia, and super admins who hold both) * posts as Ethiopia. */ + /** + * Who is posting, for a route both desks and the assigned transit agent may + * call. Staff keep the desk attribution below; a portal caller must be the + * agent assigned to this shipment and posts as TRANSIT, so a document is + * never credited to a desk that did not send it. + */ + private async resolveActor( + entityId: string, + user: TCurrentUser, + ): Promise { + const isStaff = GL_EXCHANGE_PERMS.some((p) => + hasFreightPermission(user, p), + ); + if (isStaff) return this.actor(user); + + if ( + !(await this.bookingsService.isTransitAgentForBooking( + user?.id, + entityId, + )) + ) { + throw new NotFoundException(`Entity ${entityId} not found`); + } + return { + userId: resolveAuthUserId(user), + name: actorLabel(user) ?? null, + side: 'TRANSIT', + }; + } + private actor(user: TCurrentUser): GlExchangeActor { const side: GlExchangeSide = !hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) && diff --git a/apps/edr-freight-api/src/modules/contracts/gl-exchange.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-exchange.service.ts index 1b0d88b2a..e76b26efd 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-exchange.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-exchange.service.ts @@ -17,7 +17,7 @@ import type { FileRecord } from '../files/entities/file.entity'; */ export const GL_EXCHANGE_RESOURCE = 'gl_exchange'; -export type GlExchangeSide = 'ET' | 'DJ'; +export type GlExchangeSide = 'ET' | 'DJ' | 'TRANSIT'; export interface GlExchangeActor { userId: string; @@ -180,7 +180,14 @@ export class GlExchangeService { // Pre-title rows (none in practice) fall back to the filename so a list // never renders a blank row. title: record.title ?? record.name, - side: record.code === 'DJ' ? 'DJ' : 'ET', + // `files.code` carries the poster's side. Anything unrecognised reads as + // ET, which is how every pre-TRANSIT row was written. + side: + record.code === 'DJ' + ? 'DJ' + : record.code === 'TRANSIT' + ? 'TRANSIT' + : 'ET', visibleToCustomer: record.visibleToCustomer, uploadedById: record.uploadedByUserId, uploadedByName: record.uploadedByName, diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 6516ff9ce..70b42a9aa 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -12,6 +12,7 @@ import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { FilesService } from '../files/files.service'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; +import { ClearanceEventService } from '../bookings/clearance-event.service'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity'; import { @@ -55,6 +56,7 @@ export class GlOperationsService { private readonly milestoneService: ClearanceMilestoneService, private readonly billingService: BillingService, private readonly notifier: BookingLifecycleNotifierService, + private readonly clearanceEvents: ClearanceEventService, ) {} private get bookings() { @@ -362,13 +364,15 @@ export class GlOperationsService { } /** - * GL Djibouti uploads T1 transport documents (multi-file) once the gate pass - * is secured on the train schedule (which itself follows wagon allocation). - * Replaces the previous batch; locked only once GL Ethiopia closes the T1. + * GL Djibouti / the transit agent uploads T1 transport documents (multi-file) + * once the train has DEPARTED Djibouti. Replaces the previous batch, so the + * batch's file stamps are always the last update; locked only once GL + * Ethiopia closes the T1. */ async uploadT1Documents( bookingId: string, files: Express.Multer.File[], + userId?: string, ): Promise<{ uploaded: number }> { const booking = await this.getBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { @@ -376,24 +380,24 @@ export class GlOperationsService { } const state = await this.t1State(bookingId); - if (!state.wagonAllocated) { + if (!state.trainDepartedAt) { throw new BadRequestException( - 'Wagons must be allocated before T1 transport documents can be uploaded.', - ); - } - const gatepass = await this.gatepassForBooking(bookingId); - if (!gatepass.granted) { - throw new BadRequestException( - 'Secure the Djibouti gate pass on the train schedule before uploading T1 transport documents.', + 'T1 transport documents can be uploaded once the train has departed.', ); } if (state.closed) { throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.'); } - // Departure no longer locks T1 docs — GL DJ may replace them any time until - // GL Ethiopia closes/accepts the T1. await persistT1TransportUploads(this.filesService, bookingId, files); + // History row so the portal can tell a first upload from a replacement. + await this.clearanceEvents.record({ + bookingId, + action: 'T1_DOCUMENTS_UPLOADED', + label: `Uploaded T1 transport documents (${files.length} file(s))`, + actorId: userId ?? null, + metadata: { fileNames: files.map((f) => f.originalname) }, + }); return { uploaded: files.length }; } diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts index ab2a2b2d4..05edb9cab 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -12,10 +12,17 @@ import { isImportTransitPermitFileCode, isExportTransportFileCode, isT1TransportFileCode, + isGatePassFileCode, + isDjiboutiT1FileCode, exportTransportFileLabel, t1TransportFileLabel, transitPermitFileLabel, + gatePassFileLabel, + djiboutiT1FileLabel, + GATE_PASS_FILE_PREFIX, + DJIBOUTI_T1_FILE_PREFIX, type ClearanceWorkflowFile, + type TransitArrivalDocumentKind, } from '@edr/types'; /** Require at least one declaration file in the upload batch. */ @@ -46,6 +53,7 @@ type DeclarationFileStore = { resource: string; code: string; file: Express.Multer.File; + uploadedByUserId?: string | null; }): Promise; }; @@ -445,9 +453,88 @@ export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [ /** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */ export const DJ_BOOKING_QUEUE_STATUSES = PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES; +/** Prefix + matcher + label for each transit-agent arrival document set. */ +const TRANSIT_ARRIVAL_DOCUMENT_SETS: Record< + TransitArrivalDocumentKind, + { prefix: string; matches: (code: string | null | undefined) => boolean; label: (i?: number) => string } +> = { + gate_pass: { prefix: GATE_PASS_FILE_PREFIX, matches: isGatePassFileCode, label: gatePassFileLabel }, + djibouti_t1: { prefix: DJIBOUTI_T1_FILE_PREFIX, matches: isDjiboutiT1FileCode, label: djiboutiT1FileLabel }, +}; + +export function transitArrivalDocumentMatcher( + kind: TransitArrivalDocumentKind, +): (code: string | null | undefined) => boolean { + return TRANSIT_ARRIVAL_DOCUMENT_SETS[kind].matches; +} + +/** + * APPEND a batch of transit-agent arrival documents (gate pass / Djibouti T1) + * to a booking. Unlike the DO/RO persisters this never deletes what is already + * there: the officer collects these one at a time as the paperwork comes in, + * and each file is removed individually. Codes continue from the highest + * existing index so a removed file's slot is never reused. + */ +export async function persistTransitArrivalUploads( + store: DeclarationFileStore, + bookingId: string, + kind: TransitArrivalDocumentKind, + files: Express.Multer.File[], + uploadedByUserId?: string | null, +): Promise { + if (files.length === 0) { + throw new BadRequestException('No documents uploaded'); + } + const set = TRANSIT_ARRIVAL_DOCUMENT_SETS[kind]; + const existing = await store.findByResource(bookingId, 'bookings'); + const nextIndex = + existing + .filter((f) => set.matches(f.code)) + .map((f) => Number.parseInt((f.code ?? '').slice(set.prefix.length), 10)) + .filter((n) => Number.isFinite(n)) + .reduce((max, n) => Math.max(max, n + 1), 0); + + await Promise.all( + files.map((file, index) => + store.upload({ + resourceId: bookingId, + resource: 'bookings', + code: `${set.prefix}${nextIndex + index}`, + file: { ...file, fieldname: `${set.prefix}${nextIndex + index}` }, + uploadedByUserId: uploadedByUserId ?? null, + }), + ), + ); +} + +type WorkflowFileInput = { + code?: string | null; + id: string; + name: string; + url: string; + createdAt?: Date | string | null; + updatedAt?: Date | string | null; + size?: number | null; + mimeType?: string | null; +}; + +function toWorkflowFileRef(file: WorkflowFileInput): NonNullable { + const iso = (v: Date | string | null | undefined) => + v ? new Date(v).toISOString() : null; + return { + id: file.id, + name: file.name, + url: file.url, + uploadedAt: iso(file.createdAt), + updatedAt: iso(file.updatedAt), + size: file.size ?? null, + mimeType: file.mimeType ?? null, + }; +} + /** Build labeled phased-customs file rows from resource files. */ export function buildWorkflowFiles( - files: Array<{ code?: string | null; id: string; name: string; url: string }>, + files: WorkflowFileInput[], tradeDirection: string, ): ClearanceWorkflowFile[] { const fileByCode = new Map( @@ -465,7 +552,7 @@ export function buildWorkflowFiles( label: entry.label, uploadedBy: entry.uploadedBy, category: entry.category, - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); } @@ -481,7 +568,7 @@ export function buildWorkflowFiles( label: declarationFileLabel(file.code, index), uploadedBy: 'gl_et', category: 'declaration', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); @@ -497,7 +584,7 @@ export function buildWorkflowFiles( label: draftDeclarationFileLabel(index), uploadedBy: 'gl_et', category: 'draft_declaration', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); @@ -514,7 +601,7 @@ export function buildWorkflowFiles( label: transitPermitFileLabel(file.code, index), uploadedBy: 'gl_et', category: 'transit', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); @@ -530,7 +617,7 @@ export function buildWorkflowFiles( label: deliveryOrderFileLabel(file.code, index), uploadedBy: 'gl_dj', category: 'djibouti', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); @@ -546,7 +633,7 @@ export function buildWorkflowFiles( label: t1TransportFileLabel(file.code, index), uploadedBy: 'gl_dj', category: 'djibouti', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); } @@ -564,7 +651,7 @@ export function buildWorkflowFiles( label: releaseOrderFileLabel(file.code, index), uploadedBy: 'gl_dj', category: 'djibouti', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); @@ -580,9 +667,32 @@ export function buildWorkflowFiles( label: exportTransportFileLabel(file.code, index), uploadedBy: 'gl_et', category: 'transit', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); + + // Transit-agent arrival paperwork, ordered by slot index (upload order). + const byIndex = (prefix: string) => (a: WorkflowFileInput, b: WorkflowFileInput) => + Number.parseInt((a.code ?? '').slice(prefix.length), 10) - + Number.parseInt((b.code ?? '').slice(prefix.length), 10); + + for (const kind of ['gate_pass', 'djibouti_t1'] as const) { + const set = TRANSIT_ARRIVAL_DOCUMENT_SETS[kind]; + files + .filter((f) => f.code && set.matches(f.code) && !included.has(f.code)) + .sort(byIndex(set.prefix)) + .forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: set.label(index), + uploadedBy: 'gl_dj', + category: 'djibouti', + file: toWorkflowFileRef(file), + }); + }); + } } return out; diff --git a/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts index 58c97619a..0b7de5be3 100644 --- a/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts @@ -61,6 +61,7 @@ describe('ContractClearanceService — transit assignee', () => { {} as never, notifier as never, transitAgentsService as never, + { ensureAssignment: jest.fn() } as never, // transit assignments {} as never, // dataSource ); }); diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts index 65edc5868..44862e2ff 100644 --- a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts @@ -2,7 +2,9 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { Booking } from "../bookings/entities/booking.entity"; +import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity"; import { FilesModule } from "../files/files.module"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; import { TransitAgentsModule } from "../transit-agents/transit-agents.module"; import { TransitAssignment } from "./entities/transit-assignment.entity"; import { TransitAssignmentsController } from "./transit-assignments.controller"; @@ -14,7 +16,15 @@ import { TransitAssignmentsService } from "./transit-assignments.service"; // `Booking` is registered as an ENTITY rather than importing BookingsModule: // this module only confirms a booking id exists, and that module would drag // its whole graph (billing, contracts, scheduling, first/last mile) along. - TypeOrmModule.forFeature([TransitAssignment, Booking]), + // Milestones and train schedules are read for the agent's dashboard + // timings (declaration stamps, departure/arrival fallbacks) — entities + // only, for the same reason as Booking. + TypeOrmModule.forFeature([ + TransitAssignment, + Booking, + ClearanceMilestone, + TrainSchedule, + ]), FilesModule, TransitAgentsModule, ], diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts index f23a8426a..68e856eff 100644 --- a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts @@ -38,6 +38,8 @@ describe("TransitAssignmentsService", () => { remove: jest.Mock; }; let service: TransitAssignmentsService; + let milestones: { find: jest.Mock }; + let trainSchedules: { find: jest.Mock }; const row = (over: Partial = {}) => ({ @@ -81,11 +83,16 @@ describe("TransitAssignmentsService", () => { remove: jest.fn(), }; + milestones = { find: jest.fn().mockResolvedValue([]) }; + trainSchedules = { find: jest.fn().mockResolvedValue([]) }; + service = new TransitAssignmentsService( assignments as never, agents as never, bookings as never, files as never, + milestones as never, + trainSchedules as never, ); }); @@ -294,222 +301,127 @@ describe("TransitAssignmentsService", () => { describe("myStats", () => { const at = (iso: string) => new Date(iso); + const DEPARTED = at("2026-08-27T20:00:00Z"); const withRows = (rows: Record[]) => { assignments.findByTransitAgent.mockResolvedValue( - rows.map((r, i) => row({ id: `ta-${i}`, ...r } as never)), + rows.map((r, i) => row({ id: `ta-${i}`, bookingId: `bk-${i}`, ...r } as never)), + ); + }; + const bookingFiles = (entries: Record>) => { + files.findByResourceIdsGrouped.mockImplementation( + async (_ids: string[], resource: string) => + resource === "bookings" + ? new Map( + Object.entries(entries).map(([bookingId, list]) => [ + bookingId, + list.map(([code, iso]) => ({ code, createdAt: at(iso) })), + ]), + ) + : new Map(), ); - files.findByResourceIdsGrouped.mockResolvedValue(new Map()); }; - it("uses the median, so one reopened assignment cannot skew the headline", async () => { + it("measures transit from the train's departure to its arrival, using the median", async () => { withRows([ - { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-28T10:35:00Z"), - }, - { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-28T12:10:00Z"), - }, - { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-28T13:45:00Z"), - }, - // 47h outlier: a mean would report ~12h, which describes nobody. - { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-30T08:00:00Z"), - }, + { booking: { loadedAt: DEPARTED, arrivedAt: at("2026-08-28T06:00:00Z"), tradeDirection: "EXPORT" } }, + { booking: { loadedAt: DEPARTED, arrivedAt: at("2026-08-28T08:00:00Z"), tradeDirection: "EXPORT" } }, + // 3-day outlier: a mean would describe none of the three. + { booking: { loadedAt: DEPARTED, arrivedAt: at("2026-08-30T20:00:00Z"), tradeDirection: "EXPORT" } }, + // Still rolling: contributes nothing, not zero. + { booking: { loadedAt: DEPARTED, arrivedAt: null, tradeDirection: "EXPORT" } }, ]); + bookingFiles({}); const stats = await service.myStats("user-1"); - // 95/190/285/2820 -> even count, so the median averages the middle two. - // A mean would be 848 minutes, describing none of the four. - expect(stats.performance.medianClearanceMinutes).toBe(238); - expect(stats.performance.slowestClearanceMinutes).toBe(2820); + expect(stats.timings.transit).toEqual({ + median: 720, + fastest: 600, + slowest: 4320, + measured: 3, + }); + expect(stats.totals.inTransit).toBe(1); + expect(stats.totals.arrived).toBe(3); }); - it("bands clearance times into the SLA buckets", async () => { - withRows([ + it("times the Release Order from the declaration to the LAST RO upload", async () => { + withRows([{ booking: { tradeDirection: "EXPORT", loadedAt: null, arrivedAt: null } }]); + milestones.find.mockResolvedValue([ { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-28T10:30:00Z"), - }, - { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-28T13:00:00Z"), - }, - { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-29T09:00:00Z"), + bookingId: "bk-0", + milestoneCode: "DECLARED", + status: "COMPLETED", + triggeredAt: at("2026-08-27T08:00:00Z"), }, ]); + bookingFiles({ + "bk-0": [ + ["release_order_0", "2026-08-27T09:30:00Z"], + // Replaced batch — the later stamp is the one that counts. + ["release_order_1", "2026-08-27T11:00:00Z"], + ], + }); const stats = await service.myStats("user-1"); + const [item] = stats.items; - expect(stats.sla).toEqual({ under2h: 1, under6h: 1, over6h: 1 }); - expect(stats.performance.onTimeRate).toBe(67); + expect(item.declaredAt).toBe("2026-08-27T08:00:00.000Z"); + expect(item.roAt).toBe("2026-08-27T11:00:00.000Z"); + expect(item.timings.declarationToRo).toBe(180); + expect(stats.timings.declarationToRo.median).toBe(180); + expect(item.nextAction).toEqual({ kind: "wait", label: "Awaiting train departure" }); }); - it("counts coverage only over dispatched bookings", async () => { + it("points the officer at the next upload the detail page would actually allow", async () => { withRows([ - { booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } }, - { booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } }, - // Scheduled bookings cannot receive documents yet, so counting them - // would report a failure the agent could not have avoided. - { booking: { arrivedAt: null, schedulingStatus: "SCHEDULED" } }, + // Import, nothing filed: the DO comes first. + { booking: { tradeDirection: "IMPORT", loadedAt: null, arrivedAt: null } }, + // Import with a DO but no departure yet: T1 is still locked. + { booking: { tradeDirection: "IMPORT", loadedAt: null, arrivedAt: null } }, + // Import, departed, no T1: upload it. + { booking: { tradeDirection: "IMPORT", loadedAt: DEPARTED, arrivedAt: null } }, + // Export, arrived with an RO but no gate pass yet. + { + booking: { + tradeDirection: "EXPORT", + loadedAt: DEPARTED, + arrivedAt: at("2026-08-28T06:00:00Z"), + }, + }, ]); + milestones.find.mockResolvedValue([ + { bookingId: "bk-3", milestoneCode: "DECLARED", status: "COMPLETED", triggeredAt: at("2026-08-26T08:00:00Z") }, + ]); + bookingFiles({ + "bk-1": [["delivery_order_0", "2026-08-26T10:00:00Z"]], + "bk-2": [["delivery_order_0", "2026-08-26T10:00:00Z"]], + "bk-3": [["release_order_0", "2026-08-26T10:00:00Z"]], + }); const stats = await service.myStats("user-1"); + const byBooking = new Map(stats.items.map((i) => [i.bookingId, i])); - expect(stats.coverage.dispatched).toBe(2); - expect(stats.coverage.withDocuments).toBe(0); + expect(byBooking.get("bk-0")?.nextAction.document).toBe("do"); + expect(byBooking.get("bk-1")?.nextAction).toEqual({ + kind: "wait", + label: "Awaiting train departure", + }); + expect(byBooking.get("bk-2")?.nextAction.document).toBe("t1"); + expect(byBooking.get("bk-3")?.nextAction.document).toBe("gate_pass"); + expect(stats.pending).toEqual({ ro: 0, do: 1, t1: 1, gatePass: 1, djiboutiT1: 0 }); + expect(stats.totals.actionNeeded).toBe(3); }); it("reports nulls rather than zero when nothing has been measured", async () => { - withRows([{ status: TransitAssignmentStatus.NotStarted }]); + withRows([{ status: TransitAssignmentStatus.NotStarted, booking: { arrivedAt: null } }]); + bookingFiles({}); const stats = await service.myStats("user-1"); - expect(stats.performance.medianClearanceMinutes).toBeNull(); - expect(stats.performance.onTimeRate).toBeNull(); + expect(stats.timings.transit.median).toBeNull(); + expect(stats.timings.arrivalToFinish.median).toBeNull(); expect(stats.totals.open).toBe(1); }); }); - - describe("customerName", () => { - it("flattens the booking's company name", async () => { - assignments.findOneWithRelations.mockResolvedValue( - row({ - booking: { - id: "bk-1", - arrivedAt: ARRIVED, - schedulingStatus: "DISPATCHED", - company: { name: "SHAFICI PHARMACEUTICAL" }, - } as never, - }), - ); - - expect((await service.findById("ta-1")).customerName).toBe( - "SHAFICI PHARMACEUTICAL", - ); - }); - - it("is null when the booking has no company", async () => { - expect((await service.findById("ta-1")).customerName).toBeNull(); - }); - }); - - describe("canUploadDocuments", () => { - it("is true for an open assignment on a dispatched booking", async () => { - expect((await service.findById("ta-1")).canUploadDocuments).toBe(true); - }); - - it("is false before dispatch", async () => { - assignments.findOneWithRelations.mockResolvedValue( - row({ - booking: { - id: "bk-1", - arrivedAt: null, - schedulingStatus: "SCHEDULED", - } as never, - }), - ); - expect((await service.findById("ta-1")).canUploadDocuments).toBe(false); - }); - - it("is false once finished", async () => { - assignments.findOneWithRelations.mockResolvedValue( - row({ - status: TransitAssignmentStatus.Finished, - finishedAt: new Date(), - }), - ); - expect((await service.findById("ta-1")).canUploadDocuments).toBe(false); - }); - }); - - describe("portal scoping", () => { - it("hides another agent's assignment behind a NotFound", async () => { - assignments.findOneWithRelations.mockResolvedValue( - row({ transitAgentId: "someone-else" }), - ); - - await expect(service.findMineById("user-1", "ta-1")).rejects.toThrow( - NotFoundException, - ); - }); - - it("rejects an account that is not a transit agent", async () => { - agents.findByUserId.mockResolvedValue(null); - - await expect(service.findMine("user-1")).rejects.toThrow( - ForbiddenException, - ); - }); - - it("pins the query to the session's agent and passes the filters through", async () => { - await service.findMine("user-1", { - search: "BK-2026", - status: TransitAssignmentStatus.InProgress, - schedulingStatus: "DISPATCHED", - page: 2, - pageSize: 10, - }); - - const [agentId, filter, skip, take] = - assignments.findByTransitAgentPaginated.mock.calls[0]; - // The agent id comes from the session, never from the query — otherwise - // one agent could page through another agent's work. - expect(agentId).toBe("ag-1"); - expect(filter).toMatchObject({ - search: "BK-2026", - status: TransitAssignmentStatus.InProgress, - schedulingStatus: "DISPATCHED", - }); - expect(skip).toBe(10); - expect(take).toBe(10); - }); - - it("reports pagination meta", async () => { - assignments.findByTransitAgentPaginated.mockResolvedValue([[], 45]); - - const result = await service.findMine("user-1", { pageSize: 20 }); - - expect(result.meta).toEqual({ - total: 45, - page: 1, - pageSize: 20, - totalPages: 3, - }); - }); - - it("save moves the assignment to IN_PROGRESS, finish closes it", async () => { - await service.submitMine("user-1", "ta-1", { finish: false }); - expect(assignments.update.mock.calls[0][1].status).toBe( - TransitAssignmentStatus.InProgress, - ); - - assignments.update.mockClear(); - await service.submitMine("user-1", "ta-1", { finish: true }); - expect(assignments.update.mock.calls[0][1].status).toBe( - TransitAssignmentStatus.Finished, - ); - }); - - it("refuses to re-submit an already finished assignment", async () => { - assignments.findOneWithRelations.mockResolvedValue( - row({ - status: TransitAssignmentStatus.Finished, - finishedAt: new Date(), - }), - ); - - await expect( - service.submitMine("user-1", "ta-1", { finish: true }), - ).rejects.toThrow(ForbiddenException); - }); - }); }); diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts index 389e08fac..80f8d6be9 100644 --- a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts @@ -7,10 +7,19 @@ import { } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { In, Repository } from "typeorm"; +import { + isDeliveryOrderFileCode, + isDjiboutiT1FileCode, + isGatePassFileCode, + isReleaseOrderFileCode, + isT1TransportFileCode, +} from "@edr/types"; import { Booking } from "../bookings/entities/booking.entity"; +import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity"; import { FilesService } from "../files/files.service"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; import { TransitAgentsRepository } from "../transit-agents/transit-agents.repository"; import { FileRecord } from "../files/entities/file.entity"; import { CreateTransitAssignmentDto } from "./dto/create-transit-assignment.dto"; @@ -66,6 +75,86 @@ export type TransitAssignmentView = TransitAssignment & { files?: TransitAssignmentFileView[]; }; +export type TransitTradeDirection = "IMPORT" | "EXPORT"; +export type TransitDocumentKind = "ro" | "do" | "t1" | "gate_pass" | "djibouti_t1"; + +export interface TransitNextAction { + kind: "upload" | "wait" | "done"; + label: string; + document?: TransitDocumentKind; +} + +/** Minutes, or null when nothing has been measured yet — never zero. */ +export interface TransitTimingSummary { + median: number | null; + fastest: number | null; + slowest: number | null; + measured: number; +} + +export interface TransitStatItem { + id: string; + bookingId: string; + reference: string | null; + customerName: string | null; + tradeDirection: TransitTradeDirection; + status: TransitAssignmentStatus; + schedulingStatus: string | null; + trainLabel: string | null; + assignedAt: string; + startedAt: string | null; + finishedAt: string | null; + bookingCreatedAt: string | null; + departedAt: string | null; + arrivedAt: string | null; + declaredAt: string | null; + roAt: string | null; + doAt: string | null; + t1At: string | null; + t1Closed: boolean; + gatePassAt: string | null; + djiboutiT1At: string | null; + documents: { + ro: number; + do: number; + t1: number; + gatePass: number; + djiboutiT1: number; + own: number; + }; + timings: { + transit: number | null; + declarationToRo: number | null; + bookingToDo: number | null; + departureToT1: number | null; + arrivalToT1: number | null; + arrivalToGatePass: number | null; + arrivalToDjiboutiT1: number | null; + arrivalToFinish: number | null; + }; + nextAction: TransitNextAction; +} + +export interface TransitStats { + totals: { + assignments: number; + open: number; + notStarted: number; + inProgress: number; + finished: number; + imports: number; + exports: number; + awaitingDeparture: number; + inTransit: number; + arrived: number; + actionNeeded: number; + }; + timings: Record; + documents: TransitStatItem["documents"]; + pending: { ro: number; do: number; t1: number; gatePass: number; djiboutiT1: number }; + items: TransitStatItem[]; +} + @Injectable() export class TransitAssignmentsService { constructor( @@ -77,6 +166,10 @@ export class TransitAssignmentsService { @InjectRepository(Booking) private readonly bookingsRepository: Repository, private readonly filesService: FilesService, + @InjectRepository(ClearanceMilestone) + private readonly milestonesRepository: Repository, + @InjectRepository(TrainSchedule) + private readonly trainSchedulesRepository: Repository, ) {} private static minutesBetween( @@ -179,98 +272,302 @@ export class TransitAssignmentsService { * reopened days later drags an average far enough to make the whole panel * lie about typical performance. */ - async myStats(userId: string) { + /** + * The agent's dashboard, every figure derived from stamps that already exist: + * the train's departure and arrival, the booking's clearance milestones, and + * the upload time of each document on the booking (RO / DO / T1 / gate pass / + * Djibouti T1). Replaced batches carry a fresh stamp, so an "uploaded" time + * here is always the LAST update, matching the detail page. + * + * Nothing is stored: a corrected timestamp cannot leave a stale number behind. + */ + async myStats(userId: string): Promise { const agent = await this.requireAgentForUser(userId); const rows = await this.assignmentsRepository.findByTransitAgent(agent.id); + const bookingIds = [...new Set(rows.map((r) => r.bookingId))]; - const docCounts = rows.length - ? await this.filesService.findByResourceIdsGrouped( - rows.map((r) => r.id), - TRANSIT_ASSIGNMENT_FILE_RESOURCE, - ) - : new Map(); + const [ownDocs, bookingDocs, milestones, schedules] = await Promise.all([ + rows.length + ? this.filesService.findByResourceIdsGrouped( + rows.map((r) => r.id), + TRANSIT_ASSIGNMENT_FILE_RESOURCE, + ) + : new Map(), + bookingIds.length + ? this.filesService.findByResourceIdsGrouped(bookingIds, "bookings") + : new Map(), + bookingIds.length + ? this.milestonesRepository.find({ + where: { bookingId: In(bookingIds) }, + select: ["bookingId", "milestoneCode", "status", "triggeredAt"], + }) + : [], + (() => { + const ids = [ + ...new Set( + rows + .map((r) => r.booking?.trainScheduleId) + .filter((id): id is string => Boolean(id)), + ), + ]; + return ids.length + ? this.trainSchedulesRepository.find({ + where: { id: In(ids) }, + select: [ + "id", + "trainNumber", + "voyageNumber", + "actualDepartureAt", + "actualArrivalAt", + ], + }) + : []; + })(), + ]); - const minutes = (from?: Date | null, to?: Date | null) => - from && to ? Math.floor((to.getTime() - from.getTime()) / 60_000) : null; + const scheduleById = new Map(schedules.map((sch) => [sch.id, sch])); + const milestonesByBooking = new Map(); + for (const m of milestones) { + if (!m.bookingId) continue; + const bucket = milestonesByBooking.get(m.bookingId); + if (bucket) bucket.push(m); + else milestonesByBooking.set(m.bookingId, [m]); + } + + const iso = (d?: Date | string | null): string | null => + d ? new Date(d).toISOString() : null; + const minutes = (from?: string | null, to?: string | null): number | null => + from && to + ? Math.floor((new Date(to).getTime() - new Date(from).getTime()) / 60_000) + : null; + /** Latest upload stamp among files matching a code family. */ + const latest = ( + files: FileRecord[], + matches: (code: string | null | undefined) => boolean, + ): { at: string | null; count: number } => { + const hits = files.filter((f) => matches(f.code)); + return { + count: hits.length, + at: hits.reduce((max, f) => { + const stamp = iso(f.createdAt); + return stamp && (!max || stamp > max) ? stamp : max; + }, null), + }; + }; + /** Earliest upload stamp — for append-only sets the FIRST document matters. */ + const earliest = ( + files: FileRecord[], + matches: (code: string | null | undefined) => boolean, + ): { at: string | null; count: number } => { + const hits = files.filter((f) => matches(f.code)); + return { + count: hits.length, + at: hits.reduce((min, f) => { + const stamp = iso(f.createdAt); + return stamp && (!min || stamp < min) ? stamp : min; + }, null), + }; + }; + + const items: TransitStatItem[] = rows.map((row) => { + const booking = row.booking; + const tradeDirection: TransitTradeDirection = + booking?.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT"; + const schedule = booking?.trainScheduleId + ? scheduleById.get(booking.trainScheduleId) + : undefined; + + // Same rule as the clearance view's train state: the booking's own + // load/unload stamps first, the schedule's actuals only as a fallback for + // legacy bookings that predate per-booking loading. + const departedAt = iso(booking?.loadedAt ?? schedule?.actualDepartureAt); + const arrivedAt = iso( + booking?.arrivedAt ?? + (booking?.loadedAt ? null : schedule?.actualArrivalAt), + ); + + const files = bookingDocs.get(row.bookingId) ?? []; + const ms = milestonesByBooking.get(row.bookingId) ?? []; + const milestone = (code: string) => ms.find((m) => m.milestoneCode === code); + const done = (code: string) => { + const m = milestone(code); + return m?.status === "COMPLETED" || m?.status === "SKIPPED"; + }; + + const declared = done("DECLARED"); + const declaredAt = iso(milestone("DECLARED")?.triggeredAt); + const ro = latest(files, isReleaseOrderFileCode); + const deliveryOrder = latest(files, isDeliveryOrderFileCode); + const t1 = latest(files, isT1TransportFileCode); + const gatePass = earliest(files, isGatePassFileCode); + const djiboutiT1 = earliest(files, isDjiboutiT1FileCode); + const t1Closed = milestone("T1_CLOSED")?.status === "COMPLETED"; + const bookingCreatedAt = iso(booking?.createdAt); + const finishedAt = iso(row.finishedAt); + const finished = row.status === TransitAssignmentStatus.Finished; + + const timings: TransitStatItem["timings"] = { + transit: minutes(departedAt, arrivedAt), + declarationToRo: tradeDirection === "EXPORT" ? minutes(declaredAt, ro.at) : null, + bookingToDo: + tradeDirection === "IMPORT" ? minutes(bookingCreatedAt, deliveryOrder.at) : null, + departureToT1: tradeDirection === "IMPORT" ? minutes(departedAt, t1.at) : null, + arrivalToT1: tradeDirection === "IMPORT" ? minutes(arrivedAt, t1.at) : null, + arrivalToGatePass: + tradeDirection === "EXPORT" ? minutes(arrivedAt, gatePass.at) : null, + arrivalToDjiboutiT1: + tradeDirection === "EXPORT" ? minutes(arrivedAt, djiboutiT1.at) : null, + arrivalToFinish: minutes(arrivedAt, finishedAt), + }; + + // What the officer should do next on this shipment — the same gates the + // detail page enforces, so the dashboard never points at a locked button. + let nextAction: TransitNextAction; + if (finished) { + nextAction = { kind: "done", label: "Assignment finished" }; + } else if (tradeDirection === "EXPORT") { + if (!declared) { + nextAction = { kind: "wait", label: "Awaiting customs declaration" }; + } else if (ro.count === 0) { + nextAction = { kind: "upload", label: "Upload Release Order", document: "ro" }; + } else if (!departedAt) { + nextAction = { kind: "wait", label: "Awaiting train departure" }; + } else if (!arrivedAt) { + nextAction = { kind: "wait", label: "Train in transit" }; + } else if (gatePass.count === 0) { + nextAction = { kind: "upload", label: "Upload gate pass", document: "gate_pass" }; + } else if (djiboutiT1.count === 0) { + nextAction = { + kind: "upload", + label: "Upload Djibouti T1", + document: "djibouti_t1", + }; + } else { + nextAction = { kind: "done", label: "Paperwork complete" }; + } + } else if (deliveryOrder.count === 0) { + nextAction = { kind: "upload", label: "Upload Delivery Order", document: "do" }; + } else if (!departedAt) { + nextAction = { kind: "wait", label: "Awaiting train departure" }; + } else if (t1.count === 0 && !t1Closed) { + nextAction = { kind: "upload", label: "Upload T1 documents", document: "t1" }; + } else if (!arrivedAt) { + nextAction = { kind: "wait", label: "Train in transit" }; + } else { + nextAction = { kind: "done", label: t1Closed ? "T1 closed" : "Paperwork complete" }; + } - const items = rows.map((row) => { - const arrivedAt = row.booking?.arrivedAt ?? null; return { id: row.id, - reference: row.booking?.reference ?? null, - customerName: row.booking?.company?.name ?? null, + bookingId: row.bookingId, + reference: booking?.reference ?? null, + customerName: booking?.company?.name ?? null, + tradeDirection, status: row.status, - schedulingStatus: row.booking?.schedulingStatus ?? null, - /** Dispatch (cargo loaded) to the train arriving. */ - transitMinutes: minutes(row.booking?.loadedAt, arrivedAt), - /** Arrival to the agent picking the work up. */ - pickupMinutes: minutes(arrivedAt, row.startedAt), - /** Arrival to the work being finished — the headline metric. */ - clearanceMinutes: minutes(arrivedAt, row.finishedAt), - documentCount: (docCounts.get(row.id) ?? []).length, + schedulingStatus: booking?.schedulingStatus ?? null, + trainLabel: schedule?.voyageNumber ?? schedule?.trainNumber ?? null, + assignedAt: iso(row.assignedAt) ?? new Date(0).toISOString(), + startedAt: iso(row.startedAt), + finishedAt, + bookingCreatedAt, + departedAt, + arrivedAt, + declaredAt, + roAt: ro.at, + doAt: deliveryOrder.at, + t1At: t1.at, + t1Closed, + gatePassAt: gatePass.at, + djiboutiT1At: djiboutiT1.at, + documents: { + ro: ro.count, + do: deliveryOrder.count, + t1: t1.count, + gatePass: gatePass.count, + djiboutiT1: djiboutiT1.count, + own: (ownDocs.get(row.id) ?? []).length, + }, + timings, + nextAction, }; }); - const median = (values: number[]): number | null => { - if (!values.length) return null; - const sorted = [...values].sort((a, b) => a - b); + // Most recently moving shipment first: arrival, else departure, else when + // it was handed to the agent. + const activity = (i: TransitStatItem) => + i.arrivedAt ?? i.departedAt ?? i.assignedAt; + items.sort((a, b) => activity(b).localeCompare(activity(a))); + + const summarize = (values: Array): TransitTimingSummary => { + const measured = values.filter((v): v is number => v !== null && v >= 0); + if (!measured.length) { + return { median: null, fastest: null, slowest: null, measured: 0 }; + } + const sorted = [...measured].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 - ? sorted[mid] - : Math.round((sorted[mid - 1] + sorted[mid]) / 2); + return { + median: + sorted.length % 2 + ? sorted[mid] + : Math.round((sorted[mid - 1] + sorted[mid]) / 2), + fastest: sorted[0], + slowest: sorted[sorted.length - 1], + measured: sorted.length, + }; }; + const timing = (key: keyof TransitStatItem["timings"]) => + summarize(items.map((i) => i.timings[key])); - const cleared = items - .map((i) => i.clearanceMinutes) - .filter((v): v is number => v !== null); - const pickups = items - .map((i) => i.pickupMinutes) - .filter((v): v is number => v !== null); - - // SLA bands, in minutes: inside 2h, inside 6h, beyond. - const sla = { - under2h: cleared.filter((v) => v <= 120).length, - under6h: cleared.filter((v) => v > 120 && v <= 360).length, - over6h: cleared.filter((v) => v > 360).length, - }; - - // Coverage counts only bookings that COULD have documents — uploads are - // gated on dispatch, so counting scheduled ones would invent a failure. - const dispatched = items.filter((i) => i.schedulingStatus === "DISPATCHED"); - const withDocs = dispatched.filter((i) => i.documentCount > 0).length; + const open = items.filter((i) => i.status !== TransitAssignmentStatus.Finished); + const pendingFor = (document: TransitDocumentKind) => + items.filter( + (i) => i.nextAction.kind === "upload" && i.nextAction.document === document, + ).length; + const sumDocs = (key: keyof TransitStatItem["documents"]) => + items.reduce((sum, i) => sum + i.documents[key], 0); return { totals: { assignments: items.length, - open: items.filter((i) => i.status !== TransitAssignmentStatus.Finished) + open: open.length, + notStarted: items.filter((i) => i.status === TransitAssignmentStatus.NotStarted) .length, - finished: items.filter( - (i) => i.status === TransitAssignmentStatus.Finished, - ).length, - readyForDocuments: items.filter( - (i) => - i.schedulingStatus === "DISPATCHED" && - i.status !== TransitAssignmentStatus.Finished, - ).length, - documents: items.reduce((sum, i) => sum + i.documentCount, 0), + inProgress: items.filter((i) => i.status === TransitAssignmentStatus.InProgress) + .length, + finished: items.length - open.length, + imports: items.filter((i) => i.tradeDirection === "IMPORT").length, + exports: items.filter((i) => i.tradeDirection === "EXPORT").length, + awaitingDeparture: open.filter((i) => !i.departedAt).length, + inTransit: open.filter((i) => i.departedAt && !i.arrivedAt).length, + arrived: open.filter((i) => Boolean(i.arrivedAt)).length, + actionNeeded: items.filter((i) => i.nextAction.kind === "upload").length, }, - performance: { - medianClearanceMinutes: median(cleared), - medianPickupMinutes: median(pickups), - fastestClearanceMinutes: cleared.length ? Math.min(...cleared) : null, - slowestClearanceMinutes: cleared.length ? Math.max(...cleared) : null, - onTimeRate: cleared.length - ? Math.round(((sla.under2h + sla.under6h) / cleared.length) * 100) - : null, - measured: cleared.length, + timings: { + transit: timing("transit"), + declarationToRo: timing("declarationToRo"), + bookingToDo: timing("bookingToDo"), + departureToT1: timing("departureToT1"), + arrivalToT1: timing("arrivalToT1"), + arrivalToGatePass: timing("arrivalToGatePass"), + arrivalToDjiboutiT1: timing("arrivalToDjiboutiT1"), + arrivalToFinish: timing("arrivalToFinish"), }, - sla, - coverage: { - dispatched: dispatched.length, - withDocuments: withDocs, + documents: { + ro: sumDocs("ro"), + do: sumDocs("do"), + t1: sumDocs("t1"), + gatePass: sumDocs("gatePass"), + djiboutiT1: sumDocs("djiboutiT1"), + own: sumDocs("own"), }, - /** Newest first, for the timeline and the recent-activity list. */ - items: items.slice(0, 12), + pending: { + ro: pendingFor("ro"), + do: pendingFor("do"), + t1: pendingFor("t1"), + gatePass: pendingFor("gate_pass"), + djiboutiT1: pendingFor("djibouti_t1"), + }, + items: items.slice(0, 20), }; } @@ -393,6 +690,50 @@ export class TransitAssignmentsService { return this.findMineById(userId, id); } + /** + * Make `transitAgentId` the officer working `bookingId`, as the clearance + * desk's "assign transit assignee" step means it. + * + * The booking itself only records the officer's NAME, which is all the + * clearance UI needs; the officer's own portal reads `transit_assignments`. + * This keeps the two in step, and is deliberately forgiving where `create()` + * is strict: + * - assigning the same agent twice is a no-op, not a 409 — the desk may + * re-save the step without meaning to start over; + * - a REASSIGNMENT retires the previous officer's row, so a shipment does + * not sit in the work list of someone who no longer handles it. Finished + * rows stay, since they are that officer's record of work already done. + */ + async ensureAssignment( + bookingId: string, + transitAgentId: string, + assignedByUserId?: string, + ): Promise { + const existing = + await this.assignmentsRepository.findByBooking(bookingId); + + for (const row of existing) { + if ( + row.transitAgentId !== transitAgentId && + row.status !== TransitAssignmentStatus.Finished + ) { + await this.assignmentsRepository.softDelete(row.id); + } + } + + if (existing.some((row) => row.transitAgentId === transitAgentId)) return; + + await this.assignmentsRepository.create({ + bookingId, + transitAgentId, + status: TransitAssignmentStatus.NotStarted, + startedAt: null, + finishedAt: null, + assignedByUserId: assignedByUserId ?? null, + note: null, + }); + } + async create( dto: CreateTransitAssignmentDto, assignedByUserId?: string, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx index 852a89cc1..4845d3798 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlExchangePanel.tsx @@ -42,6 +42,9 @@ const SIDES: Record ({ et: documents.filter((d) => d.side === "ET").length, dj: documents.filter((d) => d.side === "DJ").length, + transit: documents.filter((d) => d.side === "TRANSIT").length, shared: documents.filter((d) => d.visibleToCustomer).length, }), [documents], @@ -141,6 +145,11 @@ export function GlExchangePanel({ entityId }: GlExchangePanelProps) { {stats.dj} from GL Djibouti + {stats.transit > 0 ? ( + + {stats.transit} from the transit agent + + ) : null} {stats.shared} visible to customer diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 07ca7f735..313645db3 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -782,7 +782,7 @@ export function PhasedClearanceActionPanel({ diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 22266212e..62fecd600 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -662,6 +662,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ cardTitleKey: "name", columns: [ { id: "name", header: "Name", accessorKey: "name" }, + { id: "email", header: "Email", accessorKey: "email" }, + { id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber" }, { id: "validFrom", header: "Valid from", accessorKey: "validFrom", format: "date" }, { id: "validTo", header: "Valid to", accessorKey: "validTo", format: "date" }, { diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index f224b3959..5391fa150 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -71,6 +71,7 @@ import { } from "./pages/shipping-line"; import { TransitAgentBookingsPage, + TransitAgentBookingDetailPage, TransitAgentOverviewPage, } from "./pages/transit-agent"; import FaqPage from "./pages/support/FaqPage"; @@ -545,6 +546,10 @@ const App = () => { path="/transit-agent/bookings" element={} /> + } + /> )} diff --git a/apps/edr-freight-web/portal/src/components/contracts/PortalMultiFileDropzone.tsx b/apps/edr-freight-web/portal/src/components/contracts/PortalMultiFileDropzone.tsx new file mode 100644 index 000000000..060b85e75 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/contracts/PortalMultiFileDropzone.tsx @@ -0,0 +1,248 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + ActionIcon, + Box, + Group, + Stack, + Text, + ThemeIcon, + UnstyledButton, +} from "@mantine/core"; +import { FileText, Plus, Trash2, UploadCloud } from "lucide-react"; + +import { BORDER, GREEN, INK } from "@/pages/contracts/contract-ui"; + +export interface PortalMultiFileDropzoneProps { + label: string; + description?: string; + files: File[]; + onChange: (next: File[]) => void; + accept?: string; + disabled?: boolean; + /** Hint under the drop area, e.g. "PDF or image". */ + acceptHint?: string; +} + +export function formatBytes(bytes: number | null | undefined): 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]}`; +} + +function isImageFile(file: File): boolean { + if (file.type.startsWith("image/")) return true; + const ext = file.name.split(".").pop()?.toLowerCase() ?? ""; + return ["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg"].includes(ext); +} + +/** Stable identity for a staged file — two picks of the same file dedupe. */ +const fileKey = (f: File) => `${f.name}:${f.size}:${f.lastModified}`; + +/** + * Multi-file counterpart of `PortalFileDropzone`: the same drop area, but the + * picker keeps a list. Picking again APPENDS (deduplicated by name+size+mtime), + * so an officer can gather documents across several picks before uploading. + */ +export function PortalMultiFileDropzone({ + label, + description, + files, + onChange, + accept = "application/pdf,image/*", + disabled = false, + acceptHint = "PDF or image", +}: PortalMultiFileDropzoneProps) { + const inputRef = useRef(null); + const [dragOver, setDragOver] = useState(false); + + // One object URL per staged image, revoked when the list changes. + const thumbs = useMemo(() => { + const map = new Map(); + for (const f of files) { + if (isImageFile(f)) map.set(fileKey(f), URL.createObjectURL(f)); + } + return map; + }, [files]); + + useEffect(() => { + return () => { + for (const url of thumbs.values()) URL.revokeObjectURL(url); + }; + }, [thumbs]); + + const add = (incoming: File[]) => { + if (disabled || incoming.length === 0) return; + const seen = new Set(files.map(fileKey)); + const next = [...files]; + for (const f of incoming) { + const key = fileKey(f); + if (seen.has(key)) continue; + seen.add(key); + next.push(f); + } + onChange(next); + }; + + const remove = (target: File) => + onChange(files.filter((f) => fileKey(f) !== fileKey(target))); + + const openPicker = () => { + if (disabled) return; + // Reset so re-picking the same file after removal still fires onChange. + if (inputRef.current) inputRef.current.value = ""; + inputRef.current?.click(); + }; + + const hasFiles = files.length > 0; + + return ( + + + + {label} + + {description ? ( + + {description} + + ) : null} + + + add(Array.from(e.target.files ?? []))} + /> + + { + e.preventDefault(); + if (!disabled) setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setDragOver(false); + add(Array.from(e.dataTransfer.files ?? [])); + }} + onClick={openPicker} + style={{ + borderRadius: 14, + border: `2px dashed ${dragOver ? GREEN : BORDER}`, + background: dragOver ? "#F2FBF6" : "#FAFCFE", + padding: hasFiles ? "16px 20px" : "28px 20px", + textAlign: "center", + cursor: disabled ? "not-allowed" : "pointer", + opacity: disabled ? 0.6 : 1, + transition: "border-color 120ms ease, background 120ms ease, padding 120ms ease", + }} + > + + + {hasFiles ? : } + + + + {dragOver + ? "Drop to add" + : hasFiles + ? "Add more files" + : "Drag & drop your files here"} + + + or browse —{" "} + {acceptHint}. Add as many as you need. + + + + + + {hasFiles ? ( + + + + {files.length} file{files.length === 1 ? "" : "s"} ready to upload + + onChange([])} + disabled={disabled} + style={{ fontSize: 11.5, color: "#6B7C8E", fontWeight: 600 }} + > + Clear all + + + {files.map((f) => { + const key = fileKey(f); + const thumb = thumbs.get(key); + return ( + + {thumb ? ( + + + + ) : ( + + + + )} + + + {f.name} + + + {formatBytes(f.size)} + + + remove(f)} + disabled={disabled} + > + + + + ); + })} + + ) : null} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/transit-agent/ClearanceWorkflowFilesPanel.tsx b/apps/edr-freight-web/portal/src/pages/transit-agent/ClearanceWorkflowFilesPanel.tsx new file mode 100644 index 000000000..a0e34a6ad --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/transit-agent/ClearanceWorkflowFilesPanel.tsx @@ -0,0 +1,166 @@ +import { + Badge, + Box, + Button, + Card, + Group, + Paper, + Stack, + Text, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { Download, Eye, FileText } from "lucide-react"; +import type { Freight } from "@edr/types"; +import { isViewable } from "@edr/ui-common"; + +import { fetchViewableFile } from "@/services/files.service"; + +const CATEGORY_LABELS: Record< + Freight.ClearanceWorkflowFileCategory, + string +> = { + declaration: "Declaration", + draft_declaration: "Draft declaration", + duty: "Duty & taxes", + transit: "Transit", + djibouti: "Djibouti", +}; + +const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [ + "draft_declaration", + "declaration", + "duty", + "transit", + "djibouti", +]; + +const OWNER_LABELS: Record = { + customer: "Customer", + gl_et: "GL Ethiopia", + gl_dj: "GL Djibouti", +}; + +export interface ClearanceWorkflowFilesPanelProps { + files: Freight.ClearanceWorkflowFile[]; + onView: (file: { name: string; url: string }) => void; + onDownload?: (file: { id: string; name: string }) => void; + title?: string; +} + +export function ClearanceWorkflowFilesPanel({ + files, + onView, + onDownload, + title = "Customs workflow documents", +}: ClearanceWorkflowFilesPanelProps) { + if (files.length === 0) return null; + + const grouped = CATEGORY_ORDER.map((category) => ({ + category, + label: CATEGORY_LABELS[category], + items: files.filter((f) => f.category === category), + })).filter((g) => g.items.length > 0); + + return ( + + + + + + + {title} + + + + {grouped.map((group) => ( + + + {group.label} + + + {group.items.map((item) => ( + + ))} + + + ))} + + + ); +} + +function WorkflowFileRow({ + item, + onView, + onDownload, +}: { + item: Freight.ClearanceWorkflowFile; + onView: (file: { name: string; url: string }) => void; + onDownload?: (file: { id: string; name: string }) => void; +}) { + const file = item.file; + if (!file) return null; + + const canPreview = isViewable({ name: file.name, url: "" }); + + return ( + + + + + + + + + {item.label} + + + + {OWNER_LABELS[item.uploadedBy]} + + + {file.name} + + + + + + {canPreview ? ( + + + + ) : null} + {onDownload ? ( + + + + ) : null} + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingDetailPage.tsx new file mode 100644 index 000000000..0808814cc --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingDetailPage.tsx @@ -0,0 +1,1188 @@ +import { + Alert, + Anchor, + Badge, + Box, + Button, + Card, + Checkbox, + Grid, + Group, + Loader, + Modal, + Paper, + Stack, + Tabs, + Text, + TextInput, + ThemeIcon, + Timeline, + Title, + Tooltip, +} from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + AlertCircle, + AlertTriangle, + ArrowLeft, + Building2, + CheckCircle2, + CircleDot, + ClipboardList, + Download, + Eye, + EyeOff, + FileText, + History, + MessageSquareWarning, + Send, + Ship, + Receipt, + Share2, + Train, + Upload, + UserCheck, +} from "lucide-react"; +import { useNavigate, useParams } from "react-router-dom"; +import type { Freight } from "@edr/types"; + +import { isViewable } from "@edr/ui-common"; + +import { useFileViewer } from "@/hooks/useFileViewer"; +import { + downloadStoredFile, + fetchViewableFile, +} from "@/services/files.service"; +import { ClearanceWorkflowFilesPanel } from "@/pages/transit-agent/ClearanceWorkflowFilesPanel"; +import { TransitClearanceActionPanel } from "@/pages/transit-agent/TransitClearanceActionPanel"; +import { + TransitExportDocumentsPanel, + TransitImportDocumentsPanel, +} from "@/pages/transit-agent/TransitDocumentsPanel"; +import { transitAssignmentsService } from "@/services/transit-assignments.service"; +import { bookingsService } from "@/services/bookings.service"; +import { useState } from "react"; +import toast from "react-hot-toast"; + +const BACK_TO = "/transit-agent/bookings"; + +const prettyStatus = (s?: string | null) => + (s ?? "") + .toLowerCase() + .replace(/_/g, " ") + .replace(/^\w/, (c) => c.toUpperCase()); + +const formatDate = (value?: string | null): string => + value ? new Date(value).toLocaleString() : "—"; + +/** Minutes as "5h 30m" — the raw integer is unreadable in a grid. */ +function formatMinutes(minutes: number | null | undefined): string { + if (minutes == null) return "—"; + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + return hours ? `${hours}h ${rest}m` : `${rest}m`; +} + +const STATUS_COLOR: Record = { + NOT_STARTED: "gray", + IN_PROGRESS: "blue", + FINISHED: "edr-green", +}; + +/** 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" }, +}; + +const SIDES: Record< + Freight.GlExchangeDocument["side"], + { label: string; color: string } +> = { + ET: { label: "GL Ethiopia", color: "edr-green" }, + DJ: { label: "GL Djibouti", color: "blue" }, + TRANSIT: { label: "Transit agent", color: "grape" }, +}; + +function formatBytes(bytes: number): string { + if (!bytes) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${parseFloat((bytes / 1024 ** i).toFixed(1))} ${units[i]}`; +} + +const formatDateTime = (value?: string | null): string => + value + ? new Date(value).toLocaleString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }) + : "—"; + +/** Review state → badge, matching the GL review section's scale. */ +const REVIEW_META: Record = { + APPROVED: { label: "Approved", color: "edr-green" }, + QUERIED: { label: "Queried", color: "red" }, + PENDING: { label: "Pending review", color: "yellow" }, +}; + +/** + * The customer's clearance documents — what the shipment was cleared on. + * + * Read-only here: approving and querying are GL Ethiopia's, and the agent's + * token would be refused. Every attached file is viewable and downloadable, + * which is the point of the panel for the officer handling the shipment. + */ +function CustomerDocumentsCard({ + documents, + loading, + onView, +}: { + documents: Freight.ClearanceDocument[]; + loading: boolean; + onView: (file: { name: string; url: string }) => void; +}) { + const attached = documents.filter((d) => d.file); + + return ( + + + + + + + + Customer documents + + + {attached.length > 0 ? ( + + {attached.length} + + ) : null} + + + {loading ? ( + + + + ) : documents.length === 0 ? ( + + No customer documents on this shipment. + + ) : ( + + {documents.map((doc) => { + const meta = doc.reviewStatus + ? REVIEW_META[doc.reviewStatus] + : null; + return ( + + + + + + + + + + {doc.label} + + {doc.required ? ( + + Required + + ) : null} + {meta ? ( + + {meta.label} + + ) : null} + + {doc.file ? ( + + {doc.file.name} + {doc.uploadedAt ? ` · ${formatDateTime(doc.uploadedAt)}` : ""} + + ) : ( + + Not uploaded yet + + )} + {/* GL's query reason — the officer needs to know why a + document was sent back, not just that it was. */} + {doc.reviewStatus === "QUERIED" && doc.note ? ( + + {doc.note} + + ) : null} + + + + {doc.file ? ( + + {isViewable({ name: doc.file.name, url: "" }) ? ( + + + + ) : null} + + + + + ) : null} + + + ); + })} + + )} + + ); +} + +/** + * Share a document into the GL Ethiopia ↔ GL Djibouti exchange. + * + * The API stamps the post as the TRANSIT side from the session, so the agent + * cannot post as either desk. `visibleToCustomer` additionally surfaces the + * document in the customer's own portal — off by default, since most transit + * paperwork is between the desks. + */ +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 with both GL desks"); + onShared(); + close(); + }, + onError: (e: unknown) => + toast.error(e instanceof Error ? e.message : "Could not share document"), + }); + + return ( + + + Share a document + + } + > + + + Both Global Logistics desks 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)} + /> + + + + + + + + ); +} + +/** Empty-state block, matching the GL detail page's dashed panel. */ +function EmptyPanel({ icon: Icon, children }: { icon: typeof FileText; children: React.ReactNode }) { + return ( + + + + + + + {children} + + + + ); +} + +/** Bordered section with an icon header — the portal's SectionCard equivalent. */ +function SectionCard({ + icon: Icon, + title, + children, +}: { + icon: typeof FileText; + title: string; + children: React.ReactNode; +}) { + return ( + + + + + + + {title} + + + {children} + + ); +} + +function FieldRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( + + + {label} + + {value} + + ); +} + +/** + * A transit agent's view of one assigned shipment, mirroring the backoffice's + * GL Djibouti clearance detail page tab for tab. + * + * Every tab here is READ-ONLY except the agent's own documents and progress. + * The clearance reads are authorized server-side by the assignment itself (see + * `assertTransitAgentScope` in the API) — this page never re-derives that rule, + * so a tab cannot show something the API would refuse. + */ +export default function TransitAgentBookingDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { view, viewer } = useFileViewer(); + const [shareOpen, setShareOpen] = useState(false); + + const assignmentQuery = useQuery({ + queryKey: ["transit-assignment", id], + queryFn: () => transitAssignmentsService.getById(id!), + enabled: Boolean(id), + }); + + const assignment = assignmentQuery.data; + const bookingId = assignment?.bookingId; + + // The clearance grid drives both the workflow and documents tabs. + const clearanceQuery = useQuery({ + queryKey: ["transit-clearance", bookingId], + queryFn: () => bookingsService.getClearance(bookingId!), + enabled: Boolean(bookingId), + }); + + const historyQuery = useQuery({ + queryKey: ["transit-clearance-history", bookingId], + queryFn: () => transitAssignmentsService.clearanceHistory(bookingId!), + enabled: Boolean(bookingId), + }); + + const exchangeQuery = useQuery({ + queryKey: ["transit-gl-exchange", bookingId], + queryFn: () => transitAssignmentsService.glExchange(bookingId!), + enabled: Boolean(bookingId), + }); + + const incidentsQuery = useQuery({ + queryKey: ["transit-incidents", bookingId], + queryFn: () => transitAssignmentsService.incidents(bookingId!), + enabled: Boolean(bookingId), + }); + + if (assignmentQuery.isPending) { + return ( + + + + Loading shipment… + + + ); + } + + if (assignmentQuery.isError || !assignment) { + return ( + + }> + Could not load this shipment. It may no longer be assigned to you. + + + ); + } + + const booking = assignment.booking; + const clearance = clearanceQuery.data; + const workflowFiles = clearance?.workflowFiles ?? []; + const workflowFileCount = workflowFiles.filter((f) => f.file).length; + const docCount = assignment.files?.length ?? 0; + const isImport = booking?.tradeDirection === "IMPORT"; + + // What the customer supplied, as the GL review section scopes it — the GL + // side of the grid is staff paperwork the officer does not action. + const customerDocuments = (clearance?.documents ?? []).filter( + (d) => d.uploadedBy === "customer", + ); + const exchangeDocs = exchangeQuery.data ?? []; + const exchangeStats = { + et: exchangeDocs.filter((d) => d.side === "ET").length, + dj: exchangeDocs.filter((d) => d.side === "DJ").length, + transit: exchangeDocs.filter((d) => d.side === "TRANSIT").length, + shared: exchangeDocs.filter((d) => d.visibleToCustomer).length, + }; + const historyEvents = historyQuery.data ?? []; + + return ( + + + {/* ── Header ───────────────────────────────────────────────── */} + + + navigate(BACK_TO)} + style={{ cursor: "pointer" }} + > + My bookings + + + / + + + {booking?.reference ?? "—"} + + + + + + +
+ + + {booking?.reference ?? "Shipment"} + + {booking?.tradeDirection ? ( + + {prettyStatus(booking.tradeDirection)} + + ) : null} + + {prettyStatus(assignment.status)} + + + + + + {assignment.customerName ?? "—"} + + +
+
+ +
+
+ + + + }> + Clearance workflow + + } + rightSection={ + workflowFileCount > 0 ? ( + + {workflowFileCount} + + ) : undefined + } + > + Customs documents (all steps) + + }> + Document exchange + + }> + History + + }> + Incidents + + + + {/* ── Clearance workflow ─────────────────────────────────── */} + + {/* The officer's own uploads with their timings, ahead of the + read-only grid: RO / gate pass / Djibouti T1 for exports, + DO / T1 for imports. */} + {clearance && bookingId && booking?.tradeDirection ? ( + + {booking.tradeDirection === "IMPORT" ? ( + void assignmentQuery.refetch()} + /> + ) : ( + void assignmentQuery.refetch()} + /> + )} + + ) : null} + + + + {clearanceQuery.isPending ? ( + + + + ) : clearanceQuery.isError ? ( + }> + Clearance details are not available for this shipment. + + ) : ( + + + {prettyStatus(clearance?.phase) || "—"} + + } + /> + + {prettyStatus(booking?.status) || "—"} + + } + /> + + {prettyStatus(booking?.schedulingStatus) || "—"} + + } + /> + + {formatDate(clearance?.vesselArrivalDate)} + + } + /> + + {formatDate(clearance?.doCollectedDate)} + + } + /> + + )} + + + + + + + + + + + + + {prettyStatus(assignment.status)} + + } + /> + {formatDate(assignment.assignedAt)}} + /> + {formatDate(assignment.startedAt)}} + /> + {formatDate(assignment.finishedAt)}} + /> + + {formatMinutes(assignment.timeAfterTrainArrives)} + + } + /> + {docCount || "—"}} + /> + {assignment.note ? ( + {assignment.note}} + /> + ) : null} + + + + void assignmentQuery.refetch()} + /> + + + {assignment.customerName ?? "—"} + + + + + + + {/* ── Customs documents (all steps) ──────────────────────── */} + {/* ── Customs documents (all steps) ──────────────────────── */} + + {clearanceQuery.isPending ? ( + + + Loading documents… + + ) : workflowFileCount > 0 ? ( + void downloadStoredFile(f.id, f.name)} + /> + ) : ( + + No customs workflow documents uploaded yet. Files from + Ethiopia-side clearance and the Djibouti desk's DO/RO uploads + will appear here. + + )} + + + {/* ── Document exchange ──────────────────────────────────── */} + + + + + + + + + + + Document exchange + + + Documents shared with both Global Logistics desks for + this shipment. Anything you share here is visible to GL + Ethiopia and GL Djibouti immediately. + + + + + + + {exchangeDocs.length > 0 ? ( + + + {exchangeStats.et} from GL Ethiopia + + + {exchangeStats.dj} from GL Djibouti + + + {exchangeStats.transit} from you + + + {exchangeStats.shared} visible to customer + + + ) : null} + + + {exchangeQuery.isPending ? ( + + + + Loading shared documents… + + + ) : exchangeQuery.isError ? ( + + Could not load the shared documents. + + ) : exchangeDocs.length === 0 ? ( + + Nothing shared yet. Anything either desk uploads — scans, + correspondence, corrected forms — appears here. + + ) : ( + + {exchangeDocs.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" + : "GL only"} + + + + {doc.file.name} · {formatBytes(doc.file.size)} ·{" "} + {doc.uploadedByName ?? "Global Logistics"} ·{" "} + {formatDateTime(doc.uploadedAt)} + + + + + + {canPreview ? ( + + + + ) : null} + + + + + + + ); + })} + + )} + + + + + {/* ── History ────────────────────────────────────────────── */} + + {historyQuery.isPending ? ( + + + Loading history… + + ) : historyQuery.isError ? ( + }> + History is not available for this shipment. + + ) : historyEvents.length === 0 ? ( + + + No clearance actions recorded yet. Actions from now on — + approvals, queries, workflow steps, charges — appear here + automatically. + + + ) : ( + + + {historyEvents.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} + + ); + })} + + + )} + + + {/* ── Incidents ──────────────────────────────────────────── */} + + + + + Container or seal issues discovered during clearance handling. + Logged by the Djibouti desk — read-only here. + + {incidentsQuery.isPending ? ( + + + + ) : incidentsQuery.isError ? ( + }> + Incident reports are not available for this shipment. + + ) : (incidentsQuery.data ?? []).length > 0 ? ( + + {(incidentsQuery.data ?? []).map( + (inc: Freight.IClearanceIncident) => ( + + + + + + + + {prettyStatus(inc.incidentType)} + + {inc.description ? ( + + {inc.description} + + ) : null} + + {formatDate(inc.createdAt)} + + + + + ), + )} + + ) : ( + + + + No incidents reported for this shipment. + + + )} + + + + +
+ + setShareOpen(false)} + onShared={() => void exchangeQuery.refetch()} + /> + + {viewer} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingsPage.tsx b/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingsPage.tsx index 4e86a2de0..fba9f354e 100644 --- a/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingsPage.tsx @@ -1,76 +1,65 @@ import { - Alert, + ActionIcon, + Badge, Box, Button, Card, - Center, Group, - Loader, - Pagination, Select, - SimpleGrid, + Skeleton, Stack, - Table, Text, TextInput, + ThemeIcon, Title, - Tooltip, + UnstyledButton, } from "@mantine/core"; -import { useDebouncedValue } from "@mantine/hooks"; +import { useDebouncedValue, useInterval } from "@mantine/hooks"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { - AlertCircle, + DataTable, + usePagination, + type ColumnDef, + type DataTableFooterProps, +} from "@edr/ui-common"; +import type { LucideIcon } from "lucide-react"; +import { + ArrowUpRight, + Building2, CheckCircle2, + ChevronRight, Clock3, - FileText, - Lock, + Inbox, + Layers, + PackageCheck, Paperclip, + RefreshCw, Search, + ShipWheel, Train, + Truck, X, } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Pagination } from "@mantine/core"; -import { cv } from "@/pages/MyPortalPage/constants"; -import TransitAgentDocumentsModal from "@/pages/transit-agent/TransitAgentDocumentsModal"; import { transitAssignmentsService, type TransitAssignment, type TransitAssignmentStatus, } from "@/services/transit-assignments.service"; +import "./transit-bookings-table.css"; -const PAGE_SIZE = 8; +/** Matches the GL Djibouti queue's header cell treatment. */ +const headerCell = + "whitespace-nowrap text-[10px] font-semibold uppercase tracking-[0.08em] text-edr-muted"; -const PAGE_SIZE_OPTIONS = [ - { value: "8", label: "8 / page" }, - { value: "20", label: "20 / page" }, - { value: "50", label: "50 / page" }, -]; - -/** Status pills use the portal's soft-tint / strong-ink pairs, not raw Mantine colours. */ -const STATUS_META: Record< - TransitAssignmentStatus, - { label: string; bg: string; fg: string } -> = { - NOT_STARTED: { - label: "Not started", - bg: "edr-slate-soft", - fg: "edr-slate", - }, - IN_PROGRESS: { label: "In progress", bg: "edr-blue-soft", fg: "edr-blue" }, - FINISHED: { label: "Finished", bg: "edr-soft", fg: "edr-green.7" }, -}; - -const STATUS_OPTIONS = [ - { value: "NOT_STARTED", label: "Not started" }, - { value: "IN_PROGRESS", label: "In progress" }, - { value: "FINISHED", label: "Finished" }, -]; - -const SHIPMENT_OPTIONS = [ - { value: "DISPATCHED", label: "Dispatched (in transit)" }, - { value: "SCHEDULED", label: "Scheduled" }, -]; +const prettyStatus = (s?: string | null) => + (s ?? "") + .toLowerCase() + .replace(/_/g, " ") + .replace(/^\w/, (c) => c.toUpperCase()); /** Minutes as "5h 30m" — the raw integer is unreadable in a grid. */ function formatMinutes(minutes: number | null): string { @@ -80,375 +69,723 @@ function formatMinutes(minutes: number | null): string { return hours ? `${hours}h ${rest}m` : `${rest}m`; } -/** Sentence-cases a SCREAMING_SNAKE enum for display. */ -function humanize(value?: string | null): string { - if (!value) return "—"; - const spaced = value.toLowerCase().replace(/_/g, " "); - return spaced.charAt(0).toUpperCase() + spaced.slice(1); +const STATUS_META: Record< + TransitAssignmentStatus, + { label: string; color: string } +> = { + NOT_STARTED: { label: "Not started", color: "gray" }, + IN_PROGRESS: { label: "In progress", color: "blue" }, + FINISHED: { label: "Finished", color: "edr-green" }, +}; + +/** Booking scheduling state → badge tint, mirroring the GL queue's scale. */ +function shipmentColor(status?: string | null): string { + switch (status) { + case "DISPATCHED": + return "teal"; + case "SCHEDULED": + return "blue"; + case "MANUAL_ONLY": + return "orange"; + default: + return "gray"; + } } -function SummaryTile({ - icon, - label, - value, - soft, -}: { - icon: React.ReactNode; - label: string; - value: number | string; - soft: string; -}) { +const SHIPMENT_OPTIONS = [ + { value: "DISPATCHED", label: "Dispatched (in transit)" }, + { value: "SCHEDULED", label: "Scheduled" }, +]; + +// ── Tabs ───────────────────────────────────────────────────────────────────── + +type TabKey = "all" | "NOT_STARTED" | "IN_PROGRESS" | "FINISHED"; + +const TABS: { key: TabKey; label: string; icon: LucideIcon }[] = [ + { key: "all", label: "All", icon: Layers }, + { key: "NOT_STARTED", label: "Not started", icon: Clock3 }, + { key: "IN_PROGRESS", label: "In progress", icon: Train }, + { key: "FINISHED", label: "Finished", icon: CheckCircle2 }, +]; + +// ── Shell pieces ───────────────────────────────────────────────────────────── +// Ported from the backoffice's `components/page/*`, which the portal does not +// have. Kept local rather than promoted to @edr/ui-common: one consumer. + +function LivePill({ updatedAt }: { updatedAt: number }) { + // Re-render every 30s so "Xm ago" keeps ticking between refetches. + const [, setTick] = useState(0); + useInterval(() => setTick((t) => t + 1), 30_000, { autoInvoke: true }); + const mins = Math.max(0, Math.round((Date.now() - updatedAt) / 60_000)); + const label = !updatedAt + ? "Connecting…" + : mins < 1 + ? "Live · updated just now" + : `Live · updated ${mins}m ago`; return ( - - - - {icon} - - - - {value} - - - {label} - - - - + + + {label} + ); } +function Spark({ values, color }: { values: number[]; color: string }) { + const max = Math.max(1, ...values); + return ( +
+ {values.map((v, i) => ( +
+ ))} +
+ ); +} + +interface KpiItem { + label: string; + value: number | string; + icon: LucideIcon; + color: string; + spark?: number[]; + hint?: string; +} + +/** One bordered card split into cells, matching the GL queue's KPI strip. */ +function KpiStrip({ + items, + loading = false, +}: { + items: KpiItem[]; + loading?: boolean; +}) { + return ( + +
+ {items.slice(0, 5).map((item, index) => { + const Icon = item.icon; + return ( +
0 + ? "border-t border-edr-border sm:border-l sm:border-t-0" + : "", + ].join(" ")} + > +
+
+ +
+ + {item.label} + +
+ +
+
+ {loading ? ( + + ) : ( + + {item.value} + + )} + {!loading && item.hint ? ( + + + {item.hint} + + ) : null} +
+ {item.spark?.length ? ( + + ) : null} +
+
+ ); + })} +
+
+ ); +} + +/** DataTable footer: row range left, rows-per-page + pager right. */ +function TablePager({ + table, + pagination, + noun = "rows", + pageSizes = [10, 25, 50], +}: DataTableFooterProps & { noun?: string; pageSizes?: number[] }) { + const pageIndex = pagination.pageIndex ?? 0; + const pageSize = pagination.pageSize ?? 10; + const total = pagination.totalCount ?? 0; + const pageCount = Math.max( + 1, + pagination.pageCount ?? Math.ceil(total / pageSize), + ); + const start = total === 0 ? 0 : pageIndex * pageSize + 1; + const end = Math.min((pageIndex + 1) * pageSize, total); + + return ( + + + Showing {start}–{end} of {total} {noun} + + + + + Rows + + - setPageSize(Number(v) || PAGE_SIZE)} - allowDeselect={false} - /> + + {TABS.map((t) => { + const isActive = tab === t.key; + const Icon = t.icon; + return ( + { + setTab(t.key); + resetPage(); + }} + px={13} + className="flex items-center gap-2 transition-colors" + style={{ + borderBottom: `2px solid ${ + isActive + ? "var(--mantine-color-edr-green-6)" + : "transparent" + }`, + marginBottom: -1, + }} + aria-pressed={isActive} + > + + + {t.label} + + + {tabCounts[t.key]} + + + ); + })} - {/* Rendered even on a single page: the control disappearing as - the result set shrinks reads as a broken table rather than as - "there is only one page". */} - + + {total} record{total !== 1 ? "s" : ""} + - ) : null} - - )} - setActive(null)} - /> - + {/* ── Filter bar ───────────────────────────────────────── */} + + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + rightSection={ + query ? ( + setQuery("")} + aria-label="Clear search" + > + + + ) : null + } + radius="md" + size="sm" + styles={{ + input: { background: "var(--mantine-color-gray-0)" }, + }} + style={{ flex: 1, minWidth: 220 }} + /> +