From 850e0753d2b15b32f5d808e3f049d07d2801eec6 Mon Sep 17 00:00:00 2001 From: marshal Date: Tue, 8 Sep 2026 06:58:14 +0000 Subject: [PATCH] changes on transit agent --- .../booking-lifecycle-notifier.service.ts | 8 +- .../modules/bookings/bookings.controller.ts | 84 +- .../contracts/booking-clearance.service.ts | 28 +- .../checkpoint-leave-behind.spec.ts | 120 ++- .../services/train-scheduling.service.ts | 43 +- .../transit-agents.controller.ts | 14 + .../transit-agents.repository.ts | 13 + .../transit-agents/transit-agents.service.ts | 5 + .../transit-assignments.service.ts | 11 +- .../portal/src/constants/URLS.ts | 2 + .../forwarder/AssignedBookingDetailPage.tsx | 778 ++++++++++++++++-- .../forwarder/ForwarderDocumentReview.tsx | 460 +++++++++++ .../TransitClearanceActionPanel.tsx | 17 +- .../services/transit-assignments.service.ts | 60 ++ 14 files changed, 1528 insertions(+), 115 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/forwarder/ForwarderDocumentReview.tsx diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index efe1260b4..10b1677cb 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -417,12 +417,14 @@ export class BookingLifecycleNotifierService { b: Booking, agent: { id: string; name: string }, previous: string | null, + /** Who named the officer — GL Djibouti unless the clearing agent did. */ + by = 'GL Djibouti', ): void { const assignee = agent.name; const msg = previous - ? `GL Djibouti changed the transit assignee for shipment ${b.reference} from ` + + ? `${by} changed the transit assignee for shipment ${b.reference} from ` + `"${previous}" to "${assignee}".` - : `GL Djibouti assigned ${assignee} to handle shipment ${b.reference} in transit. ` + + : `${by} assigned ${assignee} to handle shipment ${b.reference} in transit. ` + `The customs declaration can now be filed.`; this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`); this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, { @@ -441,7 +443,7 @@ export class BookingLifecycleNotifierService { { title: 'New shipment assigned to you', body: - `GL Djibouti assigned shipment ${b.reference} to ${assignee} for transit. ` + + `${by} assigned shipment ${b.reference} to ${assignee} for transit. ` + `Open it in the portal to see what is needed.`, officerLink: `/transit-agent/bookings/${b.id}`, forwarderLink: '/forwarder/assigned-bookings', 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 5803899d1..1ce5da23d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -878,6 +878,33 @@ export class BookingsController { } } + /** + * Gate a GL review action that the assigned clearing agent may also take. + * + * On a without-customs booking the freight forwarder the customer assigned + * clears customs in GL's place: it reviews the customer's documents, asks + * for missing ones and finalizes. Staff pass on their permission; a portal + * caller must be assigned to THIS booking and the booking must be one GL + * does not clear — a customs booking stays with Global Logistics. + */ + private async assertStaffOrClearingAgent( + bookingId: string, + user: TCurrentUser, + staffPermission: string, + ): Promise { + if (hasFreightPermission(user, staffPermission)) return; + const booking = await this.bookingsService.findById(bookingId); + if ( + booking.customsClearingEnabled || + !(await this.bookingsService.isTransitAgentForBooking( + user?.id, + bookingId, + )) + ) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + } + private async assertWagonCancellationActor( cancellationId: string, user: TCurrentUser, @@ -1372,16 +1399,24 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + // Also the clearing agent assigned to a without-customs booking — see + // assertStaffOrClearingAgent. @Post(":id/clearance/review") - @BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments) + @MixedAudience(FREIGHT_PERMS.bookings.reviewDocuments) @ApiOperation({ - summary: "GL reviews a clearance document (Approve | Query)", + summary: + "GL — or the assigned clearing agent — reviews a clearance document (Approve | Query)", }) async reviewClearanceDocument( @Param("id", ParseUUIDPipe) id: string, @Body() dto: ReviewDocumentDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { + await this.assertStaffOrClearingAgent( + id, + user, + FREIGHT_PERMS.bookings.reviewDocuments, + ); const booking = await this.transitionService.reviewDocument( id, dto.fileKey, @@ -1393,16 +1428,21 @@ export class BookingsController { } @Post(":id/clearance/doc-requests") - @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: - "GL asks the customer for additional clearance document(s) — shown on the portal with author and time", + "GL — or the assigned clearing agent — asks the customer for additional clearance document(s); shown on the portal with author and time", }) async requestAdditionalDocuments( @Param("id", ParseUUIDPipe) id: string, @Body("note") note: string, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { + await this.assertStaffOrClearingAgent( + id, + user, + FREIGHT_PERMS.contracts.clearanceEtActions, + ); await this.transitionService.requestAdditionalDocuments( id, note, @@ -1686,15 +1726,20 @@ export class BookingsController { } @Post(":id/clearance/finalize") - @BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance) + @MixedAudience(FREIGHT_PERMS.bookings.finalizeClearance) @ApiOperation({ summary: - "GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", + "GL — or the assigned clearing agent — finalizes clearance (requires 100% approved) → CLEARANCE_READY", }) async finalizeClearance( @Param("id", ParseUUIDPipe) id: string, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { + await this.assertStaffOrClearingAgent( + id, + user, + FREIGHT_PERMS.bookings.finalizeClearance, + ); const booking = await this.transitionService.finalizeClearance( id, resolveAuthUserId(user), @@ -1721,21 +1766,36 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + // Also the clearing agent (freight forwarder) the customer assigned: on a + // without-customs booking there is no GL Djibouti desk in the loop, so the + // forwarder names the Djibouti officer itself. Any other portal caller is + // rejected below, hidden behind a NotFound like the ownership checks. @Post(":id/clearance/transit-assignee/assign") - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) @ApiOperation({ summary: - "GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", + "GL Djibouti — or the assigned clearing agent — picks the Djibouti transit officer from the roster; calling again reassigns", }) async assignBookingTransitAssignee( @Param("id", ParseUUIDPipe) id: string, @Body("transitAgentId", ParseUUIDPipe) transitAgentId: string, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { + const isStaff = hasFreightPermission( + user, + FREIGHT_PERMS.contracts.clearanceDjActions, + ); + if ( + !isStaff && + !(await this.bookingsService.isTransitAgentForBooking(user?.id, id)) + ) { + throw new NotFoundException(`Booking ${id} not found`); + } const booking = await this.bookingClearanceService.assignTransitAssignee( id, transitAgentId, resolveAuthUserId(user), + { byAssignedAgent: !isStaff }, ); return this.transitionService.enrichBookingResponse(booking); } 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 2fd232c7e..d0cdedc00 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 @@ -29,6 +29,7 @@ import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; import { GlExchangeService } from './gl-exchange.service'; +import { TransitAgentCountry } from '../transit-agents/entities/transit-agent.entity'; 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'; @@ -581,19 +582,33 @@ export class BookingClearanceService { * rejected unless the agent is active and inside its validity window. * Answering unblocks the declaration for Ethiopia. A later call overwrites * the name (reassignment) and re-notifies. + * + * `byAssignedAgent`: the clearing agent the customer assigned (a freight + * forwarder) names the Djibouti officer itself. That is a without-customs + * booking with no phased workflow and no GL request to answer, so neither + * gate applies — only that the officer is an active Djiboutian entry. The + * caller has already established the actor is assigned to this booking. */ async assignTransitAssignee( bookingId: string, transitAgentId: string, userId?: string, + opts: { byAssignedAgent?: boolean } = {}, ): Promise { - const booking = await this.loadBooking(bookingId); - if (!booking.transitAssigneeRequestedAt) { + const booking = opts.byAssignedAgent + ? await this.bookingsService.findById(bookingId) + : await this.loadBooking(bookingId); + if (!opts.byAssignedAgent && !booking.transitAssigneeRequestedAt) { throw new BadRequestException( 'GL Ethiopia has not requested a transit assignee for this shipment yet.', ); } const agent = await this.transitAgentsService.getAssignable(transitAgentId); + if (opts.byAssignedAgent && agent.country !== TransitAgentCountry.Djibouti) { + throw new BadRequestException( + `${agent.name} is not a Djibouti transit agent — pick one from the Djibouti roster.`, + ); + } const previous = booking.transitAssigneeName ?? null; await this.bookingsRepository.update(bookingId, { @@ -619,7 +634,14 @@ export class BookingClearanceService { metadata: { transitAgentId, agentName: agent.name, previous }, }); - this.notifier.transitAssigneeAssigned(booking, agent, previous); + this.notifier.transitAssigneeAssigned( + booking, + agent, + previous, + opts.byAssignedAgent + ? booking.customsClearingAgent ?? 'The clearing agent' + : undefined, + ); return this.bookingsService.findById(bookingId); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-leave-behind.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-leave-behind.spec.ts index c9ee445ca..f278d7740 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-leave-behind.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-leave-behind.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from "@nestjs/common"; +import { BadRequestException, ConflictException } from "@nestjs/common"; import { TrainSchedulingService } from "./services/train-scheduling.service"; @@ -266,3 +266,121 @@ describe('dispatchSchedule — the whole consist travels, not just loaded slots' expect(boundAtDispatch(['w1', null, 'w2'], [])).toEqual(['w1', 'w2']); }); }); + +/** + * Shedding is a WRITE that commits on its own, so it must be the last thing + * before the departure write — not the first thing dispatch does. When it ran + * first, a dispatch the wagon-yard gate then rejected (409) had already pulled + * the unticked bookings off the train: MANUAL_ONLY, allocations gone, customer + * told to rebook — for a train that never left (BK-2026-000341). + */ +describe('dispatchSchedule — sheds left-behind boarders only once every gate has passed', () => { + type Slot = { + physicalWagonId: string | null; + allocations: Array<{ bookingId: string }>; + }; + type OutElsewhereQuery = { where: { id: { value: string[] } } }; + + /** The real dispatchSchedule over stubs for everything around the gates. */ + const buildService = (opts: { yardGate?: () => Promise; slots?: Slot[] }) => { + const events: string[] = []; + let outElsewhere: OutElsewhereQuery | null = null; + const schedule = { + id: 'sched-1', + status: 'SCHEDULED', + originStationId: 'yard-a', + stationWorkLogs: { + 'yard-a': { + loading: { startedAt: '2026-09-08T05:00:00Z', endedAt: '2026-09-08T06:00:00Z' }, + }, + }, + trainSet: { wagons: opts.slots ?? [] }, + scheduleBookings: [], + }; + const svc = Object.create(TrainSchedulingService.prototype) as TrainSchedulingService; + const stubs = svc as unknown as Record; + stubs.trainSchedulesRepository = { + findByIdWithFullGraph: async () => { + events.push('load-graph'); + return schedule; + }, + }; + stubs.unloadedOriginBoarderIds = async () => ['b1', 'b2']; + stubs.unassignBooking = async (_scheduleId: string, bookingId: string) => { + events.push(`unassign:${bookingId}`); + }; + stubs.assertImportDjiboutiMayDepart = async () => undefined; + stubs.locomotivesOfTrainSet = () => []; + stubs.assertLocomotivesNotDispatchedElsewhere = async () => undefined; + stubs.assertPlannedYardsAligned = async () => { + events.push('gate:yards'); + await opts.yardGate?.(); + }; + stubs.assertNoPartiallyLoadedBookings = async () => undefined; + stubs.dataSource = { + getRepository: () => ({ + find: async (query: OutElsewhereQuery) => { + outElsewhere = query; + return []; + }, + }), + transaction: async () => { + events.push('depart'); + }, + }; + stubs.isImportDjiboutiSchedule = () => false; + stubs.emitWindowState = async () => undefined; + stubs.notifyScheduleBookings = async () => undefined; + stubs.getTrainScheduleById = async () => ({ trainNumber: '9001' }); + return { svc, events, outElsewhere: () => outElsewhere }; + }; + + it('leaves every unticked boarder on the train when a gate rejects the dispatch', async () => { + const { svc, events } = buildService({ + yardGate: async () => { + throw new ConflictException( + 'Cannot dispatch: 39 wagon(s) are not at the yard this schedule planned them for', + ); + }, + }); + await expect( + svc.dispatchSchedule('sched-1', { loadedBookingIds: [] }, 'user-1'), + ).rejects.toBeInstanceOf(ConflictException); + expect(events).toEqual(['load-graph', 'gate:yards']); + }); + + it('sheds after the last gate and before the departure write', async () => { + const { svc, events } = buildService({}); + await svc.dispatchSchedule('sched-1', { loadedBookingIds: [] }, 'user-1'); + expect(events).toEqual([ + 'load-graph', + 'gate:yards', + 'unassign:b1', + 'unassign:b2', + 'load-graph', + 'depart', + ]); + }); + + it('sheds nobody when the client omits the list', async () => { + const { svc, events } = buildService({}); + await svc.dispatchSchedule('sched-1', {}, 'user-1'); + expect(events).toEqual(['load-graph', 'gate:yards', 'depart']); + }); + + it('judges the out-on-another-train gate on the wagons that will actually depart', async () => { + const slots: Slot[] = [ + { physicalWagonId: 'w1', allocations: [{ bookingId: 'b1' }] }, // shed-only → released + { physicalWagonId: 'w2', allocations: [{ bookingId: 'b3' }] }, + { physicalWagonId: 'w3', allocations: [{ bookingId: 'b1' }, { bookingId: 'b3' }] }, + ]; + const ticked = buildService({ slots }); + await ticked.svc.dispatchSchedule('sched-1', { loadedBookingIds: [] }, 'user-1'); + expect(ticked.outElsewhere()?.where.id.value).toEqual(['w2', 'w3']); + + // No list → nothing is shed, so every pinned slot is judged. + const legacy = buildService({ slots }); + await legacy.svc.dispatchSchedule('sched-1', {}, 'user-1'); + expect(legacy.outElsewhere()?.where.id.value).toEqual(['w1', 'w2', 'w3']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 0cbe9c277..d76d4ba29 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -3169,22 +3169,20 @@ export class TrainSchedulingService { // deallocated from its wagon and returned to the booking pool — so the // origin auto-load below only ever touches confirmed cargo. Government // bookings cannot be unassigned and keep the historic auto-load. + // + // DECIDED here, SHED only after every gate below has passed. Unassign + // commits in its own transaction, so shedding first meant a dispatch the + // next gate rejected (loading window, wagons off their planned yard, …) + // had already stripped the bookings off the train — MANUAL_ONLY, + // allocations gone, customer told to rebook — while the train never left. + // Staff clear the blocker and dispatch again; the dialog re-sends the list. + let leftBehind: string[] = []; if (dto.loadedBookingIds) { const keep = new Set(dto.loadedBookingIds); const candidates = await this.unloadedOriginBoarderIds(scheduleId, schedule.originStationId); - const leftBehind = candidates.filter((id) => !keep.has(id)); - for (const bookingId of leftBehind) { - await this.unassignBooking(scheduleId, bookingId, userId); - } - if (leftBehind.length) { - // Unassign deleted allocations and slots — reload the graph dispatch works on. - const reloaded = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (!reloaded) { - throw new NotFoundException(`Train schedule ${scheduleId} not found`); - } - schedule = reloaded; - } + leftBehind = candidates.filter((id) => !keep.has(id)); } + const shed = new Set(leftBehind); // Dispatch requires the origin's loading window to be COMPLETE: started // and ended. Not started or still open both block — a train departs only // after loading was formally opened and closed. @@ -3220,8 +3218,14 @@ export class TrainSchedulingService { const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId); // Same rule for wagons: many schedules may pin the same wagon, but it can - // only be OUT on one dispatched train at a time. + // only be OUT on one dispatched train at a time. Judged on the train that + // will actually depart: shedding releases every slot left with no + // allocation, so those slots' wagons are not this train's concern. const pinnedPhysicalIds = (schedule.trainSet?.wagons ?? []) + .filter( + (slot) => + shed.size === 0 || (slot.allocations ?? []).some((a) => !shed.has(a.bookingId)), + ) .map((slot) => slot.physicalWagonId) .filter((id): id is string => Boolean(id)); if (pinnedPhysicalIds.length) { @@ -3245,6 +3249,19 @@ export class TrainSchedulingService { action: 'dispatch', }); + // Every gate passed — the train is leaving. Shed the unticked boarders now. + for (const bookingId of leftBehind) { + await this.unassignBooking(scheduleId, bookingId, userId); + } + if (leftBehind.length) { + // Unassign deleted allocations and slots — reload the graph dispatch works on. + const reloaded = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!reloaded) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + schedule = reloaded; + } + await this.dataSource.transaction(async (manager) => { const trainNumber = await this.assignTrainNumber(manager, schedule); if (setLocomotiveIds.length) { diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts index 16b1fcc41..dfa00bf3d 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts @@ -74,6 +74,20 @@ export class TransitAgentsController { return this.transitAgentsService.findForwarderOptions(); } + /** + * The Djibouti roster, id + name only, for the clearing agent on a booking + * to name the transit officer. Also before `:id` for the same reason. + */ + @Get("djibouti-options") + @PortalCustomer() + @ApiOperation({ + summary: + "List active Djibouti transit agents (id + name) an assigned clearing agent can hand the transit leg to", + }) + findDjiboutiOptions() { + return this.transitAgentsService.findDjiboutiOptions(); + } + @Get(":id") @RuleEngineView("transit-agents") @ApiOperation({ summary: "Get a transit agent by ID" }) diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts index d2cb4772c..2bcc999b0 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts @@ -41,6 +41,19 @@ export class TransitAgentsRepository extends BaseRepository { }); } + /** + * The Djibouti roster, `{ id, name }` only, for the clearing agent on a + * booking to hand the transit leg to — served to portal customers, so no + * contact details. Suspended officers are left out. + */ + findDjiboutiOptions(): Promise { + return this.repository.find({ + select: { id: true, name: true }, + where: { isActive: true, country: TransitAgentCountry.Djibouti }, + order: { name: "ASC" }, + }); + } + /** The transit agent signed in as `userId`, or null for any other account. */ findByUserId(userId: string): Promise { return this.repository.findOne({ where: { userId } }); diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts index c99296e85..f04047c5c 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts @@ -114,6 +114,11 @@ export class TransitAgentsService { return this.transitAgentsRepository.findForwarderOptions(); } + /** The Djibouti roster an assigned clearing agent picks the transit officer from. */ + findDjiboutiOptions(): Promise { + return this.transitAgentsRepository.findDjiboutiOptions(); + } + async findById(id: string): Promise { const agent = await this.transitAgentsRepository.findById(id); if (!agent) { 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 e992c2b96..c38a24f16 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 @@ -770,10 +770,19 @@ export class TransitAssignmentsService { const existing = await this.assignmentsRepository.findByBooking(bookingId); + // A shipment carries one agent PER COUNTRY: the Ethiopian clearing agent + // (the forwarder the customer picked) and the Djibouti transit officer + // work side by side. Only a predecessor in the same role is retired; the + // other country's row is left alone, or naming the officer would knock + // the forwarder off the booking it is clearing. + const incoming = await this.transitAgentsRepository.findById(transitAgentId); for (const row of existing) { if ( row.transitAgentId !== transitAgentId && - row.status !== TransitAssignmentStatus.Finished + row.status !== TransitAssignmentStatus.Finished && + (!incoming || + !row.transitAgent || + row.transitAgent.country === incoming.country) ) { await this.assignmentsRepository.softDelete(row.id); } diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 68ddab6b2..59e55d6d1 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -229,6 +229,8 @@ export const URL_CONSTANTS = { TRANSIT_AGENTS_API: { /** Active Ethiopian transit agents (id + name) a forwarder can register as. */ FORWARDER_OPTIONS: "/api/transit-agents/forwarder-options", + /** Active Djibouti transit agents (id + name) an assigned clearing agent can hand the transit leg to. */ + DJIBOUTI_OPTIONS: "/api/transit-agents/djibouti-options", }, PORTAL_CONTENT: { PUBLIC: "/api/support-content", diff --git a/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingDetailPage.tsx index 5fb57aca2..6ccd75eaa 100644 --- a/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/forwarder/AssignedBookingDetailPage.tsx @@ -1,32 +1,58 @@ import { Alert, + Anchor, Badge, Box, Button, Card, + Grid, Group, Loader, + Paper, + RingProgress, + Select, + SimpleGrid, Stack, + Tabs, Text, + Textarea, + ThemeIcon, + Timeline, Title, } from "@mantine/core"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, ArrowLeft, + ArrowRight, Building2, + CheckCircle2, + CircleDot, + ClipboardList, + Clock, Clock3, + FilePlus2, + FileText, + History, + MessageSquare, PackageCheck, + ShipWheel, + UserCheck, } from "lucide-react"; +import { useMemo, useState } from "react"; +import toast from "react-hot-toast"; import { useNavigate, useParams } from "react-router-dom"; -import { bookingDocNounCapitalized } from "@/pages/bookings/clearance/bookingNextAction"; +import useAuth from "@/hooks/useAuth"; +import { api } from "@/services/api"; import { transitAssignmentsService, + type TransitAssignment, type TransitAssignmentStatus, } from "@/services/transit-assignments.service"; +import type { Freight } from "@edr/types"; -import { AssignedBookingDocumentsLoader } from "./AssignedBookingDocuments"; +import { ForwarderDocumentReview } from "./ForwarderDocumentReview"; const LIST_PATH = "/forwarder/assigned-bookings"; @@ -57,28 +83,73 @@ function formatDate(value?: string | null): string { }); } +function formatDateTime(value: string): string { + const d = new Date(value); + return Number.isNaN(d.getTime()) ? value : d.toLocaleString(); +} + +function apiMessage(e: Error, fallback: string): string { + const data = (e as { response?: { data?: { message?: string | string[] } } }) + .response?.data; + const message = Array.isArray(data?.message) + ? data.message.join(", ") + : data?.message; + return message || e.message || fallback; +} + /** - * One booking a customer assigned to this forwarder, with the customer's - * import/export document grid on it. + * The clearing agent's working page for one assigned booking — the portal + * counterpart of the GL Ethiopia clearance page in the backoffice. * - * The forwarder clears customs on the customer's behalf, so it uploads the - * booking's clearance documents through the same flow and endpoint the - * customer uses — the API admits the assigned agent to both. Both parties can - * upload; what the forwarder never does here is pick the shipment day, which - * stays the customer's decision (`uploadOnly`). - * - * Until the roster role is approved the API hides the booking, so the page - * shows the assignment's own facts and says why the documents are not there. + * Same shape as that page: header with the review state, a KPI strip over the + * customer's documents, the review grid on the left (approve, query, upload + * on the customer's behalf, finalize), and the side column with the request + * for more documents, the Djibouti transit officer, and the booking facts. + * The History tab is the clearance trail. Uploading, reviewing and assigning + * are locked until the roster role is approved, since the API hides the + * booking until then. */ export default function AssignedBookingDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const { assignedBookingsUnlocked } = useAuth(); + const assignmentQuery = useQuery({ queryKey: ["transit-assignments", "my", id], queryFn: () => transitAssignmentsService.getById(id!), enabled: Boolean(id), }); const assignment = assignmentQuery.data; + const bookingId = assignment?.bookingId; + + const bookingQuery = useQuery({ + ...api.bookings.get.queryOptions({ input: { id: bookingId ?? "" } }), + enabled: Boolean(bookingId) && assignedBookingsUnlocked, + }); + const booking = bookingQuery.data; + + const clearanceQuery = useQuery({ + ...api.bookings.getClearance.queryOptions({ + input: { id: bookingId ?? "" }, + }), + enabled: Boolean(bookingId) && assignedBookingsUnlocked, + }); + const clearance = clearanceQuery.data; + + const stats = useMemo(() => { + const docs = (clearance?.documents ?? []).filter( + (d) => d.uploadedBy === "customer", + ); + const total = docs.length; + const approved = docs.filter((d) => d.reviewStatus === "APPROVED").length; + const queried = docs.filter((d) => d.reviewStatus === "QUERIED").length; + const pending = total - approved - queried; + const pct = total === 0 ? 0 : Math.round((approved / total) * 100); + const awaitingReview = docs.filter( + (d) => d.file && d.reviewStatus !== "APPROVED", + ).length; + return { total, approved, queried, pending, pct, awaitingReview }; + }, [clearance]); if (assignmentQuery.isPending) { return ( @@ -108,81 +179,268 @@ export default function AssignedBookingDetailPage() { } const b = assignment.booking; + const reference = b?.reference ?? booking?.reference ?? "Assigned booking"; + const direction = b?.tradeDirection ?? booking?.tradeDirection ?? null; const statusMeta = STATUS_META[assignment.status]; + const origin = + booking?.originYard?.label ?? booking?.originYard?.code ?? null; + const destination = + booking?.destinationYard?.label ?? booking?.destinationYard?.code ?? null; + const refresh = () => { + void bookingQuery.refetch(); + void clearanceQuery.refetch(); + }; + + const kpis = [ + { label: "Approved", value: stats.approved, icon: CheckCircle2, color: "edr-green" }, + { label: "Queried", value: stats.queried, icon: AlertCircle, color: "red" }, + { label: "Pending", value: stats.pending, icon: Clock, color: "gray" }, + { label: "Review progress", value: `${stats.pct}%`, icon: PackageCheck, color: "blue" }, + ]; return ( - navigate(LIST_PATH)} /> - - - -
- -
- - {b?.reference ?? "Assigned booking"} - - - - {assignment.customerName ?? "—"} - - - -
- - {b?.tradeDirection ? ( - - {prettyStatus(b.tradeDirection)} - - ) : null} - {b?.status ? ( - - {prettyStatus(b.status)} - - ) : null} - - {statusMeta.label} - - -
- - - - } - label="Assigned" - value={formatDate(assignment.assignedAt)} - /> - } - label="Started" - value={formatDate(assignment.startedAt)} - /> - } - label="Finished" - value={formatDate(assignment.finishedAt)} - /> - - {assignment.note ? ( - - {assignment.note} + {/* ── Header ─────────────────────────────────────────────── */} + + + navigate(LIST_PATH)} + style={{ cursor: "pointer" }} + > + Assigned bookings + + + / - ) : null} - + + {reference} + + - - - {b - ? bookingDocNounCapitalized({ - customsClearingEnabled: false, - tradeDirection: b.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT", - }) - : "Documents"} - - - + + + +
+ + + {reference} + + {direction ? ( + + {prettyStatus(direction)} + + ) : null} + {booking?.status ? ( + + {prettyStatus(booking.status)} + + ) : null} + + {statusMeta.label} + + {clearance ? ( + stats.awaitingReview > 0 ? ( + } + > + {stats.awaitingReview} needs approval + + ) : clearance.allApproved ? ( + } + > + All approved + + ) : ( + } + > + Review pending + + ) + ) : null} + + + + + + {assignment.customerName ?? "—"} + + + {origin || destination ? ( + + + {origin ?? "Origin"} + + + + {destination ?? "Destination"} + + + ) : null} + +
+
+
+
+ + {!assignedBookingsUnlocked ? ( + + Your transit agent registration is still under review. You can see + this booking, but reviewing its documents, uploading, and assigning + a Djibouti transit agent unlock once it is approved. + + ) : null} + + {/* ── KPI strip ───────────────────────────────────────────── */} + {assignedBookingsUnlocked ? ( + + {kpis.map((k) => ( + + + + + + + + {k.label} + + + {k.value} + + + + + ))} + + ) : null} + + + + }> + Clearance + + }> + History + + + + + + + + +
+ +
+ + Document review + + The customer's paperwork, reviewed by you as the + clearing agent. + + +
+ {!assignedBookingsUnlocked ? ( + + The document list opens once your role is approved. + + ) : bookingQuery.isPending ? ( + + + + Loading the booking… + + + ) : bookingQuery.isError || !booking ? ( + }> + The booking could not be loaded. + + ) : ( + + )} +
+
+ + + + {assignedBookingsUnlocked && bookingId ? ( + + ) : null} + + + {assignedBookingsUnlocked && clearance ? ( + + + + + + Review progress + + + + + {stats.pct}% + + + approved + + + } + /> + + + ) : null} + + +
+
+ + + {assignedBookingsUnlocked && bookingId ? ( + + ) : ( + + The clearance history opens once your role is approved. + + )} + +
); @@ -227,3 +485,361 @@ function Fact({ ); } + +/** Who the booking belongs to and where the assignment stands — the side card. */ +function BookingFactsCard({ + assignment, + booking, +}: { + assignment: TransitAssignment; + booking: Freight.IBooking | null; +}) { + const contractRef = (booking as { contract?: { reference?: string | null } } | null) + ?.contract?.reference; + return ( + + + + + + Customer & booking + + + } + label="Customer" + value={assignment.customerName ?? "—"} + /> + {contractRef ? ( + } label="Contract" value={contractRef} /> + ) : null} + + } label="Assigned" value={formatDate(assignment.assignedAt)} /> + } label="Started" value={formatDate(assignment.startedAt)} /> + } label="Finished" value={formatDate(assignment.finishedAt)} /> + + {assignment.note ? ( + + {assignment.note} + + ) : null} + + + ); +} + +/** + * Ask the customer for additional document(s) in plain words — the same card + * the GL desk has. The note, its author and its time show on the customer's + * booking page beside the upload box. + */ +function AdditionalDocsRequestCard({ + bookingId, + requests, + canRequest, + onSent, +}: { + bookingId: string; + requests: Freight.ClearanceDocRequest[]; + canRequest: boolean; + onSent?: () => void; +}) { + const [note, setNote] = useState(""); + const send = useMutation({ + mutationFn: () => + transitAssignmentsService.requestAdditionalDocuments(bookingId, note.trim()), + onSuccess: () => { + toast.success("Request sent to the customer"); + setNote(""); + onSent?.(); + }, + onError: (e: Error) => toast.error(apiMessage(e, "Could not send the request")), + }); + return ( + + + + + + + Request more documents + + The customer sees your note next to their upload box. + + + + {canRequest ? ( + +