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 9655a9603..37216f9f1 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, @@ -783,6 +784,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, @@ -1128,6 +1187,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, @@ -1136,7 +1198,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); } @@ -1278,8 +1344,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, ]) @@ -1287,7 +1356,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); } @@ -1310,6 +1383,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, @@ -1777,8 +1856,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( @@ -1788,6 +1869,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 ?? [], @@ -1798,7 +1880,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( @@ -1807,6 +1889,7 @@ export class BookingsController { @Body("vesselDepartureDate") vesselDepartureDate: string, @CurrentUser() user: TCurrentUser, ) { + await this.assertPortalClearanceAccess(id, user); const result = await this.bookingClearanceService.uploadReleaseOrder( id, files ?? [], @@ -1821,12 +1904,13 @@ export class BookingsController { } @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 b6047633a..e9bd01a86 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1982,6 +1982,65 @@ export class BookingsService { } } + /** + * 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..f21f2218b 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 @@ -30,6 +30,7 @@ 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'; @@ -176,6 +177,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, @@ -593,6 +595,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', 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..e7b3d42f6 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, ) {} @@ -1230,6 +1232,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..9c91c629e 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,18 +1360,30 @@ 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, ) { + 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 ?? []); } @@ -1534,6 +1547,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/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/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.service.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts index 389e08fac..0ce73e28e 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 @@ -244,6 +244,14 @@ export class TransitAssignmentsService { assignments: items.length, open: items.filter((i) => i.status !== TransitAssignmentStatus.Finished) .length, + // The open half split by status, so the roster's tab counts do not have + // to be derived from a single paginated page. + notStarted: items.filter( + (i) => i.status === TransitAssignmentStatus.NotStarted, + ).length, + inProgress: items.filter( + (i) => i.status === TransitAssignmentStatus.InProgress, + ).length, finished: items.filter( (i) => i.status === TransitAssignmentStatus.Finished, ).length, @@ -393,6 +401,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/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/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..acadd3625 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingDetailPage.tsx @@ -0,0 +1,1184 @@ +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 TransitAgentDocumentsModal from "@/pages/transit-agent/TransitAgentDocumentsModal"; +import { + transitAssignmentsService, + type TransitAssignment, +} 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 [docsOpen, setDocsOpen] = useState(false); + 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 locked = !assignment.canUploadDocuments; + 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 ─────────────────────────────────── */} + + + + + {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()} + /> + + setDocsOpen(false)} + /> + {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..a13f2d4aa 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,68 @@ 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, + Inbox, + Layers, Lock, + 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 +72,746 @@ 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} - /> - - {/* 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". */} - + {locked ? "View" : "Documents"} + + ); + }, + }, + { + id: "chevron", + size: 40, + header: "", + cell: () => ( + + + + ), + }, + ], + [], + ); + + return ( + + + {/* ── Header ───────────────────────────────────────────────── */} + +
+ + + Bookings + + - ) : null} + + Shipments assigned to you for transit. Documents can be uploaded + once a booking is dispatched, and are locked when you finish it. + +
+ +
+ + + + + + {/* ── Tabs ─────────────────────────────────────────────── */} + + + {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]} + + + ); + })} + + + {total} record{total !== 1 ? "s" : ""} + + + + {/* ── 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 }} + /> + onChange(Array.from(e.currentTarget.files ?? []))} + style={{ + border: "1px dashed var(--mantine-color-gray-4)", + borderRadius: 8, + padding: 10, + fontSize: 12.5, + background: "var(--mantine-color-gray-0)", + }} + /> + {files.length > 0 ? ( + + {files.map((f) => ( + + {f.name} + + ))} + + ) : null} + + ); +} + +type UploadKind = "do" | "ro"; + +interface WizardStep { + label: string; + description: string; + done: boolean; +} + +type MilestoneRow = { milestoneCode?: string | null; status?: string | null }; + +/** SKIPPED counts as done — a step that does not apply must not stall the flow. */ +function isMilestoneDone( + milestones: MilestoneRow[] | undefined, + code: string, +): boolean { + const m = milestones?.find((x) => x.milestoneCode === code); + return m?.status === "COMPLETED" || m?.status === "SKIPPED"; +} + +/** + * The Djibouti-desk actions a transit agent performs on a shipment assigned to + * them: the DO/RO upload, the RO amendment request, and the T1 transit + * documents — laid out as the backoffice's clearance action panel is. + * + * Every button here maps to an endpoint the API authorizes by the assignment + * itself, so a control never promises something the server will refuse. The + * steps are rendered from the clearance payload rather than re-derived. + */ +export function TransitClearanceActionPanel({ + bookingId, + clearance, + tradeDirection, + onChanged, +}: { + bookingId: string; + clearance?: Freight.ClearanceView; + tradeDirection?: string | null; + onChanged: () => void; +}) { + const queryClient = useQueryClient(); + const [uploadKind, setUploadKind] = useState(null); + const [amendOpen, setAmendOpen] = useState(false); + const [t1Open, setT1Open] = useState(false); + + const isImport = tradeDirection === "IMPORT"; + const workflowFiles = clearance?.workflowFiles ?? []; + const hasDo = workflowFiles.some( + (f) => isDeliveryOrderFileCode(f.code) && f.file, + ); + const hasRo = workflowFiles.some( + (f) => isReleaseOrderFileCode(f.code) && f.file, + ); + const roHoldReason = clearance?.roHoldReason ?? null; + + const refresh = () => { + void queryClient.invalidateQueries({ queryKey: ["transit-clearance"] }); + void queryClient.invalidateQueries({ + queryKey: ["transit-clearance-history"], + }); + onChanged(); + }; + + // ── Steps ──────────────────────────────────────────────────────────────── + // The same wizards the GL Djibouti desk sees: 12 import steps, 11 export. + // Every `done` reads off the clearance payload — milestones, booking + // milestones, or a server-set flag — so a step can never claim to be + // complete when the server does not consider it so. + // This page is always booking-scoped, and for a booking the clearance view's + // milestones ARE the booking milestones — the GL detail page passes the same + // array for both on its `kind === "booking"` branch. + const ms = clearance?.milestones; + const done = (code: string) => isMilestoneDone(ms, code); + const bookingDone = (code: string) => isMilestoneDone(ms, code); + const dutyRequired = clearance?.dutyRequired ?? false; + + const steps: WizardStep[] = isImport + ? [ + { + label: "Customer documents", + description: "Reviewed and approved by GL Ethiopia", + done: done("DOCUMENTS_APPROVED"), + }, + { + label: "Request transit assignee", + description: clearance?.transitAssignee?.name + ? `Transit assignee: ${clearance.transitAssignee.name}` + : "GL Djibouti names the officer handling this shipment", + done: Boolean(clearance?.transitAssignee?.name), + }, + { + label: "Draft declaration", + description: "Customer accepts the estimated price", + done: done("DRAFT_DECLARATION_ACCEPTED") || done("DECLARED"), + }, + { + label: "Customs declaration", + description: "GL Ethiopia uploads declaration documents", + done: done("DECLARED"), + }, + { + label: "Duty & tax", + description: "Amount advised and notice attached", + // A shipment with no duty due skips this rather than stalling on it. + done: !dutyRequired || done("DUTY_TAXES_ADVISED"), + }, + { + label: "Customer payment", + description: "Customer uploads the duty payment slip", + done: !dutyRequired || done("DUTY_TAX_PAID"), + }, + { + label: "Transit Permit", + description: "Transit permit documents uploaded", + done: done("TRANSIT_PERMIT_UPLOADED"), + }, + { + label: "Finalize pre-clearance", + description: "GL Ethiopia hands off to GL Djibouti", + done: Boolean(clearance?.preClearanceFinalized), + }, + { + label: "Delivery Order", + description: "You upload the DO with its collection dates", + done: done("DO_COLLECTED"), + }, + { + label: "Create booking", + description: "GL Ethiopia books for the customer", + done: Boolean(clearance?.t1) || bookingDone("FREIGHT_PAYMENT_SETTLED"), + }, + { + label: "Freight payment", + description: "Customer pays the train and service charges", + done: bookingDone("FREIGHT_PAYMENT_SETTLED"), + }, + { + label: "Gate pass", + description: "Secured after payment and wagon allocation", + done: Boolean(clearance?.gatepassGranted), + }, + ] + : [ + { + label: "Customer documents", + description: "Reviewed and approved by GL Ethiopia", + done: done("DOCUMENTS_APPROVED"), + }, + { + label: "Request transit assignee", + description: clearance?.transitAssignee?.name + ? `Transit assignee: ${clearance.transitAssignee.name}` + : "GL Djibouti names the officer handling this shipment", + done: Boolean(clearance?.transitAssignee?.name), + }, + { + label: "Customs declaration", + description: "GL Ethiopia uploads — releases the export", + done: done("DECLARED"), + }, + { + label: "Release Order", + description: "You upload the RO with the vessel departure date", + done: done("RELEASE_ORDER_SECURED") || hasRo, + }, + { + label: "Create booking", + description: "GL Ethiopia books for the customer", + done: bookingDone("FREIGHT_PAYMENT_SETTLED") || Boolean(clearance?.t1), + }, + { + label: "Payment & wagon allocation", + description: "Customer pays; operations allocates wagons", + done: + bookingDone("FREIGHT_PAYMENT_SETTLED") && + (bookingDone("WAGON_ALLOCATED") || + Boolean(clearance?.train?.wagonAllocated)), + }, + { + label: "Transport document", + description: "GL Ethiopia uploads after wagon allocation", + done: bookingDone("EXPORT_TRANSPORT_ISSUED"), + }, + { + label: "Train to Djibouti", + description: "Departure and arrival", + done: Boolean(clearance?.train?.arrivedAt), + }, + { + label: "Accept T1", + description: "You close the T1 once the train arrives", + done: Boolean(clearance?.t1Closed), + }, + { + label: "Gate pass", + description: "Secured on the train schedule after arrival", + done: Boolean(clearance?.gatepassGranted), + }, + { + label: "Offload", + description: "Cargo comes off the train", + done: Boolean(clearance?.offloaded ?? clearance?.offload?.offloaded), + }, + ]; + + // The wizard sits on the FIRST step not yet done — matching the GL desk's + // "Step N of M", which counts the step being worked on, not the ones behind it. + const firstPending = steps.findIndex((s) => !s.done); + const activeStep = firstPending === -1 ? steps.length : firstPending; + const percent = Math.round((activeStep / steps.length) * 100); + const currentStep = steps[activeStep] ?? null; + + return ( + <> + + {/* Header: title, "Step N of M", and the progress bar — the GL desk's + own wizard chrome. */} + + + + + + + + {isImport + ? "Import pre-booking clearance" + : "Export customs clearance"} + + + Step {Math.min(activeStep + 1, steps.length)} of {steps.length} + {currentStep ? ` · ${currentStep.label}` : ""} + + + + + + + {percent}% + + + + + {/* Whose desk the flow is sitting on right now. */} + {clearance?.nextAction ? ( + + + + {clearance.nextAction.actor.replace("_", " ").toUpperCase()} + + + {clearance.nextAction.action} + + + ) : null} + + + {roHoldReason ? ( + } + title="RO amendment hold" + > + {roHoldReason} + + ) : null} + + + {steps.map((s) => ( + : undefined} + /> + ))} + + + + {isImport ? ( + + ) : ( + + )} + + + + {!isImport ? ( + + ) : null} + + + + + setUploadKind(null)} + onSuccess={refresh} + /> + + setT1Open(false)} + onSuccess={refresh} + /> + + setAmendOpen(false)} + onSuccess={refresh} + /> + + ); +} + +/** DO/RO upload — mirrors the backoffice's GlClearanceUploadModal. */ +function UploadOrderModal({ + kind, + bookingId, + replaceMode, + vesselArrivalDate, + doCollectedDate, + onClose, + onSuccess, +}: { + kind: UploadKind | null; + bookingId: string; + replaceMode: boolean; + vesselArrivalDate: string | null; + doCollectedDate: string | null; + onClose: () => void; + onSuccess: () => void; +}) { + const [files, setFiles] = useState([]); + const [vesselDate, setVesselDate] = useState(null); + const [doDates, setDoDates] = useState({ + vesselArrival: vesselArrivalDate ? new Date(vesselArrivalDate) : null, + doCollected: doCollectedDate ? new Date(doCollectedDate) : null, + }); + + const isDo = kind === "do"; + const today = todayMidnight(); + const doMin = + doDates.vesselArrival && doDates.vesselArrival > today + ? doDates.vesselArrival + : today; + const outOfOrder = + Boolean(doDates.vesselArrival && doDates.doCollected) && + !doDatesComplete(doDates); + + const close = () => { + setFiles([]); + onClose(); + }; + + const submit = useMutation({ + mutationFn: async () => { + if (isDo) { + return transitAssignmentsService.uploadDeliveryOrder(bookingId, files, { + vesselArrivalDate: toIsoDate(doDates.vesselArrival)!, + doCollectedDate: toIsoDate(doDates.doCollected)!, + }); + } + return transitAssignmentsService.uploadReleaseOrder( + bookingId, + files, + toIsoDate(vesselDate)!, + ); + }, + onSuccess: (result) => { + // The RO endpoint answers with a hold instead of an error when the vessel + // date is too soon — say so rather than reporting a clean success. + if (result && typeof result === "object" && "hold" in result && result.hold) { + toast.error(result.holdReason ?? "Vessel date too soon"); + } else { + toast.success( + isDo + ? replaceMode + ? "Delivery Order updated" + : "Delivery Order uploaded" + : replaceMode + ? "Release Order updated" + : "Release Order uploaded", + ); + } + setFiles([]); + onSuccess(); + close(); + }, + onError: (e: unknown) => + toast.error(e instanceof Error ? e.message : "Upload failed"), + }); + + const blocked = + files.length === 0 || + (isDo ? !doDatesComplete(doDates) : !vesselDate); + + return ( + + + + {isDo ? "Upload Delivery Order" : "Upload Release Order"} + +
+ } + > + + + {isDo + ? "Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected. Both dates are required." + : "Upload the Release Order and confirm the vessel departure date."} + + + {isDo ? ( + + + setDoDates((d) => ({ + ...d, + vesselArrival: v ? new Date(v) : null, + })) + } + minDate={today} + size="sm" + required + withAsterisk + /> + + setDoDates((d) => ({ + ...d, + doCollected: v ? new Date(v) : null, + })) + } + minDate={doMin} + size="sm" + required + withAsterisk + error={ + outOfOrder + ? "Cannot be before the vessel arrival date." + : undefined + } + /> + + ) : ( + setVesselDate(v ? new Date(v) : null)} + minDate={today} + size="sm" + required + /> + )} + + + + + + + + + + ); +} + +function T1UploadModal({ + opened, + bookingId, + onClose, + onSuccess, +}: { + opened: boolean; + bookingId: string; + onClose: () => void; + onSuccess: () => void; +}) { + const [files, setFiles] = useState([]); + + const submit = useMutation({ + mutationFn: () => + transitAssignmentsService.uploadT1Documents(bookingId, files), + onSuccess: () => { + toast.success("T1 documents uploaded"); + setFiles([]); + onSuccess(); + onClose(); + }, + onError: (e: unknown) => + toast.error(e instanceof Error ? e.message : "Upload failed"), + }); + + return ( + + + Upload T1 transit documents +
+ } + > + + + T1 documents are filed after wagon allocation and lock once the train + departs. + + + + + + + + + ); +} + +function RoAmendmentModal({ + opened, + bookingId, + onClose, + onSuccess, +}: { + opened: boolean; + bookingId: string; + onClose: () => void; + onSuccess: () => void; +}) { + const [note, setNote] = useState(""); + + const submit = useMutation({ + mutationFn: () => + transitAssignmentsService.requestRoAmendment(bookingId, note.trim()), + onSuccess: () => { + toast.success("RO amendment requested"); + setNote(""); + onSuccess(); + onClose(); + }, + onError: (e: unknown) => + toast.error(e instanceof Error ? e.message : "Request failed"), + }); + + return ( + + + Request RO amendment + + } + > + + }> + This puts the shipment on hold until GL Ethiopia amends the Release + Order. + +