From 2315ce06de0529688a618244b4de560d625415d2 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 27 Aug 2026 14:45:16 +0300 Subject: [PATCH 01/28] refactor: ( auth ) throttle only the public sign-in endpoints --- .../src/common/throttle-sign-in.decorator.ts | 4 ++++ .../src/modules/auth/auth.controller.ts | 22 ++++++++++--------- 2 files changed, 16 insertions(+), 10 deletions(-) create mode 100644 apps/edr-passenger-api/src/common/throttle-sign-in.decorator.ts diff --git a/apps/edr-passenger-api/src/common/throttle-sign-in.decorator.ts b/apps/edr-passenger-api/src/common/throttle-sign-in.decorator.ts new file mode 100644 index 000000000..40c1ee106 --- /dev/null +++ b/apps/edr-passenger-api/src/common/throttle-sign-in.decorator.ts @@ -0,0 +1,4 @@ +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; +export const ThrottleSignIn = (limit = 20) => + applyDecorators(UseGuards(ThrottlerGuard), Throttle({ auth: { limit, ttl: 60_000 } })); diff --git a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts index ce076c970..76cbb8477 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -21,7 +21,7 @@ import { ApiBearerAuth, } from "@nestjs/swagger"; import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator"; -import { Throttle, ThrottlerGuard } from "@nestjs/throttler"; +import { ThrottleSignIn } from "../../common/throttle-sign-in.decorator"; import { PassengerAuthService } from "./passenger-auth.service"; import { RegisterDto, @@ -37,16 +37,12 @@ import { JwtGuard } from "../../common/jwt.guard"; @ApiTags("Passenger Auth") @Controller("auth") -// Scoped to this controller rather than registered as a global APP_GUARD: the staged sign-in -// exposes an account-existence lookup, and rate limiting is the mitigation for it. Applying the -// guard app-wide would change the behaviour of every other module at the same time. -@UseGuards(ThrottlerGuard) -@Throttle({ auth: { limit: 20, ttl: 60_000 } }) export class AuthController { constructor(private passengerAuthService: PassengerAuthService) {} @Post("register") @IsPublic() + @ThrottleSignIn() @ApiOperation({ summary: "Register new passenger account (sends SMS verification code)", }) @@ -66,6 +62,7 @@ export class AuthController { @Post("register/resend-code") @IsPublic() + @ThrottleSignIn() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Resend the registration verification code for a pending account", @@ -84,6 +81,7 @@ export class AuthController { @Post("login") @IsPublic() + @ThrottleSignIn() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Login with email and password" }) @ApiResponse({ @@ -199,11 +197,11 @@ export class AuthController { @Post("identifier/lookup") @IsPublic() + // Tighter than the other sign-in endpoints: this is the one that answers "does this account + // exist", so it is the one worth making expensive to sweep. Still roomy enough that a + // passenger correcting a typo two or three times is unaffected. + @ThrottleSignIn(10) @HttpCode(HttpStatus.OK) - // Tighter than the rest of the controller: this is the endpoint that answers "does this - // account exist", so it is the one worth making expensive to sweep. Still roomy enough - // that a passenger correcting a typo two or three times is unaffected. - @Throttle({ auth: { limit: 10, ttl: 60_000 } }) @ApiOperation({ summary: "Step 1 of sign-in — decide what to ask the user for next", description: @@ -222,6 +220,7 @@ export class AuthController { @Post("password-setup/request") @IsPublic() + @ThrottleSignIn() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Send the SMS code that lets an account with no password set one", @@ -237,6 +236,7 @@ export class AuthController { @Post("password-setup/complete") @IsPublic() + @ThrottleSignIn() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Redeem the code, set the password, and sign in", @@ -256,6 +256,7 @@ export class AuthController { @Post("fayda/request-password-setup") @IsPublic() + @ThrottleSignIn() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Send OTP to phone for Fayda-verified account password setup", @@ -277,6 +278,7 @@ export class AuthController { @Post("fayda/verify-and-login") @IsPublic() + @ThrottleSignIn() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: "Verify OTP and receive session token for Fayda-verified account", From f23500c273bf73676736639147293483c401fd1b Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 29 Aug 2026 10:43:34 +0000 Subject: [PATCH 02/28] fix(train-scheduling): drop wagon slots with no physical wagon pinned from marshalling docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A slot can hold a LOADED allocation with no physical wagon backing it — a fleet shortfall can leave a booking's allocation unpinned, and a REAL cut nulls physical_wagon_id without ever touching the slot's own status. Neither buildImportLoadListHtml, buildExportLoadListHtml, nor intercityOnBoardView checked for this: they rendered a ghost row (dash wagon number, but cargo/container info still listed) and counted it toward the Wagons tile. Reproduced live on S-2026-00073: 5 slots (seq 60-64) with no physical wagon, from a booking currently held out for lack of fleet, rendered as phantom rows on the origin doc and every numbered marshalling doc — visible as the Seq column running up to 64 despite only 54 real wagons. All three now additionally require physicalWagonId (or a resolved wagonNumber, for the import doc's already-flattened shape) before a slot gets a row. Added coverage for all three call sites. Co-Authored-By: Claude Sonnet 5 --- .../services/train-scheduling.service.spec.ts | 91 +++++++++++++++++++ .../services/train-scheduling.service.ts | 19 +++- 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index 64af1a56f..fb64b1e5c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -1062,6 +1062,7 @@ describe('TrainSchedulingService', () => { const makeWagon = (sequenceNo: number, wagonNumber: string, allocations: unknown[]) => ({ sequenceNo, wagonNumber, + physicalWagonId: `wagon-id-${wagonNumber}`, physicalWagon: { wagonNumber }, wagonType: { code: 'NW5', name: 'Flat Wagon', tareWeightTons: 22 }, lengthMeters: 14, @@ -1185,6 +1186,57 @@ describe('TrainSchedulingService', () => { expect(html).toContain('Total containers1'); }); + it('drops a slot with no physical wagon pinned from the import document too', () => { + const loadList = { + generatedAt: '2026-07-17T08:00:00.000Z', + trainScheduleId: 'schedule-1', + trainNumber: '7002', + route: 'DCT/SGTD → GMP', + origin: 'DCT/SGTD', + destination: 'GMP', + totalBookings: 2, + wagons: [ + { + sequenceNo: 1, + // No physical wagon pinned (fleet shortfall, or a REAL cut nulled + // it out) — nothing physical to marshal, even though the slot + // still carries a LOADED allocation. + wagonNumber: null, + boardYard: null, + alightYard: null, + allocations: [ + { + ...loadedAllocation, + containerItems: [{ containerNumber: 'GHOST-001' }], + }, + ], + }, + { + sequenceNo: 2, + wagonNumber: 'W-IMP', + boardYard: null, + alightYard: null, + allocations: [ + { + ...loadedAllocation, + containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }], + }, + ], + }, + ], + operation: { status: {} }, + }; + + const html = (service as never as { + buildImportLoadListHtml: (l: unknown) => string; + }).buildImportLoadListHtml(loadList); + + expect(html).not.toContain('GHOST-001'); + expect(html).toContain('W-IMP'); + expect(html).toContain('Wagons1'); + expect(html).toContain('Total containers1'); + }); + it('drops a leg slot entirely from the export document — not part of the departing consist', () => { const sizedAllocation = { ...loadedAllocation, @@ -1211,6 +1263,29 @@ describe('TrainSchedulingService', () => { expect(html).toContain('Total containers1'); }); + it('drops a whole-route slot with no physical wagon pinned from the export document too', () => { + const sizedAllocation = { + ...loadedAllocation, + containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }], + }; + const ghost = { ...makeWagon(2, 'W-GHOST', [sizedAllocation]), id: 'slot-ghost', physicalWagonId: null }; + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [sizedAllocation]), id: 'slot-1' }, ghost] }, + scheduleBookings: [], + }; + + const html = (service as never as { + buildExportLoadListHtml: (s: unknown, o?: unknown) => string; + }).buildExportLoadListHtml(schedule, {}); + + expect(html).not.toContain('W-GHOST'); + expect(html).toContain('Wagons1'); + expect(html).toContain('Total containers1'); + }); + it('prints the consist-changes table for this stop, and omits it when there are none', () => { const schedule = { id: 'schedule-1', @@ -1444,6 +1519,22 @@ describe('TrainSchedulingService', () => { expect(numbers).toEqual(['W-LEG2']); }); + it('drops a whole-route slot with no physical wagon pinned, even though its allocation is LOADED', () => { + // A booking can hold a LOADED allocation before a real wagon backs it + // (fleet shortfall left the slot unpinned), or a REAL cut nulls + // physicalWagonId without ever touching the slot's own status. Either + // way there is no physical wagon standing there to marshal. + const pinned = makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]); + const ghost = { ...makeWagon(2, 'W-002', [allocWith({ status: 'LOADED' })]), physicalWagonId: null }; + const schedule = { trainSet: { wagons: [pinned, ghost] }, scheduleBookings: [] }; + + const { wagons } = onBoardView(schedule); + const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map( + (w) => w.physicalWagon.wagonNumber, + ); + expect(numbers).toEqual(['W-001']); + }); + it('drops a leg slot LOADED by generation time but not yet coupled as of this stop', () => { // Both W-DIRE (coupled+loaded at Dire Dawa) and W-ADAMA (coupled+loaded // at Adama, a LATER stop) read identically to intercityOnBoardView by 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 07d386df3..9e0489012 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 @@ -3602,6 +3602,13 @@ export class TrainSchedulingService { const wagons = (schedule.trainSet?.wagons ?? []) .filter((wagon) => { if (wagon.status === 'DEPARTED') return false; + // No physical wagon pinned to the slot — a booking can hold an + // allocation before a real wagon backs it (e.g. a fleet shortfall + // left it unpinned). There is nothing physical here to marshal, and + // a REAL cut also lands here: it nulls physicalWagonId without ever + // touching this slot's own status, so a cut wagon would otherwise + // linger as a phantom row with its cargo still listed. + if (!wagon.physicalWagonId) return false; const hasLoaded = (wagon.allocations ?? []).some((a) => a.status === 'LOADED'); return wagon.boardYardId == null || hasLoaded; }) @@ -3930,6 +3937,11 @@ export class TrainSchedulingService { // Their own coupling shows up on THAT stop's own marshalling document. const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])] .filter((wagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id)) + // No physical wagon pinned to the slot (fleet shortfall left a booking's + // allocation unpinned, or a REAL cut nulled it out): nothing physical + // to marshal, so no row. Harmless no-op for the numbered docs, whose + // wagons list already went through intercityOnBoardView's own check. + .filter((wagon) => Boolean(wagon.physicalWagonId)) .sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0)); // Empties sit on wagons that carry no booking allocation, keyed by the wagon // slot recorded when they were loaded. @@ -4323,8 +4335,11 @@ export class TrainSchedulingService { // A leg slot (boardYard set) couples mid-corridor — it is not part of the // consist this Djibouti-side document is checked against yet, so it gets // no row and no count here at all. Its own coupling shows up on THAT - // stop's own marshalling document once it actually happens. - const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard); + // stop's own marshalling document once it actually happens. Same for a + // slot with no physical wagon pinned at all — a booking can hold an + // allocation before a real wagon backs it (fleet shortfall), or a REAL + // cut nulled it out; either way there is nothing physical to marshal. + const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard && wagon.wagonNumber != null); const totalAllocations = wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0); const totalWeight = wagons.reduce( (sum, wagon) => sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), From cc50a6687adb4e69fc054adc4db4990ae4b3c8a1 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 31 Aug 2026 11:28:36 +0000 Subject: [PATCH 03/28] feat(bookings): per-row stations + totals footer on carriage acceptance sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sheet printed Departure/Arrival Station as booking-level constants and squeezed the footer figures into unrelated table columns (gross weight under Cargo Name, full/empty under Wagon No.). Stations now resolve per row from the slot's own board/alight yard, falling back to the schedule's origin/destination and then the booking yards — mirroring buildExportLoadListHtml's leg-slot handling. The paper form's footer becomes its own tile block: In Total Wagon No., Tare Weight, Load Capacity, Gross Weight, Equated Length, Full Wagon, Empty Wagon, Total Amount. Rendered as .tile so buildTabularFallbackPdf still prints every figure where Chromium is absent. --- .../src/modules/bookings/bookings.service.ts | 44 +++++++--- .../carriage-acceptance-price-split.spec.ts | 81 +++++++++++++++++++ .../warehouses/warehouse-inventory.service.ts | 15 +++- 3 files changed, 130 insertions(+), 10 deletions(-) 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 5cdb0d6a4..da7811ecb 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -112,6 +112,9 @@ interface CarriageAcceptanceWagonRow { departureAt: Date | null; marshalledAt: string | null; arrivalAt: string | null; + /** Per-row stations: the slot's own board/alight yard, else the schedule's endpoints. */ + departureStation: string | null; + arrivalStation: string | null; containerNumbers: string | null; sealNumbers: string | null; /** Allocation status — LOADED/DEPARTED means EDR has the cargo. */ @@ -291,6 +294,8 @@ export class BookingsService { s.scheduled_departure_date AS "departureAt", so.label AS "marshalledAt", sd.label AS "arrivalAt", + COALESCE(by_.label, so.label) AS "departureStation", + COALESCE(ay.label, sd.label) AS "arrivalStation", a.status AS "status", string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers", string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers" @@ -303,11 +308,14 @@ export class BookingsService { ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL LEFT JOIN freight.yards so ON so.id = s.origin_station_id LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id + LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id LEFT JOIN freight.wagon_allocation_container_items ci ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL WHERE a.booking_id = $1 AND a.deleted_at IS NULL GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, - s.train_number, s.scheduled_departure_date, so.label, sd.label + s.train_number, s.scheduled_departure_date, so.label, sd.label, + by_.label, ay.label ORDER BY tsw.sequence_no`, [bookingId], ); @@ -388,6 +396,8 @@ export class BookingsService { departureAt: null, marshalledAt: null, arrivalAt: null, + departureStation: null, + arrivalStation: null, containerNumbers: row.containerNumbers, sealNumbers: row.sealNumbers ?? null, // A received line has no allocation; it is cargo EDR already holds. @@ -539,9 +549,9 @@ export class BookingsService { ${num(w.tareWeightTons, 2)} ${num(w.equatedLength)} ${num(w.loadCapacityTons)} - ${esc(arrivalStation)} + ${esc(w.arrivalStation ?? arrivalStation)} ${esc(cargoName)} - ${esc(departureStation)} + ${esc(w.departureStation ?? departureStation)} ${esc(w.containerNumbers)} ${esc(w.sealNumbers)} ${ @@ -558,16 +568,12 @@ export class BookingsService { const totalsRow = ` TOT ${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'} - ${ - pendingWagons - ? 'pending marshalling' - : `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}` - } + ${num(totals.tare, 2)} ${num(totals.length)} ${num(totals.capacity)} - Gross ${num(totals.tare + totals.load)} T + @@ -575,6 +581,23 @@ export class BookingsService { ${money(totalAmount)} `; + // The signed footer of the paper sheet. Rendered as .tile so the + // Chromium-less fallback (buildTabularFallbackPdf parses .tile, not + // arbitrary divs) still prints every figure. + const footer = ` + `; + return ` @@ -591,6 +614,8 @@ export class BookingsService { .meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; } .meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; } .summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; } + .footer-summary { grid-template-columns: repeat(8, 1fr); margin: 10px 0 0; } + .footer-summary .tile { background: #f8fafc; } .tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; } .tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; } .tile strong { font-size: 11px; } @@ -652,6 +677,7 @@ export class BookingsService { ${totalsRow} +${footer}
${ diff --git a/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts index 195e0cab0..45907c09a 100644 --- a/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts @@ -24,3 +24,84 @@ describe('carriage acceptance sheet — price split', () => { expect(shares).toEqual([33.33, 33.33, 33.34]); }); }); + +// The HTML builder only reaches `this` for two prototype helpers (escapeHtml, +// splitAmountAcrossWagons), so the prototype itself serves as `this`. +const buildSheet = (wagons: unknown[], booking: Record = {}): string => + ( + BookingsService.prototype as unknown as { + buildCarriageAcceptanceSheetHtml( + b: unknown, + w: unknown[], + o: { pendingWagons: boolean }, + ): string; + } + ).buildCarriageAcceptanceSheetHtml.call( + BookingsService.prototype, + { + reference: 'BK-1', + tradeDirection: 'EXPORT', + totalAmount: 100, + paymentCurrency: 'ETB', + originYard: { label: 'Booking Origin' }, + destinationYard: { label: 'Booking Destination' }, + ...booking, + }, + wagons, + { pendingWagons: false }, + ); + +const wagon = (over: Record = {}) => ({ + sequenceNo: 1, + wagonType: 'FLAT', + wagonNumber: 'W-001', + tareWeightTons: '20', + equatedLength: '14', + loadCapacityTons: '60', + allocatedWeightTons: '40', + trainNumber: '8302', + departureAt: null, + marshalledAt: 'DCT/SGTD', + arrivalAt: 'GMP', + departureStation: null, + arrivalStation: null, + containerNumbers: 'CN-1', + sealNumbers: 'SL-1', + status: 'LOADED', + ...over, +}); + +describe('carriage acceptance sheet — rows and footer', () => { + it('prints each row its own Departure/Arrival Station, falling back to the booking yards', () => { + const html = buildSheet([ + wagon({ departureStation: 'Dire Dawa Port', arrivalStation: 'Adama' }), + wagon({ sequenceNo: 2, wagonNumber: 'W-002' }), + ]); + expect(html).toContain('Dire Dawa Port'); + expect(html).toContain('Adama'); + expect(html).toContain('Booking Origin'); + expect(html).toContain('Booking Destination'); + }); + + it('totals the footer over loaded wagons only', () => { + const html = buildSheet([ + wagon(), + wagon({ sequenceNo: 2, wagonNumber: 'W-002', status: 'ALLOCATED' }), + wagon({ + sequenceNo: 3, + wagonNumber: 'W-003', + allocatedWeightTons: '0', + containerNumbers: null, + }), + ]); + // 2 loaded of 3: tare 40, capacity 120, equated length 28, gross 40 + 40 load. + expect(html).toContain('In Total Wagon No.2'); + expect(html).toContain('Tare Weight (T)40.00'); + expect(html).toContain('Load Capacity (T)120.000'); + expect(html).toContain('Gross Weight (T)80.000'); + expect(html).toContain('Equated Length28.000'); + expect(html).toContain('Full Wagon1'); + expect(html).toContain('Empty Wagon1'); + expect(html).toContain('Total Amount (ETB)100.00'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 43e7d7ae8..653c2ea5a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -319,6 +319,14 @@ export interface LoadableTrainRow { destination: string | null; status: string; departureTime: string | Date | null; + /** freight.yards.id the train departs from — the default boarding yard. */ + originStationId: string | null; + /** + * The schedule's per-yard loading/unloading time windows, exactly as the train + * schedule page stores them. The warehouse loading queues render the same + * Start/End controls off this, so both surfaces show one truth. + */ + stationWorkLogs: Record | null; /** Received/ready inventory not yet loaded onto this train. */ readyCount: number; /** Inventory already loaded onto this train. */ @@ -340,7 +348,12 @@ export interface TrainLoadableItemRow { wagonId: string | null; wagonNumber: string | null; sequenceNo: number | null; - /** True only when the item is READY_FOR_LOADING and has an allocated wagon. */ + /** The booking's boarding yard — the yard whose loading window gates this item. */ + originYardId: string | null; + originYardLabel: string | null; + /** True once "Start loading" was clicked for this item's boarding yard on this train. */ + loadingWindowStarted: boolean; + /** True only when the item is READY_FOR_LOADING, has an allocated wagon and GRN, and its yard's loading window is open. */ loadable: boolean; } From 0ac85ebc1f82a4944ee193086663d6b129c65b0c Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 31 Aug 2026 11:45:55 +0000 Subject: [PATCH 04/28] fix --- .../chat/chat-provisioning.service.spec.ts | 92 ++++++++++++++ .../modules/chat/chat-provisioning.service.ts | 13 ++ .../src/modules/chat/chat.module.ts | 4 +- .../src/modules/chat/matrix.client.spec.ts | 118 +++++++++++++++++- .../src/modules/chat/matrix.client.ts | 88 ++++++++++++- .../modules/health/health.controller.spec.ts | 100 +++++++++++++++ .../src/modules/health/health.controller.ts | 40 +++++- .../src/modules/health/health.module.ts | 4 +- 8 files changed, 451 insertions(+), 8 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/chat/chat-provisioning.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/health/health.controller.spec.ts diff --git a/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.spec.ts b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.spec.ts new file mode 100644 index 000000000..935f03ab4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.spec.ts @@ -0,0 +1,92 @@ +import 'reflect-metadata'; + +import type { DataSource } from 'typeorm'; + +import { ChatProvisioningService } from './chat-provisioning.service'; +import type { MatrixClient } from './matrix.client'; + +/** + * `joinUserRooms` is the only thing standing between a first sign-in and an + * empty Element — the reconcile that would otherwise fill the room list runs + * nightly. Both branches below are outages that actually happened on dev. + */ +describe('ChatProvisioningService.joinUserRooms', () => { + const NAA = '03f5eb9e-23a0-4413-8d98-8de4b98b1be2'; + const SUPER_ADMIN = 'f1534714-fa4a-4780-a081-05d4c1f6c25f'; + + function harness(holders: unknown[]) { + const matrix = { + mxidFor: jest.fn( + (userId: string, name: string) => `@${name}.${userId.slice(0, 6)}:m.test`, + ), + ensureUser: jest.fn(async (_mxid: string, _name?: string) => undefined), + ensureRoom: jest.fn( + async (alias: string, _name?: string, _opts?: unknown) => `!${alias}:m.test`, + ), + ensureJoined: jest.fn(async (_roomId: string, _mxid: string) => undefined), + }; + const dataSource = { query: jest.fn(async () => holders) }; + const service = new ChatProvisioningService( + dataSource as unknown as DataSource, + matrix as unknown as MatrixClient, + ); + return { service, matrix }; + } + + it('creates nothing for a user holding no current position', async () => { + // Super Admin on dev: three iam.employees rows, zero employee_positions. + // Synapse still auto-registers the account on JWT login, so the only + // symptom is a working sign-in into a client with no rooms in it. + const { service, matrix } = harness([]); + + await expect(service.joinUserRooms(SUPER_ADMIN, 'Super Admin')).resolves.toBe(0); + + expect(matrix.ensureUser).not.toHaveBeenCalled(); + expect(matrix.ensureRoom).not.toHaveBeenCalled(); + expect(matrix.ensureJoined).not.toHaveBeenCalled(); + }); + + it('joins a position holder to the space, #general and their dept room', async () => { + const { service, matrix } = harness([ + { + positionKey: 'edr_freight_app/marketer', + positionName: 'Marketer', + userId: NAA, + userName: 'naa', + }, + ]); + + await expect(service.joinUserRooms(NAA, 'naa')).resolves.toBe(2); + + // The account has to exist before the admin join API will touch it — JWT + // auto-registration happens after this runs. + expect(matrix.ensureUser).toHaveBeenCalledWith('@naa.03f5eb:m.test', 'naa'); + + expect(matrix.ensureRoom.mock.calls.map(([alias]) => alias)).toEqual([ + 'edr-freight', + 'general', + 'dept-edr_freight_app/marketer', + ]); + + // The space itself is joined, not only the rooms under it: Element shows a + // space in the left rail only to its members, so dropping this scatters + // every dept room loose into Home. + expect(matrix.ensureJoined.mock.calls.map(([roomId]) => roomId)).toEqual([ + '!edr-freight:m.test', + '!general:m.test', + '!dept-edr_freight_app/marketer:m.test', + ]); + }); + + it('scopes the position lookup to the one user', async () => { + const { service } = harness([]); + await service.joinUserRooms(NAA, 'naa'); + // Without the third parameter this would reconcile the whole unit on every + // click of "Open EDR Chat". + const [sql, params] = (service as unknown as { + dataSource: { query: jest.Mock }; + }).dataSource.query.mock.calls[0]; + expect(sql).toContain('AND e.user_id = $3'); + expect(params).toEqual(['edr_freight', 'edr_freight_app', NAA]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts index b122d8073..23d05d8e6 100644 --- a/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts +++ b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts @@ -113,6 +113,11 @@ export class ChatProvisioningService { const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', { isSpace: true, }); + // The space itself, not only the rooms under it: Element lists a space in + // the left rail only for members of that space, so skipping this scatters + // every dept room loose into Home and the "EDR Freight" grouping never + // appears at all. + await this.matrix.ensureJoined(spaceId, mxid); const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', { parentSpaceId: spaceId, }); @@ -192,6 +197,14 @@ export class ChatProvisioningService { // leaver, not just moved between positions — deactivate their account. const kickedUserIds = new Set(); + // Space membership follows the org tree exactly like room membership — + // see the ensureJoined in joinUserRooms for why the space needs joining + // at all. + const spaceDiff = await this.syncMembership(spaceId, allUserIds, botMxid); + joined += spaceDiff.joined; + kicked += spaceDiff.kicked.length; + spaceDiff.kicked.forEach((uid) => kickedUserIds.add(uid)); + const generalDiff = await this.syncMembership(generalRoomId, allUserIds, botMxid); joined += generalDiff.joined; kicked += generalDiff.kicked.length; diff --git a/apps/edr-freight-api/src/modules/chat/chat.module.ts b/apps/edr-freight-api/src/modules/chat/chat.module.ts index 8df827339..db2e9c44c 100644 --- a/apps/edr-freight-api/src/modules/chat/chat.module.ts +++ b/apps/edr-freight-api/src/modules/chat/chat.module.ts @@ -11,6 +11,8 @@ import { MatrixClient } from './matrix.client'; providers: [MatrixClient, ChatSsoService, ChatProvisioningService, ChatBridgeService], // ChatBridgeService: consumed by NotificationInboxModule to mirror // BACKOFFICE notifications into chat — see notification-inbox.module.ts. - exports: [ChatBridgeService], + // MatrixClient: HealthModule's readiness probe reports whether + // MATRIX_ADMIN_TOKEN really carries server-admin rights. + exports: [ChatBridgeService, MatrixClient], }) export class ChatModule {} diff --git a/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts b/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts index ea5faa978..73798bce9 100644 --- a/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts +++ b/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts @@ -1,4 +1,5 @@ -import { chatLocalpart } from './matrix.client'; +import type { ChatConfig } from '../../config/chat.config'; +import { MatrixClient, chatLocalpart } from './matrix.client'; describe('chatLocalpart', () => { it('reads from the name, not the id', () => { @@ -33,3 +34,118 @@ describe('chatLocalpart', () => { } }); }); + +const config: ChatConfig = { + enabled: true, + baseUrl: 'https://matrix.test', + publicBaseUrl: 'https://matrix.test', + webUrl: 'https://chat.test', + serverName: 'matrix.test', + jwtSecret: 'secret', + adminToken: 'syt_whatever', +}; + +type FetchFn = typeof globalThis.fetch; + +/** Just enough of a Response for {@link MatrixClient}'s fetch wrappers. */ +function response(status: number, body: unknown) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => JSON.stringify(body), + }; +} + +const realFetch: FetchFn = globalThis.fetch; +const fetchMock = jest.fn(); + +beforeEach(() => { + fetchMock.mockReset(); + globalThis.fetch = fetchMock as unknown as FetchFn; +}); + +afterAll(() => { + globalThis.fetch = realFetch; +}); + +describe('MatrixClient.verifyServerAdmin', () => { + it('accepts a token that can actually call the Synapse admin API', async () => { + fetchMock + .mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' })) + .mockResolvedValueOnce(response(200, { users: [], total: 1 })); + + const check = await new MatrixClient(config).verifyServerAdmin(); + + expect(check).toEqual({ ok: true, actingAs: '@edrbot:matrix.test' }); + // The admin ping is the check. If this ever regresses to whoami alone, + // the assertion below is what catches it. + expect(String(fetchMock.mock.calls[1][0])).toContain('/_synapse/admin/'); + }); + + it('rejects a valid token that is not a server admin', async () => { + // The dev outage, exactly: MATRIX_ADMIN_TOKEN held @super-admin's own + // token. whoami answered 200, every /_synapse/admin call answered 403, + // ensureUser threw, ChatSsoService swallowed it, and every employee got a + // working sign-in into an Element with no rooms in it. + fetchMock + .mockResolvedValueOnce( + response(200, { user_id: '@super-admin.f15347:matrix.test' }), + ) + .mockResolvedValueOnce( + response(403, { + errcode: 'M_FORBIDDEN', + error: 'You are not a server admin', + }), + ); + + const check = await new MatrixClient(config).verifyServerAdmin(); + + expect(check.ok).toBe(false); + // Naming the account the token belongs to is the whole point — it is what + // turns "chat is broken" into "wrong token in the env". + expect(check.actingAs).toBe('@super-admin.f15347:matrix.test'); + expect(check.error).toContain('403'); + }); + + it('rejects a token that is not valid at all', async () => { + fetchMock.mockResolvedValueOnce( + response(401, { errcode: 'M_UNKNOWN_TOKEN', error: 'Invalid access token' }), + ); + + const check = await new MatrixClient(config).verifyServerAdmin(); + + expect(check.ok).toBe(false); + expect(check.actingAs).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); // no point pinging admin after this + }); +}); + +describe('MatrixClient.adminCheck', () => { + it('does not re-hit Synapse on every readiness probe', async () => { + fetchMock + .mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' })) + .mockResolvedValueOnce(response(200, { users: [] })); + + const client = new MatrixClient(config); + const first = await client.adminCheck(); + const second = await client.adminCheck(); + + expect(second).toBe(first); + expect(fetchMock).toHaveBeenCalledTimes(2); // whoami + admin ping, once + }); + + it('re-checks when forced, so boot never reads a stale verdict', async () => { + fetchMock + .mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' })) + .mockResolvedValueOnce(response(200, { users: [] })) + .mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' })) + .mockResolvedValueOnce(response(200, { users: [] })); + + const client = new MatrixClient(config); + await client.adminCheck(); + await client.adminCheck(true); + + expect(fetchMock).toHaveBeenCalledTimes(4); + }); +}); diff --git a/apps/edr-freight-api/src/modules/chat/matrix.client.ts b/apps/edr-freight-api/src/modules/chat/matrix.client.ts index 1cd09ac33..fdfab052d 100644 --- a/apps/edr-freight-api/src/modules/chat/matrix.client.ts +++ b/apps/edr-freight-api/src/modules/chat/matrix.client.ts @@ -1,4 +1,5 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import type { OnApplicationBootstrap } from '@nestjs/common'; import type { ConfigType } from '@nestjs/config'; import chatConfig from '../../config/chat.config'; @@ -40,8 +41,24 @@ export function chatLocalpart(userId: string, displayName: string): string { return `${slug || 'user'}.${userId.replace(/-/g, '').slice(0, 6)}`; } +/** Result of {@link MatrixClient.verifyServerAdmin}. */ +export interface AdminCheck { + ok: boolean; + /** Who MATRIX_ADMIN_TOKEN belongs to — present whenever the token is valid + * at all, including when it is valid but carries no admin rights. */ + actingAs?: string; + error?: string; +} + @Injectable() -export class MatrixClient { +export class MatrixClient implements OnApplicationBootstrap { + private readonly logger = new Logger(MatrixClient.name); + + /** The token is a deploy-time fact and the readiness probe runs every few + * seconds, so {@link adminCheck} memoises for this long. */ + private static readonly ADMIN_CHECK_TTL_MS = 5 * 60_000; + private adminCheckCache?: { at: number; result: AdminCheck }; + constructor( @Inject(chatConfig.KEY) private readonly config: ConfigType, @@ -73,6 +90,11 @@ export class MatrixClient { return this.config.serverName; } + /** MATRIX_ENABLED — read by the readiness probe to tell "off" from "broken". */ + get enabled(): boolean { + return this.config.enabled; + } + private async request( method: string, path: string, @@ -157,6 +179,68 @@ export class MatrixClient { return res.user_id; } + /** + * Is MATRIX_ADMIN_TOKEN actually a *server admin* token? + * + * `whoami` cannot answer this: it returns 200 for any valid user token at + * all. Dev shipped with MATRIX_ADMIN_TOKEN holding an ordinary staff + * account's token — whoami said 200, every `/_synapse/admin/*` call said + * 403 "You are not a server admin", `ensureUser` threw, ChatSsoService + * swallowed it (by design — a failed room join must not deny anyone a + * sign-in link), and every employee got a working sign-in into a client + * with no rooms in it. Nothing else in the system noticed. + * + * So this pings an endpoint only a server admin may call, and reports who + * the token belongs to — the one fact that makes the mix-up obvious. + */ + async verifyServerAdmin(): Promise { + let actingAs: string | undefined; + try { + actingAs = await this.whoami(); + await this.request('GET', '/_synapse/admin/v2/users?limit=1'); + return { ok: true, actingAs }; + } catch (err) { + return { ok: false, actingAs, error: (err as Error).message }; + } + } + + /** {@link verifyServerAdmin}, memoised for {@link ADMIN_CHECK_TTL_MS}. */ + async adminCheck(force = false): Promise { + const cached = this.adminCheckCache; + if ( + !force && + cached && + Date.now() - cached.at < MatrixClient.ADMIN_CHECK_TTL_MS + ) { + return cached.result; + } + const result = await this.verifyServerAdmin(); + this.adminCheckCache = { at: Date.now(), result }; + return result; + } + + /** + * Fail loud at boot instead of silently on every sign-in. Logged, never + * thrown: chat provisioning must not be able to stop the API from starting, + * the same contract the reconcile cron and the notification bridge hold to. + */ + async onApplicationBootstrap(): Promise { + if (!this.config.enabled) return; + const check = await this.adminCheck(true); + if (check.ok) { + this.logger.log( + `MATRIX_ADMIN_TOKEN verified — server admin as ${check.actingAs}`, + ); + return; + } + this.logger.error( + 'MATRIX_ADMIN_TOKEN is not a server-admin token' + + (check.actingAs ? ` (it belongs to ${check.actingAs})` : '') + + `: ${check.error}. Chat provisioning will create no rooms, and every ` + + 'employee who opens chat will land in an empty Element.', + ); + } + /** Currently-joined user ids for a room (not full member-event state). */ async joinedMembers(roomId: string): Promise { const res = await this.request<{ joined: Record }>( diff --git a/apps/edr-freight-api/src/modules/health/health.controller.spec.ts b/apps/edr-freight-api/src/modules/health/health.controller.spec.ts new file mode 100644 index 000000000..26c873567 --- /dev/null +++ b/apps/edr-freight-api/src/modules/health/health.controller.spec.ts @@ -0,0 +1,100 @@ +import 'reflect-metadata'; + +import type { Response } from 'express'; +import type { DataSource } from 'typeorm'; + +import type { MatrixClient } from '../chat/matrix.client'; +import type { EmailClientService } from '../notifications/email-client.service'; +import type { SmsClientService } from '../notifications/sms-client.service'; +import { HealthController } from './health.controller'; + +type ReadinessBody = { + status: string; + checks: { + chat: { status: string; enabled: boolean; actingAs?: string; error?: string }; + }; +}; + +/** Captures what the controller wrote, in place of an express Response. */ +function recorder() { + const sent: { code?: number; body?: ReadinessBody } = {}; + const res = { + status(code: number) { + sent.code = code; + return this; + }, + json(body: ReadinessBody) { + sent.body = body; + return this; + }, + }; + return { sent, res: res as unknown as Response }; +} + +function controllerWith(matrix: Partial) { + const dataSource = { query: jest.fn(async () => [{ '?column?': 1 }]) }; + return new HealthController( + dataSource as unknown as DataSource, + { brokerConnected: true } as unknown as SmsClientService, + { brokerConnected: true } as unknown as EmailClientService, + matrix as MatrixClient, + ); +} + +describe('HealthController readiness — chat check', () => { + it('reports degraded, not 503, when MATRIX_ADMIN_TOKEN is not a server admin', async () => { + // The dev outage. Chat is broken, but chat is not worth pulling the pod + // out of the load balancer for — bookings and billing still work. + const controller = controllerWith({ + enabled: true, + adminCheck: jest.fn(async () => ({ + ok: false, + actingAs: '@super-admin.f15347:matrixdev.edrsc.com', + error: 'Matrix GET /_synapse/admin/v2/users?limit=1 -> 403: not a server admin', + })), + }); + + const { sent, res } = recorder(); + await controller.readiness(res); + + expect(sent.code).toBe(200); + expect(sent.body?.status).toBe('degraded'); + expect(sent.body?.checks.chat.status).toBe('error'); + // The account name is the actionable half — it says *which* token is wired up. + expect(sent.body?.checks.chat.actingAs).toBe( + '@super-admin.f15347:matrixdev.edrsc.com', + ); + }); + + it('reports ok when the token really is a server admin', async () => { + const controller = controllerWith({ + enabled: true, + adminCheck: jest.fn(async () => ({ + ok: true, + actingAs: '@edrbot:matrixdev.edrsc.com', + })), + }); + + const { sent, res } = recorder(); + await controller.readiness(res); + + expect(sent.body?.status).toBe('ok'); + expect(sent.body?.checks.chat).toMatchObject({ + status: 'ok', + enabled: true, + actingAs: '@edrbot:matrixdev.edrsc.com', + }); + }); + + it('does not call Synapse, or degrade, when chat is switched off', async () => { + const adminCheck = jest.fn(); + const controller = controllerWith({ enabled: false, adminCheck }); + + const { sent, res } = recorder(); + await controller.readiness(res); + + expect(adminCheck).not.toHaveBeenCalled(); + expect(sent.body?.status).toBe('ok'); + expect(sent.body?.checks.chat).toEqual({ status: 'unknown', enabled: false }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/health/health.controller.ts b/apps/edr-freight-api/src/modules/health/health.controller.ts index 6b559f2e3..94b8eb698 100644 --- a/apps/edr-freight-api/src/modules/health/health.controller.ts +++ b/apps/edr-freight-api/src/modules/health/health.controller.ts @@ -7,6 +7,7 @@ import { Public } from "@edr/api-common"; import { Response } from "express"; import { DataSource } from "typeorm"; +import { MatrixClient } from "../chat/matrix.client"; import { EmailClientService } from "../notifications/email-client.service"; import { SmsClientService } from "../notifications/sms-client.service"; @@ -32,6 +33,7 @@ export class HealthController { private readonly dataSource: DataSource, private readonly smsClient: SmsClientService, private readonly emailClient: EmailClientService, + private readonly matrix: MatrixClient, ) {} @Get() @@ -45,7 +47,7 @@ export class HealthController { @Public() @ApiOperation({ summary: - "Readiness probe — database plus SMS/email broker connectivity. Broker failures report as degraded unless READINESS_REQUIRES_BROKER=true.", + "Readiness probe — database, SMS/email broker connectivity, and the Matrix admin token. Broker failures report as degraded unless READINESS_REQUIRES_BROKER=true; chat failures always report as degraded.", }) async readiness(@Res() res: Response) { const startedAt = Date.now(); @@ -76,23 +78,55 @@ export class HealthController { enabled: process.env.RABBITMQ_ENABLED !== "false", }; + const chat = await this.chatCheck(); + const brokerDown = broker.sms.status === "error" || broker.email.status === "error"; const failed = database.status === "error" || (READINESS_REQUIRES_BROKER && brokerDown); - const status = failed ? "error" : brokerDown ? "degraded" : "ok"; + const status = failed + ? "error" + : brokerDown || chat.status === "error" + ? "degraded" + : "ok"; return res .status(failed ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.OK) .json({ status, timestamp: new Date().toISOString(), - checks: { database, broker }, + checks: { database, broker, chat }, }); } + /** + * Chat provisioning runs entirely on MATRIX_ADMIN_TOKEN, and a token that is + * valid but not *server admin* fails only the `/_synapse/admin` half: rooms + * are never created, joins never happen, and the sole symptom is an empty + * Element for every employee. Nothing else in the probe would catch that. + * + * Degraded, never a 503 — chat is not worth pulling the pod out of the load + * balancer for, by the same reasoning as the broker check above. `unknown` + * when MATRIX_ENABLED is off: a feature that is switched off is not a fault. + */ + private async chatCheck(): Promise<{ + status: CheckStatus; + enabled: boolean; + actingAs?: string; + error?: string; + }> { + if (!this.matrix.enabled) return { status: "unknown", enabled: false }; + const check = await this.matrix.adminCheck(); + return { + status: check.ok ? "ok" : "error", + enabled: true, + actingAs: check.actingAs, + error: check.error, + }; + } + @Get("info") @Public() @ApiOperation({ summary: "App info — version, environment, uptime" }) diff --git a/apps/edr-freight-api/src/modules/health/health.module.ts b/apps/edr-freight-api/src/modules/health/health.module.ts index 572e5eb86..1aa74f56f 100644 --- a/apps/edr-freight-api/src/modules/health/health.module.ts +++ b/apps/edr-freight-api/src/modules/health/health.module.ts @@ -2,13 +2,15 @@ import { Module } from "@nestjs/common"; +import { ChatModule } from "../chat/chat.module"; import { HealthController } from "./health.controller"; import { NotificationsModule } from "../notifications/notifications.module"; @Module({ // NotificationsModule exports the SMS/email clients; the readiness probe reads // their broker connection state rather than opening a second connection. - imports: [NotificationsModule], + // ChatModule exports MatrixClient for the MATRIX_ADMIN_TOKEN check. + imports: [NotificationsModule, ChatModule], controllers: [HealthController], }) export class HealthModule {} From b5ad46f317e253b750d57bf92216d615c09ded19 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 31 Aug 2026 13:56:39 +0000 Subject: [PATCH 05/28] feat: add Transit Clearance Action Panel for managing delivery and release orders - Implemented TransitClearanceActionPanel component for transit agents to handle DO/RO uploads and amendments. - Added file picker for uploading documents with validation for vessel arrival and collection dates. - Introduced modals for uploading T1 documents and requesting RO amendments. - Enhanced transit assignments service with new API endpoints for clearance history, GL exchange documents, and incident reports. - Updated types to include new document upload sources and statuses. - Created CSS styles for transit bookings table to improve layout and responsiveness. - Exported new TransitAgentBookingDetailPage for detailed booking views. --- .../modules/bookings/bookings.controller.ts | 96 +- .../src/modules/bookings/bookings.service.ts | 59 + .../booking-clearance.service.spec.ts | 1 + .../contracts/booking-clearance.service.ts | 13 + .../contracts/contract-clearance.service.ts | 14 + .../contracts/contract-duty-dispute.spec.ts | 1 + .../modules/contracts/contracts.controller.ts | 19 +- .../src/modules/contracts/contracts.module.ts | 5 + .../contracts/gl-exchange.controller.ts | 72 +- .../modules/contracts/gl-exchange.service.ts | 11 +- .../contracts/transit-assignee.spec.ts | 1 + .../transit-assignments.service.ts | 52 + .../components/contracts/GlExchangePanel.tsx | 9 + .../src/pages/ruleEngine/config/resources.ts | 2 + apps/edr-freight-web/portal/src/App.tsx | 5 + .../ClearanceWorkflowFilesPanel.tsx | 166 +++ .../TransitAgentBookingDetailPage.tsx | 1184 +++++++++++++++++ .../TransitAgentBookingsPage.tsx | 1093 ++++++++++----- .../TransitClearanceActionPanel.tsx | 829 ++++++++++++ .../portal/src/pages/transit-agent/index.ts | 1 + .../transit-agent/transit-bookings-table.css | 150 +++ .../services/transit-assignments.service.ts | 119 ++ packages/types/src/freight/index.ts | 7 +- 23 files changed, 3524 insertions(+), 385 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/transit-agent/ClearanceWorkflowFilesPanel.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingDetailPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/transit-agent/TransitClearanceActionPanel.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/transit-agent/transit-bookings-table.css 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. + +