From 3fd94c931463759c43903fbea590ac69c6e7fe62 Mon Sep 17 00:00:00 2001 From: marshal Date: Wed, 2 Sep 2026 23:36:35 +0000 Subject: [PATCH] feat(transit-agent): timed document uploads and a real-data overview --- .../modules/bookings/bookings.controller.ts | 55 + .../contracts/booking-clearance.service.ts | 86 +- .../contracts/contract-clearance.service.ts | 1 + .../modules/contracts/contracts.controller.ts | 6 +- .../contracts/final-invoice-approval.spec.ts | 1 + .../contracts/gl-operations.service.ts | 30 +- .../contracts/phased-clearance.util.ts | 128 +- .../transit-assignments.module.ts | 12 +- .../transit-assignments.service.spec.ts | 278 +-- .../transit-assignments.service.ts | 447 ++++- .../contracts/PhasedClearanceActionPanel.tsx | 2 +- .../contracts/PortalMultiFileDropzone.tsx | 248 +++ .../TransitAgentBookingDetailPage.tsx | 52 +- .../TransitAgentBookingsPage.tsx | 46 +- .../TransitAgentOverviewPage.tsx | 1150 +++++++---- .../TransitClearanceActionPanel.tsx | 413 +--- .../transit-agent/TransitDocumentsPanel.tsx | 1736 +++++++++++++++++ .../services/transit-assignments.service.ts | 132 +- .../src/freight/clearance-files.catalog.ts | 44 +- packages/types/src/freight/index.ts | 2 + 20 files changed, 3717 insertions(+), 1152 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/components/contracts/PortalMultiFileDropzone.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/transit-agent/TransitDocumentsPanel.tsx 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 2d38592fb..381510e7e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -1914,6 +1914,61 @@ export class BookingsController { }; } + // Transit-agent arrival paperwork (export): gate pass and Djibouti T1 sets. + // Same audience rule as the DO/RO uploads above — the desk, or the agent + // assigned to this shipment. + @Post(":id/clearance/gate-pass-documents") + @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes("multipart/form-data") + async uploadBookingGatePassDocuments( + @Param("id", ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + await this.assertPortalClearanceAccess(id, user); + return this.bookingClearanceService.uploadTransitArrivalDocuments( + id, + "gate_pass", + files ?? [], + resolveAuthUserId(user), + ); + } + + @Post(":id/clearance/djibouti-t1-documents") + @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes("multipart/form-data") + async uploadBookingDjiboutiT1Documents( + @Param("id", ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + await this.assertPortalClearanceAccess(id, user); + return this.bookingClearanceService.uploadTransitArrivalDocuments( + id, + "djibouti_t1", + files ?? [], + resolveAuthUserId(user), + ); + } + + @Delete(":id/clearance/transit-documents/:fileId") + @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) + @HttpCode(204) + async removeBookingTransitDocument( + @Param("id", ParseUUIDPipe) id: string, + @Param("fileId", ParseUUIDPipe) fileId: string, + @CurrentUser() user: TCurrentUser, + ) { + await this.assertPortalClearanceAccess(id, user); + await this.bookingClearanceService.removeTransitArrivalDocument( + id, + fileId, + resolveAuthUserId(user), + ); + } + @Post(":id/clearance/ro-amendment") @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) async requestBookingRoAmendment( 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 f21f2218b..b776a547c 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { In } from 'typeorm'; import { ContractDocPhase, @@ -34,7 +34,8 @@ import { TransitAssignmentsService } from '../transit-assignments/transit-assign import { YardScopeService } from '../rule-engine/services/yard-scope.service'; import { ContractsRepository } from './contracts.repository'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; -import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitArrivalUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES, transitArrivalDocumentMatcher } from './phased-clearance.util'; +import type { TransitArrivalDocumentKind } from '@edr/types'; import { buildClearanceDocHistory, @@ -46,6 +47,8 @@ import { clearanceDocumentsOpen } from '../bookings/clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; export interface BookingClearanceView { + /** Booking creation stamp — the import DO is timed from it. */ + bookingCreatedAt?: string | null; bookingId: string; status: string; includesCustoms: boolean; @@ -367,6 +370,7 @@ export class BookingClearanceService { return { bookingId, status: booking.status, + bookingCreatedAt: booking.createdAt ? new Date(booking.createdAt).toISOString() : null, includesCustoms, inputCode, outputCode, @@ -387,6 +391,7 @@ export class BookingClearanceService { status: m.status, ownerRegion: m.ownerRegion, metadata: (m.metadata ?? null) as Record | null, + triggeredAt: m.triggeredAt ? new Date(m.triggeredAt).toISOString() : null, sortOrder: m.sortOrder, })), nextAction, @@ -1174,6 +1179,83 @@ export class BookingClearanceService { return { booking: await this.bookingsService.findById(bookingId), hold: false }; } + // ── Transit-agent arrival paperwork (export) ──────────────────────────── + // Gate pass and Djibouti T1 documents the assigned transit officer files at + // Djibouti around train arrival. Append-only sets with per-file removal — see + // `persistTransitArrivalUploads`. The clearance view stamps every file with + // its upload time, so the portal can measure it against train departure and + // arrival without a separate ledger. + + private static readonly TRANSIT_ARRIVAL_LABELS: Record< + TransitArrivalDocumentKind, + { name: string; uploaded: string; removed: string } + > = { + gate_pass: { + name: 'gate pass', + uploaded: 'GATE_PASS_DOCUMENTS_UPLOADED', + removed: 'GATE_PASS_DOCUMENT_REMOVED', + }, + djibouti_t1: { + name: 'Djibouti T1', + uploaded: 'DJIBOUTI_T1_DOCUMENTS_UPLOADED', + removed: 'DJIBOUTI_T1_DOCUMENT_REMOVED', + }, + }; + + async uploadTransitArrivalDocuments( + bookingId: string, + kind: TransitArrivalDocumentKind, + files: Express.Multer.File[], + userId?: string, + ): Promise<{ uploaded: number }> { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException( + 'Gate pass and Djibouti T1 documents apply only to export bookings.', + ); + } + const labels = BookingClearanceService.TRANSIT_ARRIVAL_LABELS[kind]; + await persistTransitArrivalUploads(this.filesService, bookingId, kind, files ?? [], userId); + await this.clearanceEvents.record({ + bookingId, + action: labels.uploaded, + label: `Uploaded ${files.length} ${labels.name} document(s)`, + actorId: userId ?? null, + metadata: { kind, fileNames: (files ?? []).map((f) => f.originalname) }, + }); + return { uploaded: files.length }; + } + + /** + * Remove ONE gate pass / Djibouti T1 file. Only those two code families are + * removable here: the route is reachable by the transit agent, and it must + * never become a way to delete a declaration or a Release Order. + */ + async removeTransitArrivalDocument( + bookingId: string, + fileId: string, + userId?: string, + ): Promise { + await this.loadBooking(bookingId); + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const file = files.find((f) => f.id === fileId); + const kind = (['gate_pass', 'djibouti_t1'] as const).find((k) => + transitArrivalDocumentMatcher(k)(file?.code), + ); + if (!file || !kind) { + throw new NotFoundException('Document not found on this booking.'); + } + await this.filesService.remove(fileId); + const labels = BookingClearanceService.TRANSIT_ARRIVAL_LABELS[kind]; + await this.clearanceEvents.record({ + bookingId, + action: labels.removed, + label: `Removed ${labels.name} document ${file.name}`, + actorId: userId ?? null, + metadata: { kind, fileName: file.name }, + }); + } + async requestRoAmendment( bookingId: string, note?: string, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index e7b3d42f6..c15d20800 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -482,6 +482,7 @@ export class ContractClearanceService { status: m.status, ownerRegion: m.ownerRegion, metadata: (m.metadata ?? null) as Record | null, + triggeredAt: m.triggeredAt ? new Date(m.triggeredAt).toISOString() : null, sortOrder: m.sortOrder, })), nextAction, 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 9c91c629e..7cf7a8398 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -1384,7 +1384,11 @@ export class ContractsController { ) { throw new NotFoundException(`Booking ${bookingId} not found`); } - return this.glOperationsService.uploadT1Documents(bookingId, files ?? []); + return this.glOperationsService.uploadT1Documents( + bookingId, + files ?? [], + resolveAuthUserId(user), + ); } @Post('bookings/:bookingId/t1-close') diff --git a/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts b/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts index 4875d9e24..25f798e68 100644 --- a/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts @@ -50,6 +50,7 @@ describe('GlOperationsService — final invoice approval', () => { {} as never, // milestoneService billingService as never, notifier as never, + { record: jest.fn() } as never, // clearanceEvents ); }); diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 6516ff9ce..70b42a9aa 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -12,6 +12,7 @@ import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { FilesService } from '../files/files.service'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; +import { ClearanceEventService } from '../bookings/clearance-event.service'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity'; import { @@ -55,6 +56,7 @@ export class GlOperationsService { private readonly milestoneService: ClearanceMilestoneService, private readonly billingService: BillingService, private readonly notifier: BookingLifecycleNotifierService, + private readonly clearanceEvents: ClearanceEventService, ) {} private get bookings() { @@ -362,13 +364,15 @@ export class GlOperationsService { } /** - * GL Djibouti uploads T1 transport documents (multi-file) once the gate pass - * is secured on the train schedule (which itself follows wagon allocation). - * Replaces the previous batch; locked only once GL Ethiopia closes the T1. + * GL Djibouti / the transit agent uploads T1 transport documents (multi-file) + * once the train has DEPARTED Djibouti. Replaces the previous batch, so the + * batch's file stamps are always the last update; locked only once GL + * Ethiopia closes the T1. */ async uploadT1Documents( bookingId: string, files: Express.Multer.File[], + userId?: string, ): Promise<{ uploaded: number }> { const booking = await this.getBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { @@ -376,24 +380,24 @@ export class GlOperationsService { } const state = await this.t1State(bookingId); - if (!state.wagonAllocated) { + if (!state.trainDepartedAt) { throw new BadRequestException( - 'Wagons must be allocated before T1 transport documents can be uploaded.', - ); - } - const gatepass = await this.gatepassForBooking(bookingId); - if (!gatepass.granted) { - throw new BadRequestException( - 'Secure the Djibouti gate pass on the train schedule before uploading T1 transport documents.', + 'T1 transport documents can be uploaded once the train has departed.', ); } if (state.closed) { throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.'); } - // Departure no longer locks T1 docs — GL DJ may replace them any time until - // GL Ethiopia closes/accepts the T1. await persistT1TransportUploads(this.filesService, bookingId, files); + // History row so the portal can tell a first upload from a replacement. + await this.clearanceEvents.record({ + bookingId, + action: 'T1_DOCUMENTS_UPLOADED', + label: `Uploaded T1 transport documents (${files.length} file(s))`, + actorId: userId ?? null, + metadata: { fileNames: files.map((f) => f.originalname) }, + }); return { uploaded: files.length }; } diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts index ab2a2b2d4..05edb9cab 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -12,10 +12,17 @@ import { isImportTransitPermitFileCode, isExportTransportFileCode, isT1TransportFileCode, + isGatePassFileCode, + isDjiboutiT1FileCode, exportTransportFileLabel, t1TransportFileLabel, transitPermitFileLabel, + gatePassFileLabel, + djiboutiT1FileLabel, + GATE_PASS_FILE_PREFIX, + DJIBOUTI_T1_FILE_PREFIX, type ClearanceWorkflowFile, + type TransitArrivalDocumentKind, } from '@edr/types'; /** Require at least one declaration file in the upload batch. */ @@ -46,6 +53,7 @@ type DeclarationFileStore = { resource: string; code: string; file: Express.Multer.File; + uploadedByUserId?: string | null; }): Promise; }; @@ -445,9 +453,88 @@ export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [ /** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */ export const DJ_BOOKING_QUEUE_STATUSES = PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES; +/** Prefix + matcher + label for each transit-agent arrival document set. */ +const TRANSIT_ARRIVAL_DOCUMENT_SETS: Record< + TransitArrivalDocumentKind, + { prefix: string; matches: (code: string | null | undefined) => boolean; label: (i?: number) => string } +> = { + gate_pass: { prefix: GATE_PASS_FILE_PREFIX, matches: isGatePassFileCode, label: gatePassFileLabel }, + djibouti_t1: { prefix: DJIBOUTI_T1_FILE_PREFIX, matches: isDjiboutiT1FileCode, label: djiboutiT1FileLabel }, +}; + +export function transitArrivalDocumentMatcher( + kind: TransitArrivalDocumentKind, +): (code: string | null | undefined) => boolean { + return TRANSIT_ARRIVAL_DOCUMENT_SETS[kind].matches; +} + +/** + * APPEND a batch of transit-agent arrival documents (gate pass / Djibouti T1) + * to a booking. Unlike the DO/RO persisters this never deletes what is already + * there: the officer collects these one at a time as the paperwork comes in, + * and each file is removed individually. Codes continue from the highest + * existing index so a removed file's slot is never reused. + */ +export async function persistTransitArrivalUploads( + store: DeclarationFileStore, + bookingId: string, + kind: TransitArrivalDocumentKind, + files: Express.Multer.File[], + uploadedByUserId?: string | null, +): Promise { + if (files.length === 0) { + throw new BadRequestException('No documents uploaded'); + } + const set = TRANSIT_ARRIVAL_DOCUMENT_SETS[kind]; + const existing = await store.findByResource(bookingId, 'bookings'); + const nextIndex = + existing + .filter((f) => set.matches(f.code)) + .map((f) => Number.parseInt((f.code ?? '').slice(set.prefix.length), 10)) + .filter((n) => Number.isFinite(n)) + .reduce((max, n) => Math.max(max, n + 1), 0); + + await Promise.all( + files.map((file, index) => + store.upload({ + resourceId: bookingId, + resource: 'bookings', + code: `${set.prefix}${nextIndex + index}`, + file: { ...file, fieldname: `${set.prefix}${nextIndex + index}` }, + uploadedByUserId: uploadedByUserId ?? null, + }), + ), + ); +} + +type WorkflowFileInput = { + code?: string | null; + id: string; + name: string; + url: string; + createdAt?: Date | string | null; + updatedAt?: Date | string | null; + size?: number | null; + mimeType?: string | null; +}; + +function toWorkflowFileRef(file: WorkflowFileInput): NonNullable { + const iso = (v: Date | string | null | undefined) => + v ? new Date(v).toISOString() : null; + return { + id: file.id, + name: file.name, + url: file.url, + uploadedAt: iso(file.createdAt), + updatedAt: iso(file.updatedAt), + size: file.size ?? null, + mimeType: file.mimeType ?? null, + }; +} + /** Build labeled phased-customs file rows from resource files. */ export function buildWorkflowFiles( - files: Array<{ code?: string | null; id: string; name: string; url: string }>, + files: WorkflowFileInput[], tradeDirection: string, ): ClearanceWorkflowFile[] { const fileByCode = new Map( @@ -465,7 +552,7 @@ export function buildWorkflowFiles( label: entry.label, uploadedBy: entry.uploadedBy, category: entry.category, - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); } @@ -481,7 +568,7 @@ export function buildWorkflowFiles( label: declarationFileLabel(file.code, index), uploadedBy: 'gl_et', category: 'declaration', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); @@ -497,7 +584,7 @@ export function buildWorkflowFiles( label: draftDeclarationFileLabel(index), uploadedBy: 'gl_et', category: 'draft_declaration', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); @@ -514,7 +601,7 @@ export function buildWorkflowFiles( label: transitPermitFileLabel(file.code, index), uploadedBy: 'gl_et', category: 'transit', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); @@ -530,7 +617,7 @@ export function buildWorkflowFiles( label: deliveryOrderFileLabel(file.code, index), uploadedBy: 'gl_dj', category: 'djibouti', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); @@ -546,7 +633,7 @@ export function buildWorkflowFiles( label: t1TransportFileLabel(file.code, index), uploadedBy: 'gl_dj', category: 'djibouti', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); } @@ -564,7 +651,7 @@ export function buildWorkflowFiles( label: releaseOrderFileLabel(file.code, index), uploadedBy: 'gl_dj', category: 'djibouti', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); @@ -580,9 +667,32 @@ export function buildWorkflowFiles( label: exportTransportFileLabel(file.code, index), uploadedBy: 'gl_et', category: 'transit', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); + + // Transit-agent arrival paperwork, ordered by slot index (upload order). + const byIndex = (prefix: string) => (a: WorkflowFileInput, b: WorkflowFileInput) => + Number.parseInt((a.code ?? '').slice(prefix.length), 10) - + Number.parseInt((b.code ?? '').slice(prefix.length), 10); + + for (const kind of ['gate_pass', 'djibouti_t1'] as const) { + const set = TRANSIT_ARRIVAL_DOCUMENT_SETS[kind]; + files + .filter((f) => f.code && set.matches(f.code) && !included.has(f.code)) + .sort(byIndex(set.prefix)) + .forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: set.label(index), + uploadedBy: 'gl_dj', + category: 'djibouti', + file: toWorkflowFileRef(file), + }); + }); + } } return out; diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts index 65edc5868..44862e2ff 100644 --- a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts @@ -2,7 +2,9 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { Booking } from "../bookings/entities/booking.entity"; +import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity"; import { FilesModule } from "../files/files.module"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; import { TransitAgentsModule } from "../transit-agents/transit-agents.module"; import { TransitAssignment } from "./entities/transit-assignment.entity"; import { TransitAssignmentsController } from "./transit-assignments.controller"; @@ -14,7 +16,15 @@ import { TransitAssignmentsService } from "./transit-assignments.service"; // `Booking` is registered as an ENTITY rather than importing BookingsModule: // this module only confirms a booking id exists, and that module would drag // its whole graph (billing, contracts, scheduling, first/last mile) along. - TypeOrmModule.forFeature([TransitAssignment, Booking]), + // Milestones and train schedules are read for the agent's dashboard + // timings (declaration stamps, departure/arrival fallbacks) — entities + // only, for the same reason as Booking. + TypeOrmModule.forFeature([ + TransitAssignment, + Booking, + ClearanceMilestone, + TrainSchedule, + ]), FilesModule, TransitAgentsModule, ], diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts index f23a8426a..68e856eff 100644 --- a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts @@ -38,6 +38,8 @@ describe("TransitAssignmentsService", () => { remove: jest.Mock; }; let service: TransitAssignmentsService; + let milestones: { find: jest.Mock }; + let trainSchedules: { find: jest.Mock }; const row = (over: Partial = {}) => ({ @@ -81,11 +83,16 @@ describe("TransitAssignmentsService", () => { remove: jest.fn(), }; + milestones = { find: jest.fn().mockResolvedValue([]) }; + trainSchedules = { find: jest.fn().mockResolvedValue([]) }; + service = new TransitAssignmentsService( assignments as never, agents as never, bookings as never, files as never, + milestones as never, + trainSchedules as never, ); }); @@ -294,222 +301,127 @@ describe("TransitAssignmentsService", () => { describe("myStats", () => { const at = (iso: string) => new Date(iso); + const DEPARTED = at("2026-08-27T20:00:00Z"); const withRows = (rows: Record[]) => { assignments.findByTransitAgent.mockResolvedValue( - rows.map((r, i) => row({ id: `ta-${i}`, ...r } as never)), + rows.map((r, i) => row({ id: `ta-${i}`, bookingId: `bk-${i}`, ...r } as never)), + ); + }; + const bookingFiles = (entries: Record>) => { + files.findByResourceIdsGrouped.mockImplementation( + async (_ids: string[], resource: string) => + resource === "bookings" + ? new Map( + Object.entries(entries).map(([bookingId, list]) => [ + bookingId, + list.map(([code, iso]) => ({ code, createdAt: at(iso) })), + ]), + ) + : new Map(), ); - files.findByResourceIdsGrouped.mockResolvedValue(new Map()); }; - it("uses the median, so one reopened assignment cannot skew the headline", async () => { + it("measures transit from the train's departure to its arrival, using the median", async () => { withRows([ - { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-28T10:35:00Z"), - }, - { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-28T12:10:00Z"), - }, - { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-28T13:45:00Z"), - }, - // 47h outlier: a mean would report ~12h, which describes nobody. - { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-30T08:00:00Z"), - }, + { booking: { loadedAt: DEPARTED, arrivedAt: at("2026-08-28T06:00:00Z"), tradeDirection: "EXPORT" } }, + { booking: { loadedAt: DEPARTED, arrivedAt: at("2026-08-28T08:00:00Z"), tradeDirection: "EXPORT" } }, + // 3-day outlier: a mean would describe none of the three. + { booking: { loadedAt: DEPARTED, arrivedAt: at("2026-08-30T20:00:00Z"), tradeDirection: "EXPORT" } }, + // Still rolling: contributes nothing, not zero. + { booking: { loadedAt: DEPARTED, arrivedAt: null, tradeDirection: "EXPORT" } }, ]); + bookingFiles({}); const stats = await service.myStats("user-1"); - // 95/190/285/2820 -> even count, so the median averages the middle two. - // A mean would be 848 minutes, describing none of the four. - expect(stats.performance.medianClearanceMinutes).toBe(238); - expect(stats.performance.slowestClearanceMinutes).toBe(2820); + expect(stats.timings.transit).toEqual({ + median: 720, + fastest: 600, + slowest: 4320, + measured: 3, + }); + expect(stats.totals.inTransit).toBe(1); + expect(stats.totals.arrived).toBe(3); }); - it("bands clearance times into the SLA buckets", async () => { - withRows([ + it("times the Release Order from the declaration to the LAST RO upload", async () => { + withRows([{ booking: { tradeDirection: "EXPORT", loadedAt: null, arrivedAt: null } }]); + milestones.find.mockResolvedValue([ { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-28T10:30:00Z"), - }, - { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-28T13:00:00Z"), - }, - { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-29T09:00:00Z"), + bookingId: "bk-0", + milestoneCode: "DECLARED", + status: "COMPLETED", + triggeredAt: at("2026-08-27T08:00:00Z"), }, ]); + bookingFiles({ + "bk-0": [ + ["release_order_0", "2026-08-27T09:30:00Z"], + // Replaced batch — the later stamp is the one that counts. + ["release_order_1", "2026-08-27T11:00:00Z"], + ], + }); const stats = await service.myStats("user-1"); + const [item] = stats.items; - expect(stats.sla).toEqual({ under2h: 1, under6h: 1, over6h: 1 }); - expect(stats.performance.onTimeRate).toBe(67); + expect(item.declaredAt).toBe("2026-08-27T08:00:00.000Z"); + expect(item.roAt).toBe("2026-08-27T11:00:00.000Z"); + expect(item.timings.declarationToRo).toBe(180); + expect(stats.timings.declarationToRo.median).toBe(180); + expect(item.nextAction).toEqual({ kind: "wait", label: "Awaiting train departure" }); }); - it("counts coverage only over dispatched bookings", async () => { + it("points the officer at the next upload the detail page would actually allow", async () => { withRows([ - { booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } }, - { booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } }, - // Scheduled bookings cannot receive documents yet, so counting them - // would report a failure the agent could not have avoided. - { booking: { arrivedAt: null, schedulingStatus: "SCHEDULED" } }, + // Import, nothing filed: the DO comes first. + { booking: { tradeDirection: "IMPORT", loadedAt: null, arrivedAt: null } }, + // Import with a DO but no departure yet: T1 is still locked. + { booking: { tradeDirection: "IMPORT", loadedAt: null, arrivedAt: null } }, + // Import, departed, no T1: upload it. + { booking: { tradeDirection: "IMPORT", loadedAt: DEPARTED, arrivedAt: null } }, + // Export, arrived with an RO but no gate pass yet. + { + booking: { + tradeDirection: "EXPORT", + loadedAt: DEPARTED, + arrivedAt: at("2026-08-28T06:00:00Z"), + }, + }, ]); + milestones.find.mockResolvedValue([ + { bookingId: "bk-3", milestoneCode: "DECLARED", status: "COMPLETED", triggeredAt: at("2026-08-26T08:00:00Z") }, + ]); + bookingFiles({ + "bk-1": [["delivery_order_0", "2026-08-26T10:00:00Z"]], + "bk-2": [["delivery_order_0", "2026-08-26T10:00:00Z"]], + "bk-3": [["release_order_0", "2026-08-26T10:00:00Z"]], + }); const stats = await service.myStats("user-1"); + const byBooking = new Map(stats.items.map((i) => [i.bookingId, i])); - expect(stats.coverage.dispatched).toBe(2); - expect(stats.coverage.withDocuments).toBe(0); + expect(byBooking.get("bk-0")?.nextAction.document).toBe("do"); + expect(byBooking.get("bk-1")?.nextAction).toEqual({ + kind: "wait", + label: "Awaiting train departure", + }); + expect(byBooking.get("bk-2")?.nextAction.document).toBe("t1"); + expect(byBooking.get("bk-3")?.nextAction.document).toBe("gate_pass"); + expect(stats.pending).toEqual({ ro: 0, do: 1, t1: 1, gatePass: 1, djiboutiT1: 0 }); + expect(stats.totals.actionNeeded).toBe(3); }); it("reports nulls rather than zero when nothing has been measured", async () => { - withRows([{ status: TransitAssignmentStatus.NotStarted }]); + withRows([{ status: TransitAssignmentStatus.NotStarted, booking: { arrivedAt: null } }]); + bookingFiles({}); const stats = await service.myStats("user-1"); - expect(stats.performance.medianClearanceMinutes).toBeNull(); - expect(stats.performance.onTimeRate).toBeNull(); + expect(stats.timings.transit.median).toBeNull(); + expect(stats.timings.arrivalToFinish.median).toBeNull(); expect(stats.totals.open).toBe(1); }); }); - - describe("customerName", () => { - it("flattens the booking's company name", async () => { - assignments.findOneWithRelations.mockResolvedValue( - row({ - booking: { - id: "bk-1", - arrivedAt: ARRIVED, - schedulingStatus: "DISPATCHED", - company: { name: "SHAFICI PHARMACEUTICAL" }, - } as never, - }), - ); - - expect((await service.findById("ta-1")).customerName).toBe( - "SHAFICI PHARMACEUTICAL", - ); - }); - - it("is null when the booking has no company", async () => { - expect((await service.findById("ta-1")).customerName).toBeNull(); - }); - }); - - describe("canUploadDocuments", () => { - it("is true for an open assignment on a dispatched booking", async () => { - expect((await service.findById("ta-1")).canUploadDocuments).toBe(true); - }); - - it("is false before dispatch", async () => { - assignments.findOneWithRelations.mockResolvedValue( - row({ - booking: { - id: "bk-1", - arrivedAt: null, - schedulingStatus: "SCHEDULED", - } as never, - }), - ); - expect((await service.findById("ta-1")).canUploadDocuments).toBe(false); - }); - - it("is false once finished", async () => { - assignments.findOneWithRelations.mockResolvedValue( - row({ - status: TransitAssignmentStatus.Finished, - finishedAt: new Date(), - }), - ); - expect((await service.findById("ta-1")).canUploadDocuments).toBe(false); - }); - }); - - describe("portal scoping", () => { - it("hides another agent's assignment behind a NotFound", async () => { - assignments.findOneWithRelations.mockResolvedValue( - row({ transitAgentId: "someone-else" }), - ); - - await expect(service.findMineById("user-1", "ta-1")).rejects.toThrow( - NotFoundException, - ); - }); - - it("rejects an account that is not a transit agent", async () => { - agents.findByUserId.mockResolvedValue(null); - - await expect(service.findMine("user-1")).rejects.toThrow( - ForbiddenException, - ); - }); - - it("pins the query to the session's agent and passes the filters through", async () => { - await service.findMine("user-1", { - search: "BK-2026", - status: TransitAssignmentStatus.InProgress, - schedulingStatus: "DISPATCHED", - page: 2, - pageSize: 10, - }); - - const [agentId, filter, skip, take] = - assignments.findByTransitAgentPaginated.mock.calls[0]; - // The agent id comes from the session, never from the query — otherwise - // one agent could page through another agent's work. - expect(agentId).toBe("ag-1"); - expect(filter).toMatchObject({ - search: "BK-2026", - status: TransitAssignmentStatus.InProgress, - schedulingStatus: "DISPATCHED", - }); - expect(skip).toBe(10); - expect(take).toBe(10); - }); - - it("reports pagination meta", async () => { - assignments.findByTransitAgentPaginated.mockResolvedValue([[], 45]); - - const result = await service.findMine("user-1", { pageSize: 20 }); - - expect(result.meta).toEqual({ - total: 45, - page: 1, - pageSize: 20, - totalPages: 3, - }); - }); - - it("save moves the assignment to IN_PROGRESS, finish closes it", async () => { - await service.submitMine("user-1", "ta-1", { finish: false }); - expect(assignments.update.mock.calls[0][1].status).toBe( - TransitAssignmentStatus.InProgress, - ); - - assignments.update.mockClear(); - await service.submitMine("user-1", "ta-1", { finish: true }); - expect(assignments.update.mock.calls[0][1].status).toBe( - TransitAssignmentStatus.Finished, - ); - }); - - it("refuses to re-submit an already finished assignment", async () => { - assignments.findOneWithRelations.mockResolvedValue( - row({ - status: TransitAssignmentStatus.Finished, - finishedAt: new Date(), - }), - ); - - await expect( - service.submitMine("user-1", "ta-1", { finish: true }), - ).rejects.toThrow(ForbiddenException); - }); - }); }); diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts index 0ce73e28e..80f8d6be9 100644 --- a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts @@ -7,10 +7,19 @@ import { } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { In, Repository } from "typeorm"; +import { + isDeliveryOrderFileCode, + isDjiboutiT1FileCode, + isGatePassFileCode, + isReleaseOrderFileCode, + isT1TransportFileCode, +} from "@edr/types"; import { Booking } from "../bookings/entities/booking.entity"; +import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity"; import { FilesService } from "../files/files.service"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; import { TransitAgentsRepository } from "../transit-agents/transit-agents.repository"; import { FileRecord } from "../files/entities/file.entity"; import { CreateTransitAssignmentDto } from "./dto/create-transit-assignment.dto"; @@ -66,6 +75,86 @@ export type TransitAssignmentView = TransitAssignment & { files?: TransitAssignmentFileView[]; }; +export type TransitTradeDirection = "IMPORT" | "EXPORT"; +export type TransitDocumentKind = "ro" | "do" | "t1" | "gate_pass" | "djibouti_t1"; + +export interface TransitNextAction { + kind: "upload" | "wait" | "done"; + label: string; + document?: TransitDocumentKind; +} + +/** Minutes, or null when nothing has been measured yet — never zero. */ +export interface TransitTimingSummary { + median: number | null; + fastest: number | null; + slowest: number | null; + measured: number; +} + +export interface TransitStatItem { + id: string; + bookingId: string; + reference: string | null; + customerName: string | null; + tradeDirection: TransitTradeDirection; + status: TransitAssignmentStatus; + schedulingStatus: string | null; + trainLabel: string | null; + assignedAt: string; + startedAt: string | null; + finishedAt: string | null; + bookingCreatedAt: string | null; + departedAt: string | null; + arrivedAt: string | null; + declaredAt: string | null; + roAt: string | null; + doAt: string | null; + t1At: string | null; + t1Closed: boolean; + gatePassAt: string | null; + djiboutiT1At: string | null; + documents: { + ro: number; + do: number; + t1: number; + gatePass: number; + djiboutiT1: number; + own: number; + }; + timings: { + transit: number | null; + declarationToRo: number | null; + bookingToDo: number | null; + departureToT1: number | null; + arrivalToT1: number | null; + arrivalToGatePass: number | null; + arrivalToDjiboutiT1: number | null; + arrivalToFinish: number | null; + }; + nextAction: TransitNextAction; +} + +export interface TransitStats { + totals: { + assignments: number; + open: number; + notStarted: number; + inProgress: number; + finished: number; + imports: number; + exports: number; + awaitingDeparture: number; + inTransit: number; + arrived: number; + actionNeeded: number; + }; + timings: Record; + documents: TransitStatItem["documents"]; + pending: { ro: number; do: number; t1: number; gatePass: number; djiboutiT1: number }; + items: TransitStatItem[]; +} + @Injectable() export class TransitAssignmentsService { constructor( @@ -77,6 +166,10 @@ export class TransitAssignmentsService { @InjectRepository(Booking) private readonly bookingsRepository: Repository, private readonly filesService: FilesService, + @InjectRepository(ClearanceMilestone) + private readonly milestonesRepository: Repository, + @InjectRepository(TrainSchedule) + private readonly trainSchedulesRepository: Repository, ) {} private static minutesBetween( @@ -179,106 +272,302 @@ export class TransitAssignmentsService { * reopened days later drags an average far enough to make the whole panel * lie about typical performance. */ - async myStats(userId: string) { + /** + * The agent's dashboard, every figure derived from stamps that already exist: + * the train's departure and arrival, the booking's clearance milestones, and + * the upload time of each document on the booking (RO / DO / T1 / gate pass / + * Djibouti T1). Replaced batches carry a fresh stamp, so an "uploaded" time + * here is always the LAST update, matching the detail page. + * + * Nothing is stored: a corrected timestamp cannot leave a stale number behind. + */ + async myStats(userId: string): Promise { const agent = await this.requireAgentForUser(userId); const rows = await this.assignmentsRepository.findByTransitAgent(agent.id); + const bookingIds = [...new Set(rows.map((r) => r.bookingId))]; - const docCounts = rows.length - ? await this.filesService.findByResourceIdsGrouped( - rows.map((r) => r.id), - TRANSIT_ASSIGNMENT_FILE_RESOURCE, - ) - : new Map(); + const [ownDocs, bookingDocs, milestones, schedules] = await Promise.all([ + rows.length + ? this.filesService.findByResourceIdsGrouped( + rows.map((r) => r.id), + TRANSIT_ASSIGNMENT_FILE_RESOURCE, + ) + : new Map(), + bookingIds.length + ? this.filesService.findByResourceIdsGrouped(bookingIds, "bookings") + : new Map(), + bookingIds.length + ? this.milestonesRepository.find({ + where: { bookingId: In(bookingIds) }, + select: ["bookingId", "milestoneCode", "status", "triggeredAt"], + }) + : [], + (() => { + const ids = [ + ...new Set( + rows + .map((r) => r.booking?.trainScheduleId) + .filter((id): id is string => Boolean(id)), + ), + ]; + return ids.length + ? this.trainSchedulesRepository.find({ + where: { id: In(ids) }, + select: [ + "id", + "trainNumber", + "voyageNumber", + "actualDepartureAt", + "actualArrivalAt", + ], + }) + : []; + })(), + ]); - const minutes = (from?: Date | null, to?: Date | null) => - from && to ? Math.floor((to.getTime() - from.getTime()) / 60_000) : null; + const scheduleById = new Map(schedules.map((sch) => [sch.id, sch])); + const milestonesByBooking = new Map(); + for (const m of milestones) { + if (!m.bookingId) continue; + const bucket = milestonesByBooking.get(m.bookingId); + if (bucket) bucket.push(m); + else milestonesByBooking.set(m.bookingId, [m]); + } + + const iso = (d?: Date | string | null): string | null => + d ? new Date(d).toISOString() : null; + const minutes = (from?: string | null, to?: string | null): number | null => + from && to + ? Math.floor((new Date(to).getTime() - new Date(from).getTime()) / 60_000) + : null; + /** Latest upload stamp among files matching a code family. */ + const latest = ( + files: FileRecord[], + matches: (code: string | null | undefined) => boolean, + ): { at: string | null; count: number } => { + const hits = files.filter((f) => matches(f.code)); + return { + count: hits.length, + at: hits.reduce((max, f) => { + const stamp = iso(f.createdAt); + return stamp && (!max || stamp > max) ? stamp : max; + }, null), + }; + }; + /** Earliest upload stamp — for append-only sets the FIRST document matters. */ + const earliest = ( + files: FileRecord[], + matches: (code: string | null | undefined) => boolean, + ): { at: string | null; count: number } => { + const hits = files.filter((f) => matches(f.code)); + return { + count: hits.length, + at: hits.reduce((min, f) => { + const stamp = iso(f.createdAt); + return stamp && (!min || stamp < min) ? stamp : min; + }, null), + }; + }; + + const items: TransitStatItem[] = rows.map((row) => { + const booking = row.booking; + const tradeDirection: TransitTradeDirection = + booking?.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT"; + const schedule = booking?.trainScheduleId + ? scheduleById.get(booking.trainScheduleId) + : undefined; + + // Same rule as the clearance view's train state: the booking's own + // load/unload stamps first, the schedule's actuals only as a fallback for + // legacy bookings that predate per-booking loading. + const departedAt = iso(booking?.loadedAt ?? schedule?.actualDepartureAt); + const arrivedAt = iso( + booking?.arrivedAt ?? + (booking?.loadedAt ? null : schedule?.actualArrivalAt), + ); + + const files = bookingDocs.get(row.bookingId) ?? []; + const ms = milestonesByBooking.get(row.bookingId) ?? []; + const milestone = (code: string) => ms.find((m) => m.milestoneCode === code); + const done = (code: string) => { + const m = milestone(code); + return m?.status === "COMPLETED" || m?.status === "SKIPPED"; + }; + + const declared = done("DECLARED"); + const declaredAt = iso(milestone("DECLARED")?.triggeredAt); + const ro = latest(files, isReleaseOrderFileCode); + const deliveryOrder = latest(files, isDeliveryOrderFileCode); + const t1 = latest(files, isT1TransportFileCode); + const gatePass = earliest(files, isGatePassFileCode); + const djiboutiT1 = earliest(files, isDjiboutiT1FileCode); + const t1Closed = milestone("T1_CLOSED")?.status === "COMPLETED"; + const bookingCreatedAt = iso(booking?.createdAt); + const finishedAt = iso(row.finishedAt); + const finished = row.status === TransitAssignmentStatus.Finished; + + const timings: TransitStatItem["timings"] = { + transit: minutes(departedAt, arrivedAt), + declarationToRo: tradeDirection === "EXPORT" ? minutes(declaredAt, ro.at) : null, + bookingToDo: + tradeDirection === "IMPORT" ? minutes(bookingCreatedAt, deliveryOrder.at) : null, + departureToT1: tradeDirection === "IMPORT" ? minutes(departedAt, t1.at) : null, + arrivalToT1: tradeDirection === "IMPORT" ? minutes(arrivedAt, t1.at) : null, + arrivalToGatePass: + tradeDirection === "EXPORT" ? minutes(arrivedAt, gatePass.at) : null, + arrivalToDjiboutiT1: + tradeDirection === "EXPORT" ? minutes(arrivedAt, djiboutiT1.at) : null, + arrivalToFinish: minutes(arrivedAt, finishedAt), + }; + + // What the officer should do next on this shipment — the same gates the + // detail page enforces, so the dashboard never points at a locked button. + let nextAction: TransitNextAction; + if (finished) { + nextAction = { kind: "done", label: "Assignment finished" }; + } else if (tradeDirection === "EXPORT") { + if (!declared) { + nextAction = { kind: "wait", label: "Awaiting customs declaration" }; + } else if (ro.count === 0) { + nextAction = { kind: "upload", label: "Upload Release Order", document: "ro" }; + } else if (!departedAt) { + nextAction = { kind: "wait", label: "Awaiting train departure" }; + } else if (!arrivedAt) { + nextAction = { kind: "wait", label: "Train in transit" }; + } else if (gatePass.count === 0) { + nextAction = { kind: "upload", label: "Upload gate pass", document: "gate_pass" }; + } else if (djiboutiT1.count === 0) { + nextAction = { + kind: "upload", + label: "Upload Djibouti T1", + document: "djibouti_t1", + }; + } else { + nextAction = { kind: "done", label: "Paperwork complete" }; + } + } else if (deliveryOrder.count === 0) { + nextAction = { kind: "upload", label: "Upload Delivery Order", document: "do" }; + } else if (!departedAt) { + nextAction = { kind: "wait", label: "Awaiting train departure" }; + } else if (t1.count === 0 && !t1Closed) { + nextAction = { kind: "upload", label: "Upload T1 documents", document: "t1" }; + } else if (!arrivedAt) { + nextAction = { kind: "wait", label: "Train in transit" }; + } else { + nextAction = { kind: "done", label: t1Closed ? "T1 closed" : "Paperwork complete" }; + } - const items = rows.map((row) => { - const arrivedAt = row.booking?.arrivedAt ?? null; return { id: row.id, - reference: row.booking?.reference ?? null, - customerName: row.booking?.company?.name ?? null, + bookingId: row.bookingId, + reference: booking?.reference ?? null, + customerName: booking?.company?.name ?? null, + tradeDirection, status: row.status, - schedulingStatus: row.booking?.schedulingStatus ?? null, - /** Dispatch (cargo loaded) to the train arriving. */ - transitMinutes: minutes(row.booking?.loadedAt, arrivedAt), - /** Arrival to the agent picking the work up. */ - pickupMinutes: minutes(arrivedAt, row.startedAt), - /** Arrival to the work being finished — the headline metric. */ - clearanceMinutes: minutes(arrivedAt, row.finishedAt), - documentCount: (docCounts.get(row.id) ?? []).length, + schedulingStatus: booking?.schedulingStatus ?? null, + trainLabel: schedule?.voyageNumber ?? schedule?.trainNumber ?? null, + assignedAt: iso(row.assignedAt) ?? new Date(0).toISOString(), + startedAt: iso(row.startedAt), + finishedAt, + bookingCreatedAt, + departedAt, + arrivedAt, + declaredAt, + roAt: ro.at, + doAt: deliveryOrder.at, + t1At: t1.at, + t1Closed, + gatePassAt: gatePass.at, + djiboutiT1At: djiboutiT1.at, + documents: { + ro: ro.count, + do: deliveryOrder.count, + t1: t1.count, + gatePass: gatePass.count, + djiboutiT1: djiboutiT1.count, + own: (ownDocs.get(row.id) ?? []).length, + }, + timings, + nextAction, }; }); - const median = (values: number[]): number | null => { - if (!values.length) return null; - const sorted = [...values].sort((a, b) => a - b); + // Most recently moving shipment first: arrival, else departure, else when + // it was handed to the agent. + const activity = (i: TransitStatItem) => + i.arrivedAt ?? i.departedAt ?? i.assignedAt; + items.sort((a, b) => activity(b).localeCompare(activity(a))); + + const summarize = (values: Array): TransitTimingSummary => { + const measured = values.filter((v): v is number => v !== null && v >= 0); + if (!measured.length) { + return { median: null, fastest: null, slowest: null, measured: 0 }; + } + const sorted = [...measured].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 - ? sorted[mid] - : Math.round((sorted[mid - 1] + sorted[mid]) / 2); + return { + median: + sorted.length % 2 + ? sorted[mid] + : Math.round((sorted[mid - 1] + sorted[mid]) / 2), + fastest: sorted[0], + slowest: sorted[sorted.length - 1], + measured: sorted.length, + }; }; + const timing = (key: keyof TransitStatItem["timings"]) => + summarize(items.map((i) => i.timings[key])); - const cleared = items - .map((i) => i.clearanceMinutes) - .filter((v): v is number => v !== null); - const pickups = items - .map((i) => i.pickupMinutes) - .filter((v): v is number => v !== null); - - // SLA bands, in minutes: inside 2h, inside 6h, beyond. - const sla = { - under2h: cleared.filter((v) => v <= 120).length, - under6h: cleared.filter((v) => v > 120 && v <= 360).length, - over6h: cleared.filter((v) => v > 360).length, - }; - - // Coverage counts only bookings that COULD have documents — uploads are - // gated on dispatch, so counting scheduled ones would invent a failure. - const dispatched = items.filter((i) => i.schedulingStatus === "DISPATCHED"); - const withDocs = dispatched.filter((i) => i.documentCount > 0).length; + const open = items.filter((i) => i.status !== TransitAssignmentStatus.Finished); + const pendingFor = (document: TransitDocumentKind) => + items.filter( + (i) => i.nextAction.kind === "upload" && i.nextAction.document === document, + ).length; + const sumDocs = (key: keyof TransitStatItem["documents"]) => + items.reduce((sum, i) => sum + i.documents[key], 0); return { totals: { assignments: items.length, - open: items.filter((i) => i.status !== TransitAssignmentStatus.Finished) + open: open.length, + notStarted: items.filter((i) => i.status === TransitAssignmentStatus.NotStarted) .length, - // 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, - readyForDocuments: items.filter( - (i) => - i.schedulingStatus === "DISPATCHED" && - i.status !== TransitAssignmentStatus.Finished, - ).length, - documents: items.reduce((sum, i) => sum + i.documentCount, 0), + inProgress: items.filter((i) => i.status === TransitAssignmentStatus.InProgress) + .length, + finished: items.length - open.length, + imports: items.filter((i) => i.tradeDirection === "IMPORT").length, + exports: items.filter((i) => i.tradeDirection === "EXPORT").length, + awaitingDeparture: open.filter((i) => !i.departedAt).length, + inTransit: open.filter((i) => i.departedAt && !i.arrivedAt).length, + arrived: open.filter((i) => Boolean(i.arrivedAt)).length, + actionNeeded: items.filter((i) => i.nextAction.kind === "upload").length, }, - performance: { - medianClearanceMinutes: median(cleared), - medianPickupMinutes: median(pickups), - fastestClearanceMinutes: cleared.length ? Math.min(...cleared) : null, - slowestClearanceMinutes: cleared.length ? Math.max(...cleared) : null, - onTimeRate: cleared.length - ? Math.round(((sla.under2h + sla.under6h) / cleared.length) * 100) - : null, - measured: cleared.length, + timings: { + transit: timing("transit"), + declarationToRo: timing("declarationToRo"), + bookingToDo: timing("bookingToDo"), + departureToT1: timing("departureToT1"), + arrivalToT1: timing("arrivalToT1"), + arrivalToGatePass: timing("arrivalToGatePass"), + arrivalToDjiboutiT1: timing("arrivalToDjiboutiT1"), + arrivalToFinish: timing("arrivalToFinish"), }, - sla, - coverage: { - dispatched: dispatched.length, - withDocuments: withDocs, + documents: { + ro: sumDocs("ro"), + do: sumDocs("do"), + t1: sumDocs("t1"), + gatePass: sumDocs("gatePass"), + djiboutiT1: sumDocs("djiboutiT1"), + own: sumDocs("own"), }, - /** Newest first, for the timeline and the recent-activity list. */ - items: items.slice(0, 12), + pending: { + ro: pendingFor("ro"), + do: pendingFor("do"), + t1: pendingFor("t1"), + gatePass: pendingFor("gate_pass"), + djiboutiT1: pendingFor("djibouti_t1"), + }, + items: items.slice(0, 20), }; } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 07ca7f735..313645db3 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -782,7 +782,7 @@ export function PhasedClearanceActionPanel({ diff --git a/apps/edr-freight-web/portal/src/components/contracts/PortalMultiFileDropzone.tsx b/apps/edr-freight-web/portal/src/components/contracts/PortalMultiFileDropzone.tsx new file mode 100644 index 000000000..060b85e75 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/contracts/PortalMultiFileDropzone.tsx @@ -0,0 +1,248 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + ActionIcon, + Box, + Group, + Stack, + Text, + ThemeIcon, + UnstyledButton, +} from "@mantine/core"; +import { FileText, Plus, Trash2, UploadCloud } from "lucide-react"; + +import { BORDER, GREEN, INK } from "@/pages/contracts/contract-ui"; + +export interface PortalMultiFileDropzoneProps { + label: string; + description?: string; + files: File[]; + onChange: (next: File[]) => void; + accept?: string; + disabled?: boolean; + /** Hint under the drop area, e.g. "PDF or image". */ + acceptHint?: string; +} + +export function formatBytes(bytes: number | null | undefined): string { + if (!bytes) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); + return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`; +} + +function isImageFile(file: File): boolean { + if (file.type.startsWith("image/")) return true; + const ext = file.name.split(".").pop()?.toLowerCase() ?? ""; + return ["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg"].includes(ext); +} + +/** Stable identity for a staged file — two picks of the same file dedupe. */ +const fileKey = (f: File) => `${f.name}:${f.size}:${f.lastModified}`; + +/** + * Multi-file counterpart of `PortalFileDropzone`: the same drop area, but the + * picker keeps a list. Picking again APPENDS (deduplicated by name+size+mtime), + * so an officer can gather documents across several picks before uploading. + */ +export function PortalMultiFileDropzone({ + label, + description, + files, + onChange, + accept = "application/pdf,image/*", + disabled = false, + acceptHint = "PDF or image", +}: PortalMultiFileDropzoneProps) { + const inputRef = useRef(null); + const [dragOver, setDragOver] = useState(false); + + // One object URL per staged image, revoked when the list changes. + const thumbs = useMemo(() => { + const map = new Map(); + for (const f of files) { + if (isImageFile(f)) map.set(fileKey(f), URL.createObjectURL(f)); + } + return map; + }, [files]); + + useEffect(() => { + return () => { + for (const url of thumbs.values()) URL.revokeObjectURL(url); + }; + }, [thumbs]); + + const add = (incoming: File[]) => { + if (disabled || incoming.length === 0) return; + const seen = new Set(files.map(fileKey)); + const next = [...files]; + for (const f of incoming) { + const key = fileKey(f); + if (seen.has(key)) continue; + seen.add(key); + next.push(f); + } + onChange(next); + }; + + const remove = (target: File) => + onChange(files.filter((f) => fileKey(f) !== fileKey(target))); + + const openPicker = () => { + if (disabled) return; + // Reset so re-picking the same file after removal still fires onChange. + if (inputRef.current) inputRef.current.value = ""; + inputRef.current?.click(); + }; + + const hasFiles = files.length > 0; + + return ( + + + + {label} + + {description ? ( + + {description} + + ) : null} + + + add(Array.from(e.target.files ?? []))} + /> + + { + e.preventDefault(); + if (!disabled) setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setDragOver(false); + add(Array.from(e.dataTransfer.files ?? [])); + }} + onClick={openPicker} + style={{ + borderRadius: 14, + border: `2px dashed ${dragOver ? GREEN : BORDER}`, + background: dragOver ? "#F2FBF6" : "#FAFCFE", + padding: hasFiles ? "16px 20px" : "28px 20px", + textAlign: "center", + cursor: disabled ? "not-allowed" : "pointer", + opacity: disabled ? 0.6 : 1, + transition: "border-color 120ms ease, background 120ms ease, padding 120ms ease", + }} + > + + + {hasFiles ? : } + + + + {dragOver + ? "Drop to add" + : hasFiles + ? "Add more files" + : "Drag & drop your files here"} + + + or browse —{" "} + {acceptHint}. Add as many as you need. + + + + + + {hasFiles ? ( + + + + {files.length} file{files.length === 1 ? "" : "s"} ready to upload + + onChange([])} + disabled={disabled} + style={{ fontSize: 11.5, color: "#6B7C8E", fontWeight: 600 }} + > + Clear all + + + {files.map((f) => { + const key = fileKey(f); + const thumb = thumbs.get(key); + return ( + + {thumb ? ( + + + + ) : ( + + + + )} + + + {f.name} + + + {formatBytes(f.size)} + + + remove(f)} + disabled={disabled} + > + + + + ); + })} + + ) : null} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingDetailPage.tsx index acadd3625..0808814cc 100644 --- a/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingDetailPage.tsx @@ -55,11 +55,11 @@ import { } 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"; + TransitExportDocumentsPanel, + TransitImportDocumentsPanel, +} from "@/pages/transit-agent/TransitDocumentsPanel"; +import { transitAssignmentsService } from "@/services/transit-assignments.service"; import { bookingsService } from "@/services/bookings.service"; import { useState } from "react"; import toast from "react-hot-toast"; @@ -500,7 +500,6 @@ 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({ @@ -563,7 +562,6 @@ export default function TransitAgentBookingDetailPage() { 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 @@ -644,20 +642,6 @@ export default function TransitAgentBookingDetailPage() { - - - @@ -692,6 +676,30 @@ export default function TransitAgentBookingDetailPage() { {/* ── Clearance workflow ─────────────────────────────────── */} + {/* The officer's own uploads with their timings, ahead of the + read-only grid: RO / gate pass / Djibouti T1 for exports, + DO / T1 for imports. */} + {clearance && bookingId && booking?.tradeDirection ? ( + + {booking.tradeDirection === "IMPORT" ? ( + void assignmentQuery.refetch()} + /> + ) : ( + void assignmentQuery.refetch()} + /> + )} + + ) : null} @@ -1174,10 +1182,6 @@ export default function TransitAgentBookingDetailPage() { 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 a13f2d4aa..fba9f354e 100644 --- a/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentBookingsPage.tsx @@ -29,10 +29,8 @@ import { CheckCircle2, ChevronRight, Clock3, - FileText, Inbox, Layers, - Lock, PackageCheck, Paperclip, RefreshCw, @@ -46,7 +44,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Pagination } from "@mantine/core"; -import TransitAgentDocumentsModal from "@/pages/transit-agent/TransitAgentDocumentsModal"; import { transitAssignmentsService, type TransitAssignment, @@ -319,7 +316,6 @@ function TablePager({ */ export default function TransitAgentBookingsPage() { const navigate = useNavigate(); - const [active, setActive] = useState(null); const [query, setQuery] = useState(""); const [debouncedQuery] = useDebouncedValue(query, 300); const [tab, setTab] = useState("all"); @@ -506,30 +502,6 @@ export default function TransitAgentBookingsPage() { ), }, - { - id: "action", - header: () => Documents, - cell: ({ row }) => { - const locked = !row.original.canUploadDocuments; - return ( - - ); - }, - }, { id: "chevron", size: 40, @@ -589,14 +561,21 @@ export default function TransitAgentBookingsPage() { color: "yellow", }, { - label: "Ready for documents", - value: stats?.totals.readyForDocuments ?? 0, + label: "Uploads due", + value: stats?.totals.actionNeeded ?? 0, icon: CheckCircle2, color: "edr-green", }, { label: "Documents filed", - value: stats?.totals.documents ?? 0, + value: stats + ? stats.documents.ro + + stats.documents.do + + stats.documents.t1 + + stats.documents.gatePass + + stats.documents.djiboutiT1 + + stats.documents.own + : 0, icon: Paperclip, color: "gray", }, @@ -807,11 +786,6 @@ export default function TransitAgentBookingsPage() { - - setActive(null)} - /> ); } diff --git a/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentOverviewPage.tsx b/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentOverviewPage.tsx index 4db8c305c..0e62ec710 100644 --- a/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentOverviewPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/transit-agent/TransitAgentOverviewPage.tsx @@ -1,14 +1,21 @@ -import { Box, Button, Center, Group, Loader, Stack, Text } from "@mantine/core"; +import { Box, Button, Center, Group, Loader, Stack, Text, Tooltip } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { AlertCircle, ArrowRight, - FileCheck2, - FileX2, - Paperclip, - Play, - Target, + CheckCircle2, + Clock3, + FileStack, + FileText, + Hourglass, + MapPin, + PackageCheck, + Route, + Ship, + ShieldCheck, Timer, + Train, + Upload, } from "lucide-react"; import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; @@ -17,23 +24,53 @@ import { Card } from "@/pages/MyPortalPage/components"; import { cv } from "@/pages/MyPortalPage/constants"; import { transitAssignmentsService, + type TransitDocumentKind, type TransitStatItem, type TransitStats, + type TransitTimingKey, + type TransitTimingSummary, } from "@/services/transit-assignments.service"; -/** Minutes as a compact "3h 10m" / "45m" — raw integers are unreadable in a grid. */ -function formatMinutes(minutes: number | null): string { - if (minutes === null) return "—"; - const h = Math.floor(minutes / 60); +// ── Formatting ─────────────────────────────────────────────────────────────── + +/** Minutes as "2d 4h", "3h 10m", "45m" — raw integers are unreadable in a grid. */ +function formatMinutes(minutes: number | null | undefined): string { + if (minutes === null || minutes === undefined) return "—"; + if (minutes < 1) return "<1m"; + const d = Math.floor(minutes / 1440); + const h = Math.floor((minutes % 1440) / 60); const m = minutes % 60; - return h ? `${h}h${m ? ` ${m}m` : ""}` : `${m}m`; + if (d) return `${d}d${h ? ` ${h}h` : ""}`; + if (h) return `${h}h${m ? ` ${m}m` : ""}`; + return `${m}m`; } /** Split a duration so the number and its unit can be styled apart. */ function splitDuration(minutes: number | null): [string, string] { if (minutes === null) return ["—", ""]; if (minutes < 90) return [String(minutes), "min"]; - return [(minutes / 60).toFixed(1), "hrs"]; + if (minutes < 2880) return [(minutes / 60).toFixed(1), "hrs"]; + return [(minutes / 1440).toFixed(1), "days"]; +} + +function formatStamp(value?: string | null): string { + if (!value) return "—"; + return new Date(value).toLocaleString(undefined, { + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** "3h ago" / "2d ago" — for the action list, where recency is the point. */ +function ago(value?: string | null): string | null { + if (!value) return null; + const minutes = Math.max( + Math.floor((Date.now() - new Date(value).getTime()) / 60_000), + 0, + ); + return `${formatMinutes(minutes)} ago`; } const pctOf = (part: number, total: number) => @@ -49,7 +86,81 @@ const TONES: Record = { red: { soft: cv("edr-red-soft"), ink: cv("edr-red") }, }; -/** Section heading shared by every panel, so the rhythm stays identical. */ +const DOC_ICON: Record = { + ro: Ship, + do: PackageCheck, + t1: FileStack, + gate_pass: ShieldCheck, + djibouti_t1: FileStack, +}; + +/** Every timing the API measures, with the copy that explains each one. */ +const TIMINGS: Array<{ + key: TransitTimingKey; + label: string; + caption: string; + icon: typeof Timer; + tone: Tone; + direction?: "IMPORT" | "EXPORT"; +}> = [ + { + key: "transit", + label: "Transit time", + caption: "train departure → arrival", + icon: Route, + tone: "blue", + }, + { + key: "declarationToRo", + label: "Declaration → RO", + caption: "customs declaration → Release Order", + icon: Ship, + tone: "green", + direction: "EXPORT", + }, + { + key: "arrivalToGatePass", + label: "Arrival → gate pass", + caption: "train arrival → first gate pass", + icon: ShieldCheck, + tone: "green", + direction: "EXPORT", + }, + { + key: "arrivalToDjiboutiT1", + label: "Arrival → Djibouti T1", + caption: "train arrival → first Djibouti T1", + icon: FileStack, + tone: "green", + direction: "EXPORT", + }, + { + key: "bookingToDo", + label: "Booking → DO", + caption: "booking created → Delivery Order", + icon: PackageCheck, + tone: "green", + direction: "IMPORT", + }, + { + key: "departureToT1", + label: "Departure → T1", + caption: "train departure → T1 documents", + icon: FileStack, + tone: "green", + direction: "IMPORT", + }, + { + key: "arrivalToFinish", + label: "Arrival → finished", + caption: "train arrival → assignment finished", + icon: CheckCircle2, + tone: "amber", + }, +]; + +// ── Pieces ─────────────────────────────────────────────────────────────────── + function PanelHead({ title, hint, @@ -76,23 +187,18 @@ function PanelHead({ ); } -/** - * One headline metric. Built on the portal's own KPI language — soft icon chip, - * tight numeral, muted label — rather than a generic bordered box per stat. - */ -function Kpi({ +/** One headline count: soft icon chip, tight numeral, muted label. */ +function CountKpi({ icon: Icon, label, value, - unit, caption, tone, divider, }: { icon: typeof Timer; label: string; - value: string; - unit: string; + value: number; caption: string; tone: Tone; divider?: boolean; @@ -114,22 +220,9 @@ function Kpi({ - - - {value} - - {unit ? ( - - {unit} - - ) : null} - + + {value} + {label} @@ -142,75 +235,363 @@ function Kpi({ ); } +/** One measured duration: the median headline, with the spread underneath. */ +function TimingKpi({ + icon: Icon, + label, + caption, + summary, + tone, +}: { + icon: typeof Timer; + label: string; + caption: string; + summary: TransitTimingSummary; + tone: Tone; +}) { + const t = TONES[summary.measured ? tone : "slate"]; + const [value, unit] = splitDuration(summary.median); + return ( + + + + + + + + {label} + + + {caption} + + + + + + {value} + + {unit ? ( + + {unit} + + ) : null} + + median + + + + {summary.measured + ? `${summary.measured} measured · fastest ${formatMinutes(summary.fastest)} · slowest ${formatMinutes(summary.slowest)}` + : "Nothing measured yet"} + + + ); +} + +function DirectionChip({ direction }: { direction: "IMPORT" | "EXPORT" }) { + const tone: Tone = direction === "EXPORT" ? "green" : "blue"; + return ( + + + {direction === "EXPORT" ? "EXP" : "IMP"} + + + ); +} + +/** Where a shipment is on the rail leg, from its own stamps. */ +function movementOf(item: TransitStatItem): { label: string; tone: Tone } { + if (item.status === "FINISHED") return { label: "Finished", tone: "green" }; + if (item.arrivedAt) return { label: "Arrived", tone: "green" }; + if (item.departedAt) return { label: "In transit", tone: "blue" }; + return { label: "Awaiting departure", tone: "slate" }; +} + +/** A small "+3h 20m" chip for one document's offset from its reference event. */ +function OffsetChip({ + icon: Icon, + minutes, + title, + tone = "green", +}: { + icon: typeof FileText; + minutes: number | null; + title: string; + tone?: Tone; +}) { + if (minutes === null) return null; + const t = TONES[minutes < 0 ? "slate" : tone]; + return ( + + + + + {minutes < 0 ? "−" : "+"} + {formatMinutes(Math.abs(minutes))} + + + + ); +} + /** - * One booking's arrival→clearance track: a pickup-lag segment followed by the - * clearance work, both on one shared scale so rows compare directly. + * One shipment's rail leg on a shared scale — departure to arrival — with the + * paperwork timed against it. A still-rolling train shows its time so far. */ -function TimelineRow({ +function MovementRow({ item, scaleMax, + onOpen, }: { item: TransitStatItem; scaleMax: number; + onOpen: () => void; }) { - const pickup = item.pickupMinutes ?? 0; - const total = item.clearanceMinutes; - const work = total !== null ? Math.max(total - pickup, 0) : 0; - const pct = (v: number) => `${Math.min((v / scaleMax) * 100, 100)}%`; - - const tone: Tone = - total === null - ? "slate" - : total <= 120 - ? "green" - : total <= 360 - ? "amber" - : "red"; + const movement = movementOf(item); + const transit = + item.timings.transit ?? + (item.departedAt + ? Math.floor((Date.now() - new Date(item.departedAt).getTime()) / 60_000) + : null); + const live = item.timings.transit === null && item.departedAt !== null; + const width = transit !== null ? `${Math.min((transit / scaleMax) * 100, 100)}%` : "0%"; + const isExport = item.tradeDirection === "EXPORT"; return ( - - - - {item.reference?.replace("BK-2026-", "…") ?? "—"} - - - {item.customerName ?? "—"} - - - - - {pickup > 0 ? ( - - ) : null} - {total !== null ? ( - - ) : ( - - {item.status === "NOT_STARTED" ? "not started" : "in progress"} + + + + + + + {item.reference ?? "—"} + + + + {item.customerName ?? "—"} + {item.trainLabel ? ` · ${item.trainLabel}` : ""} - )} - + - - {formatMinutes(total)} - + + + Departed + + + {formatStamp(item.departedAt)} + + + + + {transit !== null ? ( + + ) : null} + + {transit === null + ? "not departed" + : live + ? `${formatMinutes(transit)} so far` + : formatMinutes(transit)} + + + + + + Arrived + + + {item.arrivedAt ? formatStamp(item.arrivedAt) : live ? "in transit" : "—"} + + + + + {isExport ? ( + <> + + + + + ) : ( + <> + + + + )} + + + {movement.label} + + + + + + ); +} + +/** A shipment that needs an upload now, with the context that makes it urgent. */ +function ActionRow({ item, onOpen }: { item: TransitStatItem; onOpen: () => void }) { + const doc = item.nextAction.document ?? "ro"; + const Icon = DOC_ICON[doc]; + // The reference event for this upload — what the clock is running from. + const since = + doc === "ro" + ? { label: "declared", at: item.declaredAt } + : doc === "do" + ? { label: "booked", at: item.bookingCreatedAt } + : doc === "t1" + ? { label: "departed", at: item.departedAt } + : { label: "arrived", at: item.arrivedAt }; + const waited = since.at + ? Math.floor((Date.now() - new Date(since.at).getTime()) / 60_000) + : null; + const tone: Tone = waited === null ? "slate" : waited > 720 ? "red" : waited > 240 ? "amber" : "green"; + + return ( + + + + + + + + + + {item.reference ?? "—"} + + + {item.customerName ?? "—"} + + + + + {item.nextAction.label} + + {since.at ? ` · ${since.label} ${ago(since.at)}` : ""} + + + + + {waited !== null ? ( + + {formatMinutes(waited)} + + ) : null} + + ); } -/** A single SLA band as a labelled proportional bar. */ -function SlaBar({ +function DocCountRow({ + icon: Icon, + label, + count, + pending, +}: { + icon: typeof FileText; + label: string; + count: number; + pending: number; +}) { + return ( + + + + + {label} + + + + {pending > 0 ? ( + + {pending} pending + + ) : null} + + {count} + + + + ); +} + +function StageBar({ label, count, total, @@ -226,10 +607,7 @@ function SlaBar({ - + {label} @@ -239,48 +617,61 @@ function SlaBar({ - + ); } +// ── Page ───────────────────────────────────────────────────────────────────── + /** - * The transit agent's dashboard: how quickly documents are filed after the - * train dispatches and arrives. + * The transit agent's home: where each shipment is on the rail leg, what + * paperwork is due next, and how long each document takes measured from the + * event that unlocks it — the train's departure and arrival, the customs + * declaration, the booking's creation. * - * Every figure comes from `GET /transit-assignments/my/stats`, which derives - * them from timestamps that already exist. The page renders what the API - * measured rather than recomputing, so the two cannot disagree. + * Every figure comes from `GET /transit-assignments/my/stats`, derived there + * from timestamps that already exist. The page renders what the API measured + * rather than recomputing, so the two cannot disagree. */ export default function TransitAgentOverviewPage() { const navigate = useNavigate(); const query = useQuery({ queryKey: ["transit-stats"], queryFn: transitAssignmentsService.stats, + refetchInterval: 60_000, }); const s: TransitStats | undefined = query.data; - const timeline = useMemo( - () => - (s?.items ?? []) - .filter((i) => i.schedulingStatus === "DISPATCHED") - .slice(0, 6), + const actions = useMemo( + () => (s?.items ?? []).filter((i) => i.nextAction.kind === "upload"), + [s], + ); + const movements = useMemo( + () => (s?.items ?? []).filter((i) => i.departedAt || i.status !== "FINISHED").slice(0, 8), [s], ); - // One shared scale, capped at 6h: a single 47h outlier would otherwise - // compress every other row into an invisible sliver. + // One shared scale: the longest completed transit, padded — a single + // multi-day outlier is capped so the others do not vanish into a sliver. const scaleMax = useMemo(() => { - const measured = timeline - .map((i) => i.clearanceMinutes) + const measured = movements + .map((i) => i.timings.transit) .filter((v): v is number => v !== null); - return Math.min(Math.max(...measured, 120) * 1.15, 360); - }, [timeline]); + return Math.min(Math.max(...measured, 360) * 1.15, 4320); + }, [movements]); + + // Show only the timings that can apply to this agent's mix of shipments. + const timingCards = useMemo(() => { + if (!s) return []; + return TIMINGS.filter( + (t) => + !t.direction || + (t.direction === "EXPORT" ? s.totals.exports > 0 : s.totals.imports > 0), + ); + }, [s]); if (query.isPending) { return ( @@ -297,8 +688,7 @@ export default function TransitAgentOverviewPage() { - {(query.error as Error)?.message ?? - "Could not load your overview."} + {(query.error as Error)?.message ?? "Could not load your overview."} @@ -306,15 +696,9 @@ export default function TransitAgentOverviewPage() { ); } - const { totals, performance, sla, coverage } = s; - const [medianValue, medianUnit] = splitDuration( - performance.medianClearanceMinutes, - ); - const [pickupValue, pickupUnit] = splitDuration( - performance.medianPickupMinutes, - ); - const uncovered = coverage.dispatched - coverage.withDocuments; - const coveragePct = pctOf(coverage.withDocuments, coverage.dispatched); + const { totals, timings, documents, pending } = s; + const openDetail = (item: TransitStatItem) => navigate(`/transit-agent/bookings/${item.id}`); + const waiting = s.items.filter((i) => i.nextAction.kind === "wait"); return ( @@ -322,238 +706,276 @@ export default function TransitAgentOverviewPage() { - Clearance performance + Transit overview - How fast documents are filed after the train dispatches and - arrives + Where your shipments are on the rail leg, what paperwork is due, + and how long each document takes from the moment it unlocks - {totals.open > 0 ? ( - + {totals.actionNeeded > 0 ? ( + + + + {totals.actionNeeded} upload{totals.actionNeeded === 1 ? "" : "s"} due + + + ) : totals.open > 0 ? ( + + + + {totals.open} active · paperwork up to date + + + ) : null} + + + {/* ── Where the shipments are ──────────────────────────────── */} - - + - + - 0 ? "amber" : "green"} + - 0 ? "amber" : "green"} divider /> - - - - - {( - [ - [cv("edr-blue-dot"), "pickup"], - [cv("edr-green.7"), "cleared"], - [cv("edr-red"), "breach"], - ] as const - ).map(([color, label]) => ( - - - - {label} - - - ))} - - } + {/* ── Timings ──────────────────────────────────────────────── */} + + + + {timingCards.map((t) => ( + - {timeline.length === 0 ? ( - - No dispatched bookings yet. - + ))} + + + + + {/* ── Action needed ────────────────────────────────────── */} + + + + + {waiting.length} waiting on the train or the desk + + } + /> + + {actions.length === 0 ? ( + + + + + + Nothing to upload right now. + + + {waiting.length > 0 + ? `${waiting.length} shipment${waiting.length === 1 ? " is" : "s are"} waiting on a declaration or on the train — they will appear here the moment an upload unlocks.` + : "Every shipment assigned to you has its paperwork complete."} + + + + ) : ( - - {timeline.map((item) => ( - - ))} - + + {[...actions] + .sort((a, b) => { + const ref = (i: TransitStatItem) => + i.nextAction.document === "ro" + ? i.declaredAt + : i.nextAction.document === "do" + ? i.bookingCreatedAt + : i.nextAction.document === "t1" + ? i.departedAt + : i.arrivedAt; + return (ref(a) ?? "9").localeCompare(ref(b) ?? "9"); + }) + .map((item) => ( + openDetail(item)} /> + ))} + )} + {/* ── Documents + stages ───────────────────────────────── */} - {performance.measured} measured + {documents.ro + documents.do + documents.t1 + documents.gatePass + documents.djiboutiT1 + documents.own}{" "} + files } /> - - - - + + {totals.exports > 0 ? ( + <> + + + + + ) : null} + {totals.imports > 0 ? ( + <> + + + + ) : null} + + {totals.assignments} total + + } /> - - - {coveragePct}% - - - {coverage.withDocuments} of {coverage.dispatched} - - - - 0 ? cv("edr-amber-text") : cv("edr-green.6"), - }} - /> - - {uncovered > 0 ? ( - - - - {uncovered} dispatched booking - {uncovered === 1 ? " has" : "s have"} no documents filed. - - - ) : ( - - - - Every dispatched booking has evidence filed. - - - )} - + + + + + + + {/* ── Train movements ──────────────────────────────────────── */} + + + + {( + [ + [TONES.blue.ink, "arrived"], + [cv("edr-blue-dot"), "still rolling"], + [TONES.green.soft, "doc offset"], + ] as const + ).map(([color, label]) => ( + + + + {label} + + + ))} + + } + /> + + {movements.length === 0 ? ( + + No shipments on the rail yet. + + ) : ( + + + {movements.map((item) => ( + openDetail(item)} + /> + ))} + + + )} + + + {/* ── Recent assignments ───────────────────────────────────── */} - - {s.items.slice(0, 6).map((item) => ( + {s.items.slice(0, 8).map((item) => { + const movement = movementOf(item); + return ( openDetail(item)} > - + + {item.reference ?? "—"} - - - {item.status === "FINISHED" - ? "Finished" - : item.status === "IN_PROGRESS" - ? "In progress" - : "Not started"} + + + {movement.label} {item.customerName ?? "—"} - - - {formatMinutes(item.clearanceMinutes)} - - - {item.documentCount > 0 ? ( - - ) : ( - - )} - 0 - ? cv("edr-green.7") - : cv("edr-muted"), - }} - > - {item.documentCount} + + + + + {item.arrivedAt + ? `arrived ${ago(item.arrivedAt)}` + : item.departedAt + ? `departed ${ago(item.departedAt)}` + : `assigned ${ago(item.assignedAt)}`} + + {item.nextAction.label} + - ))} - + ); + })} diff --git a/apps/edr-freight-web/portal/src/pages/transit-agent/TransitClearanceActionPanel.tsx b/apps/edr-freight-web/portal/src/pages/transit-agent/TransitClearanceActionPanel.tsx index 8179446d4..3fe55cfb8 100644 --- a/apps/edr-freight-web/portal/src/pages/transit-agent/TransitClearanceActionPanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/transit-agent/TransitClearanceActionPanel.tsx @@ -1,6 +1,5 @@ import { Alert, - Badge, Box, Button, Card, @@ -13,102 +12,21 @@ import { Textarea, ThemeIcon, } from "@mantine/core"; -import { DateInput } from "@mantine/dates"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, ArrowRight, CheckCircle2, - FileStack, Ship, - Upload, X, } from "lucide-react"; import { useState } from "react"; import toast from "react-hot-toast"; import type { Freight } from "@edr/types"; -import { - isDeliveryOrderFileCode, - isReleaseOrderFileCode, -} from "@edr/types"; +import { isReleaseOrderFileCode } from "@edr/types"; import { transitAssignmentsService } from "@/services/transit-assignments.service"; -/** `YYYY-MM-DD` in local time — the API column is a DATE, so no UTC shift. */ -function toIsoDate(value: Date | null): string | null { - if (!value) return null; - const tz = value.getTimezoneOffset() * 60000; - return new Date(value.getTime() - tz).toISOString().slice(0, 10); -} - -/** Today at local midnight — the floor for every date picker here. */ -function todayMidnight(): Date { - const d = new Date(); - d.setHours(0, 0, 0, 0); - return d; -} - -interface DoDates { - vesselArrival: Date | null; - doCollected: Date | null; -} - -/** Both dates present and the DO not collected before the vessel docked. */ -function doDatesComplete(d: DoDates): boolean { - if (!d.vesselArrival || !d.doCollected) return false; - return (toIsoDate(d.doCollected) ?? "") >= (toIsoDate(d.vesselArrival) ?? ""); -} - -/** Minimal multi-file picker — the portal has no shared dropzone component. */ -function FilePicker({ - label, - description, - accept, - files, - onChange, -}: { - label: string; - description: string; - accept?: string; - files: File[]; - onChange: (next: File[]) => void; -}) { - return ( - - - {label} - - - {description} - - 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; @@ -127,13 +45,12 @@ function isMilestoneDone( } /** - * 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. + * The clearance stepper a transit agent sees on a shipment assigned to them, + * laid out as the backoffice's clearance action panel is, plus the RO + * amendment request. The uploads themselves (DO, RO, T1, gate pass, Djibouti + * T1) live in the transit documents panel, where their timings are shown. * - * 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. + * The steps are rendered from the clearance payload rather than re-derived. */ export function TransitClearanceActionPanel({ bookingId, @@ -147,15 +64,10 @@ export function TransitClearanceActionPanel({ 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, ); @@ -419,36 +331,10 @@ export function TransitClearanceActionPanel({ ))} + {/* Every upload (DO, RO, T1, gate pass, Djibouti T1) lives in the + transit documents panel above the grid, where its timings are + shown; only the RO amendment request stays here. */} - {isImport ? ( - - ) : ( - - )} - - - {!isImport ? ( - - - - - ); -} - -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, diff --git a/apps/edr-freight-web/portal/src/pages/transit-agent/TransitDocumentsPanel.tsx b/apps/edr-freight-web/portal/src/pages/transit-agent/TransitDocumentsPanel.tsx new file mode 100644 index 000000000..d8cc23f49 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/transit-agent/TransitDocumentsPanel.tsx @@ -0,0 +1,1736 @@ +import { + ActionIcon, + Alert, + Badge, + Box, + Button, + Card, + Group, + Modal, + Paper, + SimpleGrid, + Stack, + Text, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + AlertTriangle, + ArrowRight, + CheckCircle2, + Clock3, + Download, + Eye, + FileCheck2, + FileStack, + FileText, + Lock, + MapPin, + PackageCheck, + Ship, + ShieldCheck, + Timer, + Train, + Trash2, + Upload, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import toast from "react-hot-toast"; +import type { Freight } from "@edr/types"; +import { + isDeliveryOrderFileCode, + isDjiboutiT1FileCode, + isGatePassFileCode, + isReleaseOrderFileCode, + isT1TransportFileCode, +} from "@edr/types"; +import { isViewable } from "@edr/ui-common"; + +import { + PortalMultiFileDropzone, + formatBytes, +} from "@/components/contracts/PortalMultiFileDropzone"; +import { BORDER, GREEN, INK, MUTED } from "@/pages/contracts/contract-ui"; +import { + downloadStoredFile, + fetchViewableFile, +} from "@/services/files.service"; +import { transitAssignmentsService } from "@/services/transit-assignments.service"; + +// ── Time helpers ───────────────────────────────────────────────────────────── + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +/** "Sep 2, 2026, 14:05" — the officer reads these against a clock, not a calendar. */ +function formatStamp(value?: string | null): string { + if (!value) return "—"; + return new Date(value).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); +} + +/** Signed duration in ms → "2d 4h 13m", "45m", "<1m". */ +function formatDuration(ms: number): string { + const abs = Math.abs(ms); + if (abs < MINUTE) return "<1m"; + const days = Math.floor(abs / DAY); + const hours = Math.floor((abs % DAY) / HOUR); + const minutes = Math.floor((abs % HOUR) / MINUTE); + const parts: string[] = []; + if (days) parts.push(`${days}d`); + if (hours) parts.push(`${hours}h`); + if (minutes || parts.length === 0) parts.push(`${minutes}m`); + return parts.join(" "); +} + +/** Elapsed between two stamps; null when either is missing. */ +function elapsed(from?: string | null, to?: string | null): string | null { + if (!from || !to) return null; + return formatDuration(new Date(to).getTime() - new Date(from).getTime()); +} + +/** + * How far an upload sits from a train event. "after" is the normal case; a + * document filed BEFORE the event (paperwork prepared in advance) is labeled + * so, never shown as a negative number. + */ +function offsetFrom( + base: string | null | undefined, + at: string | null | undefined, + event: string, +): { text: string; late: boolean } | null { + if (!base || !at) return null; + const diff = new Date(at).getTime() - new Date(base).getTime(); + return diff >= 0 + ? { text: `${formatDuration(diff)} after ${event}`, late: false } + : { text: `${formatDuration(diff)} before ${event}`, late: true }; +} + +const latestOf = (stamps: Array): string | null => + stamps.reduce( + (max, s) => (s && (!max || s > max) ? s : max), + null, + ); + +const earliestOf = (stamps: Array): string | null => + stamps.reduce( + (min, s) => (s && (!min || s < min) ? s : min), + null, + ); + +/** Newest history event for an action, or null. History is newest-first. */ +function latestEvent( + history: Freight.ClearanceHistoryEvent[], + action: string, +): Freight.ClearanceHistoryEvent | null { + return history.find((e) => e.action === action) ?? null; +} + +// ── Small presentational pieces ────────────────────────────────────────────── + +/** One figure in a stat strip: label, big value, optional footnote. */ +function Stat({ + icon: Icon, + label, + value, + hint, + tone = "default", +}: { + icon: typeof Clock3; + label: string; + value: React.ReactNode; + hint?: React.ReactNode; + tone?: "default" | "green" | "blue" | "orange" | "muted"; +}) { + const color = + tone === "green" + ? "edr-green" + : tone === "blue" + ? "blue" + : tone === "orange" + ? "orange" + : "gray"; + return ( + + + + + + + {label} + + + + {value} + + {hint ? ( + + {hint} + + ) : null} + + ); +} + +function OffsetChip({ + offset, + color, +}: { + offset: { text: string; late: boolean } | null; + color: string; +}) { + if (!offset) return null; + return ( + } + > + {offset.text} + + ); +} + +/** A stored document row: name, stamp, train offsets, view / download / remove. */ +function StoredDocumentRow({ + item, + train, + onView, + onRemove, + removing, +}: { + item: Freight.ClearanceWorkflowFile; + train?: Freight.ClearanceTrainState | null; + onView: (file: { name: string; url: string; mimeType?: string | null }) => void; + onRemove?: (file: { id: string; name: string }) => void; + removing?: boolean; +}) { + const file = item.file; + if (!file) return null; + const canPreview = isViewable({ name: file.name, url: "" }); + const uploadedAt = file.uploadedAt ?? null; + + return ( + + + + + + + + + + {item.label} + + {file.size ? ( + + · {formatBytes(file.size)} + + ) : null} + + + {file.name} + + + } + > + {formatStamp(uploadedAt)} + + {train ? ( + <> + + + + ) : null} + + + + + {canPreview ? ( + + + void fetchViewableFile(file.id, file.name).then(onView) + } + > + + + + ) : null} + + void downloadStoredFile(file.id, file.name)} + > + + + + {onRemove ? ( + + onRemove({ id: file.id, name: file.name })} + > + + + + ) : null} + + + + ); +} + +function EmptyDocs({ icon: Icon, children }: { icon: typeof FileText; children: React.ReactNode }) { + return ( + + + + + + + {children} + + + + ); +} + +/** Card chrome shared by the three document sections. */ +function DocumentCard({ + icon: Icon, + title, + subtitle, + status, + action, + children, +}: { + icon: typeof FileText; + title: string; + subtitle: string; + status?: React.ReactNode; + action?: React.ReactNode; + children: React.ReactNode; +}) { + return ( + + + + + + + + + + {title} + + {status} + + + {subtitle} + + + + {action ? {action} : null} + + {children} + + ); +} + +// ── Train timeline strip ───────────────────────────────────────────────────── + +function TrainStrip({ train }: { train?: Freight.ClearanceTrainState | null }) { + const departed = train?.departedAt ?? null; + const arrived = train?.arrivedAt ?? null; + const transit = elapsed(departed, arrived); + const sinceDeparture = + departed && !arrived ? formatDuration(Date.now() - new Date(departed).getTime()) : null; + const sinceArrival = arrived + ? formatDuration(Date.now() - new Date(arrived).getTime()) + : null; + + return ( + + + + + + ); +} + +// ── Release Order card ─────────────────────────────────────────────────────── + +function ReleaseOrderCard({ + bookingId, + clearance, + history, + onView, + onChanged, +}: { + bookingId: string; + clearance: Freight.ClearanceView; + history: Freight.ClearanceHistoryEvent[]; + onView: (file: { name: string; url: string; mimeType?: string | null }) => void; + onChanged: () => void; +}) { + const [open, setOpen] = useState(false); + + const roFiles = (clearance.workflowFiles ?? []).filter( + (f) => isReleaseOrderFileCode(f.code) && f.file, + ); + const hasRo = roFiles.length > 0; + + // The customs declaration is the gate: the API refuses an RO before it. + const declaredMilestone = clearance.milestones?.find( + (m) => m.milestoneCode === "DECLARED", + ); + const declared = + declaredMilestone?.status === "COMPLETED" || + declaredMilestone?.status === "SKIPPED"; + const declaredAt = + declaredMilestone?.triggeredAt ?? + latestEvent(history, "DECLARATION_UPLOADED")?.at ?? + null; + + // "Uploaded" is the latest stamp on the current RO set — replacing the RO + // stores a fresh batch, so this is always the last update, as required. + const roEvents = history.filter((e) => e.action === "RELEASE_ORDER_UPLOADED"); + const roAt = + latestOf(roFiles.map((f) => f.file?.uploadedAt)) ?? roEvents[0]?.at ?? null; + const roUpdated = roEvents.length > 1; + const declarationToRo = elapsed(declaredAt, roAt); + const secured = + clearance.milestones?.find((m) => m.milestoneCode === "RELEASE_ORDER_SECURED") + ?.status === "COMPLETED"; + const hold = clearance.roHoldReason ?? null; + + const status = hold ? ( + + On hold + + ) : secured ? ( + } + > + Secured + + ) : hasRo ? ( + + Uploaded + + ) : declared ? ( + + Ready to upload + + ) : ( + } + > + Waiting for declaration + + ); + + return ( + <> + + + + } + > + + {hold ? ( + } + title="RO amendment hold" + > + {hold} + + ) : null} + + + + + + + + {hasRo ? ( + + {roFiles.map((item) => ( + + ))} + + ) : ( + + {declared + ? "No Release Order on file yet. Upload the RO and confirm the vessel departure date." + : "The Release Order can be uploaded as soon as the customs declaration is on file."} + + )} + + + + setOpen(false)} + onSuccess={onChanged} + /> + + ); +} + +/** `YYYY-MM-DD` in local time — the API column is a DATE, so no UTC shift. */ +function toIsoDate(value: Date | null): string | null { + if (!value) return null; + const tz = value.getTimezoneOffset() * 60000; + return new Date(value.getTime() - tz).toISOString().slice(0, 10); +} + +function ReleaseOrderModal({ + opened, + bookingId, + replaceMode, + vesselDepartureDate, + onClose, + onSuccess, +}: { + opened: boolean; + bookingId: string; + replaceMode: boolean; + vesselDepartureDate: string | null; + onClose: () => void; + onSuccess: () => void; +}) { + const [files, setFiles] = useState([]); + const [vesselDate, setVesselDate] = useState( + vesselDepartureDate ? new Date(vesselDepartureDate) : null, + ); + const today = useMemo(() => { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d; + }, []); + + const close = () => { + setFiles([]); + onClose(); + }; + + const submit = useMutation({ + mutationFn: () => + transitAssignmentsService.uploadReleaseOrder( + bookingId, + files, + toIsoDate(vesselDate)!, + ), + onSuccess: (result) => { + // The API 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?.hold) { + toast.error(result.holdReason ?? "Vessel date too soon"); + } else { + toast.success(replaceMode ? "Release Order updated" : "Release Order uploaded"); + } + onSuccess(); + close(); + }, + onError: (e: unknown) => + toast.error(e instanceof Error ? e.message : "Upload failed"), + }); + + return ( + + + + + + {replaceMode ? "Replace Release Order" : "Upload Release Order"} + + + } + > + + + Upload the Release Order and confirm the vessel departure date. The + upload time is recorded and measured against the customs declaration. + {replaceMode + ? " Replacing removes the current RO files and records a new time." + : ""} + + + setVesselDate(v ? new Date(v) : null)} + minDate={today} + size="sm" + radius="md" + required + withAsterisk + /> + + + + + + + + + + ); +} + +// ── Gate pass / Djibouti T1 cards ──────────────────────────────────────────── + +type ArrivalKind = "gate_pass" | "djibouti_t1"; + +const ARRIVAL_SETS: Record< + ArrivalKind, + { + title: string; + subtitle: string; + icon: typeof FileText; + matches: (code: string) => boolean; + upload: (bookingId: string, files: File[]) => Promise<{ uploaded: number }>; + empty: string; + modalHint: string; + } +> = { + gate_pass: { + title: "Gate pass", + subtitle: "Port gate pass documents collected at Djibouti", + icon: ShieldCheck, + matches: isGatePassFileCode, + upload: transitAssignmentsService.uploadGatePassDocuments, + empty: + "No gate pass documents yet. Add each pass as you collect it — every upload is time-stamped.", + modalHint: "Gate pass scans or photos. You can add more later.", + }, + djibouti_t1: { + title: "Djibouti T1", + subtitle: "T1 transit documents issued at Djibouti customs", + icon: FileStack, + matches: isDjiboutiT1FileCode, + upload: transitAssignmentsService.uploadDjiboutiT1Documents, + empty: + "No Djibouti T1 documents yet. Add each T1 as customs issues it — every upload is time-stamped.", + modalHint: "T1 scans or photos. You can add more later.", + }, +}; + +function ArrivalDocumentsCard({ + kind, + bookingId, + clearance, + onView, + onChanged, +}: { + kind: ArrivalKind; + bookingId: string; + clearance: Freight.ClearanceView; + onView: (file: { name: string; url: string; mimeType?: string | null }) => void; + onChanged: () => void; +}) { + const set = ARRIVAL_SETS[kind]; + const [open, setOpen] = useState(false); + const [removingId, setRemovingId] = useState(null); + + const items = (clearance.workflowFiles ?? []).filter( + (f) => set.matches(f.code) && f.file, + ); + const train = clearance.train ?? null; + const stamps = items.map((i) => i.file?.uploadedAt); + const firstAt = earliestOf(stamps); + const lastAt = latestOf(stamps); + const arrivalToFirst = elapsed(train?.arrivedAt, firstAt); + const departureToFirst = elapsed(train?.departedAt, firstAt); + + const remove = useMutation({ + mutationFn: (file: { id: string; name: string }) => { + setRemovingId(file.id); + return transitAssignmentsService.removeTransitDocument(bookingId, file.id); + }, + onSuccess: (_r, file) => { + toast.success(`Removed ${file.name}`); + onChanged(); + }, + onError: (e: unknown) => + toast.error(e instanceof Error ? e.message : "Could not remove the document"), + onSettled: () => setRemovingId(null), + }); + + return ( + <> + 0 ? ( + + {items.length} on file + + ) : ( + + None yet + + ) + } + action={ + + } + > + + + + + + + + {items.length > 0 ? ( + + {items.map((item) => ( + remove.mutate(f)} + removing={removingId === item.file?.id} + /> + ))} + + ) : ( + {set.empty} + )} + + + + setOpen(false)} + onSuccess={onChanged} + /> + + ); +} + +function ArrivalUploadModal({ + kind, + opened, + bookingId, + existing, + onClose, + onSuccess, +}: { + kind: ArrivalKind; + opened: boolean; + bookingId: string; + existing: number; + onClose: () => void; + onSuccess: () => void; +}) { + const set = ARRIVAL_SETS[kind]; + const [files, setFiles] = useState([]); + const Icon = set.icon; + + const close = () => { + setFiles([]); + onClose(); + }; + + const submit = useMutation({ + mutationFn: () => set.upload(bookingId, files), + onSuccess: (r) => { + toast.success( + `${r.uploaded} ${set.title} document${r.uploaded === 1 ? "" : "s"} uploaded`, + ); + onSuccess(); + close(); + }, + onError: (e: unknown) => + toast.error(e instanceof Error ? e.message : "Upload failed"), + }); + + return ( + + + + + + {existing > 0 ? `Add ${set.title} documents` : `Upload ${set.title} documents`} + + + } + > + + + {existing > 0 + ? `${existing} already on file — these are added alongside them. ` + : ""} + Each file is stamped with its upload time and measured against the + train's departure and arrival. + + + + + + + + + + + ); +} + +// ── Delivery Order card (import) ───────────────────────────────────────────── + +function DeliveryOrderCard({ + bookingId, + clearance, + history, + onView, + onChanged, +}: { + bookingId: string; + clearance: Freight.ClearanceView; + history: Freight.ClearanceHistoryEvent[]; + onView: (file: { name: string; url: string; mimeType?: string | null }) => void; + onChanged: () => void; +}) { + const [open, setOpen] = useState(false); + + const doFiles = (clearance.workflowFiles ?? []).filter( + (f) => isDeliveryOrderFileCode(f.code) && f.file, + ); + const hasDo = doFiles.length > 0; + + // The DO clock starts when the booking is created. "Uploaded" is the latest + // stamp on the current DO set — replacing stores a fresh batch, so it is + // always the last update. + const createdAt = clearance.bookingCreatedAt ?? null; + const doEvents = history.filter((e) => e.action === "DELIVERY_ORDER_UPLOADED"); + const doAt = + latestOf(doFiles.map((f) => f.file?.uploadedAt)) ?? doEvents[0]?.at ?? null; + const doUpdated = doEvents.length > 1; + const bookingToDo = elapsed(createdAt, doAt); + const collected = + clearance.milestones?.find((m) => m.milestoneCode === "DO_COLLECTED") + ?.status === "COMPLETED"; + + const status = collected ? ( + } + > + Collected + + ) : hasDo ? ( + + Uploaded + + ) : ( + + Ready to upload + + ); + + return ( + <> + : } + onClick={() => setOpen(true)} + > + {hasDo ? "Replace Delivery Order" : "Upload Delivery Order"} + + } + > + + + + + + + + {hasDo ? ( + + {doFiles.map((item) => ( + + ))} + + ) : ( + + No Delivery Order on file yet. Upload the DO and record when the + vessel arrived and when the DO was collected. + + )} + + + + setOpen(false)} + onSuccess={onChanged} + /> + + ); +} + +function DeliveryOrderModal({ + opened, + bookingId, + replaceMode, + vesselArrivalDate, + doCollectedDate, + onClose, + onSuccess, +}: { + opened: boolean; + bookingId: string; + replaceMode: boolean; + vesselArrivalDate: string | null; + doCollectedDate: string | null; + onClose: () => void; + onSuccess: () => void; +}) { + const [files, setFiles] = useState([]); + const [vesselArrival, setVesselArrival] = useState( + vesselArrivalDate ? new Date(vesselArrivalDate) : null, + ); + const [collected, setCollected] = useState( + doCollectedDate ? new Date(doCollectedDate) : null, + ); + + // The DO cannot be collected before the vessel docked. + const outOfOrder = + Boolean(vesselArrival && collected) && + (toIsoDate(collected) ?? "") < (toIsoDate(vesselArrival) ?? ""); + const datesComplete = Boolean(vesselArrival && collected) && !outOfOrder; + + const close = () => { + setFiles([]); + onClose(); + }; + + const submit = useMutation({ + mutationFn: () => + transitAssignmentsService.uploadDeliveryOrder(bookingId, files, { + vesselArrivalDate: toIsoDate(vesselArrival)!, + doCollectedDate: toIsoDate(collected)!, + }), + onSuccess: () => { + toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded"); + onSuccess(); + close(); + }, + onError: (e: unknown) => + toast.error(e instanceof Error ? e.message : "Upload failed"), + }); + + return ( + + + + + + {replaceMode ? "Replace Delivery Order" : "Upload Delivery Order"} + + + } + > + + + Upload the Djibouti Delivery Order and record when the vessel arrived + and when the DO was collected. The upload time is recorded and + measured from the booking's creation. + {replaceMode + ? " Replacing removes the current DO files and records a new time." + : ""} + + + + setVesselArrival(v ? new Date(v) : null)} + size="sm" + radius="md" + required + withAsterisk + /> + setCollected(v ? new Date(v) : null)} + minDate={vesselArrival ?? undefined} + size="sm" + radius="md" + required + withAsterisk + error={outOfOrder ? "Cannot be before the vessel arrival date." : undefined} + /> + + + + + + + + + + + ); +} + +// ── T1 transport documents card (import) ──────────────────────────────────── + +function ImportT1Card({ + bookingId, + clearance, + history, + onView, + onChanged, +}: { + bookingId: string; + clearance: Freight.ClearanceView; + history: Freight.ClearanceHistoryEvent[]; + onView: (file: { name: string; url: string; mimeType?: string | null }) => void; + onChanged: () => void; +}) { + const [open, setOpen] = useState(false); + + const t1Files = (clearance.workflowFiles ?? []).filter( + (f) => isT1TransportFileCode(f.code) && f.file, + ); + const hasT1 = t1Files.length > 0; + const train = clearance.train ?? null; + const departed = Boolean(train?.departedAt); + const closed = Boolean(clearance.t1Closed); + + // Replacing stores a fresh batch, so the latest stamp is the last update. + const t1Events = history.filter((e) => e.action === "T1_DOCUMENTS_UPLOADED"); + const t1At = latestOf(t1Files.map((f) => f.file?.uploadedAt)) ?? null; + const t1Updated = t1Events.length > 1; + const departureToT1 = elapsed(train?.departedAt, t1At); + const arrivalToT1 = elapsed(train?.arrivedAt, t1At); + + const status = closed ? ( + } + > + Closed by GL Ethiopia + + ) : hasT1 ? ( + + Uploaded + + ) : departed ? ( + + Ready to upload + + ) : ( + } + > + Waiting for departure + + ); + + const locked = !departed || closed; + + return ( + <> + + + + } + > + + + + + + + + {hasT1 ? ( + + {t1Files.map((item) => ( + + ))} + + ) : ( + + {departed + ? "No T1 transport documents yet. Upload the T1 set — every file is time-stamped against departure and arrival." + : "T1 transport documents can be uploaded as soon as the train departs Djibouti."} + + )} + + + + setOpen(false)} + onSuccess={onChanged} + /> + + ); +} + +function ImportT1Modal({ + opened, + bookingId, + replaceMode, + onClose, + onSuccess, +}: { + opened: boolean; + bookingId: string; + replaceMode: boolean; + onClose: () => void; + onSuccess: () => void; +}) { + const [files, setFiles] = useState([]); + + const close = () => { + setFiles([]); + onClose(); + }; + + const submit = useMutation({ + mutationFn: () => transitAssignmentsService.uploadT1Documents(bookingId, files), + onSuccess: () => { + toast.success(replaceMode ? "T1 documents updated" : "T1 documents uploaded"); + onSuccess(); + close(); + }, + onError: (e: unknown) => + toast.error(e instanceof Error ? e.message : "Upload failed"), + }); + + return ( + + + + + + {replaceMode ? "Replace T1 transport documents" : "Upload T1 transport documents"} + + + } + > + + + Upload the T1 set for this shipment. Every file is stamped with its + upload time and measured against the train's departure and arrival. + {replaceMode + ? " Replacing removes the current T1 files and records a new time." + : ""} + + + + + + + + + + + ); +} + +// ── Panel ──────────────────────────────────────────────────────────────────── + +/** Header card shared by both directions: title, flow hint, train strip. */ +function PanelHeader({ + title, + subtitle, + flow, + train, +}: { + title: string; + subtitle: string; + flow: string[]; + train?: Freight.ClearanceTrainState | null; +}) { + return ( + + + + + + + + + {title} + + + {subtitle} + + + + + {flow.map((step, i) => ( + + {i > 0 ? : null} + + {step} + + + ))} + + + + + ); +} + +/** + * The transit agent's import paperwork on one shipment: the Delivery Order + * (timed from the booking's creation) and the T1 transport documents + * (unlocked by train departure, timed against departure and arrival). Both + * are replace-as-a-batch sets, so the stamps shown are always the last update. + */ +export function TransitImportDocumentsPanel({ + bookingId, + clearance, + history, + onView, + onChanged, +}: { + bookingId: string; + clearance: Freight.ClearanceView; + history: Freight.ClearanceHistoryEvent[]; + onView: (file: { name: string; url: string; mimeType?: string | null }) => void; + onChanged: () => void; +}) { + const queryClient = useQueryClient(); + + const refresh = () => { + void queryClient.invalidateQueries({ queryKey: ["transit-clearance"] }); + void queryClient.invalidateQueries({ queryKey: ["transit-clearance-history"] }); + onChanged(); + }; + + return ( + + + + + + + + ); +} + + +/** + * The transit agent's export paperwork on one shipment: the Release Order + * (gated on the customs declaration, timed from it), and the gate pass and + * Djibouti T1 sets collected around train arrival (each file timed against + * the train's departure and arrival). + * + * Every timestamp here comes from the server — file `uploadedAt` stamps, + * milestone `triggeredAt`, the train state — so what the officer sees is what + * the desk and the customer's reports will also see. + */ +export function TransitExportDocumentsPanel({ + bookingId, + clearance, + history, + onView, + onChanged, +}: { + bookingId: string; + clearance: Freight.ClearanceView; + history: Freight.ClearanceHistoryEvent[]; + onView: (file: { name: string; url: string; mimeType?: string | null }) => void; + onChanged: () => void; +}) { + const queryClient = useQueryClient(); + + const refresh = () => { + void queryClient.invalidateQueries({ queryKey: ["transit-clearance"] }); + void queryClient.invalidateQueries({ queryKey: ["transit-clearance-history"] }); + onChanged(); + }; + + return ( + + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/services/transit-assignments.service.ts b/apps/edr-freight-web/portal/src/services/transit-assignments.service.ts index 99925dd71..9dfed7b59 100644 --- a/apps/edr-freight-web/portal/src/services/transit-assignments.service.ts +++ b/apps/edr-freight-web/portal/src/services/transit-assignments.service.ts @@ -67,23 +67,73 @@ export interface TransitAssignmentListResult { meta: { total: number; page: number; pageSize: number; totalPages: number }; } -/** One row behind the overview's timeline and activity list. */ +export type TransitTradeDirection = "IMPORT" | "EXPORT"; +export type TransitDocumentKind = "ro" | "do" | "t1" | "gate_pass" | "djibouti_t1"; + +/** What the officer should do next on a shipment — mirrors the detail page's gates. */ +export interface TransitNextAction { + kind: "upload" | "wait" | "done"; + label: string; + document?: TransitDocumentKind; +} + +/** Minutes, or null when nothing has been measured yet — never zero. */ +export interface TransitTimingSummary { + median: number | null; + fastest: number | null; + slowest: number | null; + measured: number; +} + +export type TransitTimingKey = + | "transit" + | "declarationToRo" + | "bookingToDo" + | "departureToT1" + | "arrivalToT1" + | "arrivalToGatePass" + | "arrivalToDjiboutiT1" + | "arrivalToFinish"; + +/** One shipment on the overview: train stamps, document stamps, derived timings. */ export interface TransitStatItem { id: string; + bookingId: string; reference: string | null; customerName: string | null; + tradeDirection: TransitTradeDirection; status: TransitAssignmentStatus; schedulingStatus: string | null; - transitMinutes: number | null; - pickupMinutes: number | null; - clearanceMinutes: number | null; - documentCount: number; + trainLabel: string | null; + assignedAt: string; + startedAt: string | null; + finishedAt: string | null; + bookingCreatedAt: string | null; + departedAt: string | null; + arrivedAt: string | null; + declaredAt: string | null; + roAt: string | null; + doAt: string | null; + t1At: string | null; + t1Closed: boolean; + gatePassAt: string | null; + djiboutiT1At: string | null; + documents: { + ro: number; + do: number; + t1: number; + gatePass: number; + djiboutiT1: number; + own: number; + }; + timings: Record; + nextAction: TransitNextAction; } /** - * Overview figures, all derived server-side from existing timestamps. Every - * duration is minutes, and null means "not measurable yet" rather than zero — - * an unfinished assignment has no clearance time. + * Overview figures, all derived server-side from existing timestamps: the + * train's departure and arrival, clearance milestones, and each document's + * upload time (a replaced batch counts from its last update). */ export interface TransitStats { totals: { @@ -92,19 +142,16 @@ export interface TransitStats { notStarted: number; inProgress: number; finished: number; - readyForDocuments: number; - documents: number; + imports: number; + exports: number; + awaitingDeparture: number; + inTransit: number; + arrived: number; + actionNeeded: number; }; - performance: { - medianClearanceMinutes: number | null; - medianPickupMinutes: number | null; - fastestClearanceMinutes: number | null; - slowestClearanceMinutes: number | null; - onTimeRate: number | null; - measured: number; - }; - sla: { under2h: number; under6h: number; over6h: number }; - coverage: { dispatched: number; withDocuments: number }; + timings: Record; + documents: TransitStatItem["documents"]; + pending: { ro: number; do: number; t1: number; gatePass: number; djiboutiT1: number }; items: TransitStatItem[]; } @@ -275,7 +322,50 @@ export const transitAssignmentsService = { return data.data ?? data; }, - /** T1 transit documents; locked once the train departs. */ + // ── Export arrival paperwork (gate pass / Djibouti T1) ─────────────────── + // Append-only multi-file sets: each call ADDS files, and a file is removed on + // its own. The clearance view returns them under `workflowFiles` with the + // `gate_pass_*` / `djibouti_t1_*` codes and an `uploadedAt` stamp per file. + + uploadGatePassDocuments: async ( + bookingId: string, + files: File[], + ): Promise<{ uploaded: number }> => { + const form = new FormData(); + for (const f of files) form.append("files", f); + const { data } = await client.post( + `/api/bookings/${bookingId}/clearance/gate-pass-documents`, + form, + { headers: { "Content-Type": "multipart/form-data" } }, + ); + return data.data ?? data; + }, + + uploadDjiboutiT1Documents: async ( + bookingId: string, + files: File[], + ): Promise<{ uploaded: number }> => { + const form = new FormData(); + for (const f of files) form.append("files", f); + const { data } = await client.post( + `/api/bookings/${bookingId}/clearance/djibouti-t1-documents`, + form, + { headers: { "Content-Type": "multipart/form-data" } }, + ); + return data.data ?? data; + }, + + /** Remove one gate pass / Djibouti T1 file. Other document kinds are refused. */ + removeTransitDocument: async ( + bookingId: string, + fileId: string, + ): Promise => { + await client.delete( + `/api/bookings/${bookingId}/clearance/transit-documents/${fileId}`, + ); + }, + + /** T1 transit documents (import); locked once GL Ethiopia closes the T1. */ uploadT1Documents: async ( bookingId: string, files: File[], diff --git a/packages/types/src/freight/clearance-files.catalog.ts b/packages/types/src/freight/clearance-files.catalog.ts index 8fa50cba4..9a4468af0 100644 --- a/packages/types/src/freight/clearance-files.catalog.ts +++ b/packages/types/src/freight/clearance-files.catalog.ts @@ -99,7 +99,16 @@ export interface ClearanceWorkflowFile { label: string; uploadedBy: ClearanceWorkflowFileOwner; category: ClearanceWorkflowFileCategory; - file: { id: string; name: string; url: string } | null; + file: { + id: string; + name: string; + url: string; + /** When this file record was stored — a replaced batch carries the new stamp. */ + uploadedAt?: string | null; + updatedAt?: string | null; + size?: number | null; + mimeType?: string | null; + } | null; } const CATALOG_BY_CODE = new Map( @@ -239,6 +248,39 @@ export function exportTransportFileLabel(code: string, index?: number): string { return code; } +// ── Transit agent arrival paperwork (export) ──────────────────────────────── +// Filed by the assigned transit officer at Djibouti around train arrival. Both +// are append-only multi-file sets: each upload adds files, and a file can be +// removed on its own, unlike the DO/RO batches that replace as a whole. + +/** Multi-file Djibouti gate pass uploads use `gate_pass_0`, `gate_pass_1`, … */ +export const GATE_PASS_FILE_PREFIX = "gate_pass_"; + +export function isGatePassFileCode(code: string | null | undefined): boolean { + if (!code) return false; + return code.toLowerCase().startsWith(GATE_PASS_FILE_PREFIX); +} + +export function gatePassFileLabel(index?: number): string { + return index != null ? `Gate pass ${index + 1}` : "Gate pass"; +} + +/** Multi-file Djibouti T1 uploads use `djibouti_t1_0`, `djibouti_t1_1`, … */ +export const DJIBOUTI_T1_FILE_PREFIX = "djibouti_t1_"; + +export function isDjiboutiT1FileCode(code: string | null | undefined): boolean { + if (!code) return false; + return code.toLowerCase().startsWith(DJIBOUTI_T1_FILE_PREFIX); +} + +export function djiboutiT1FileLabel(index?: number): string { + return index != null ? `Djibouti T1 ${index + 1}` : "Djibouti T1"; +} + +/** The two transit-agent arrival document sets, keyed by the API route segment. */ +export const TRANSIT_ARRIVAL_DOCUMENT_KINDS = ["gate_pass", "djibouti_t1"] as const; +export type TransitArrivalDocumentKind = (typeof TRANSIT_ARRIVAL_DOCUMENT_KINDS)[number]; + export function catalogEntriesForTradeDirection( tradeDirection: string, ): ClearanceWorkflowFileCatalogEntry[] { diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 078352652..f1ac43c6e 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -1177,6 +1177,8 @@ export interface GlExchangeDocument { /** The clearance view for a booking, driving both portals' clearance UI. */ export interface ClearanceView { + /** When the booking was created — the import DO clock starts here. */ + bookingCreatedAt?: string | null; status: string; includesCustoms: boolean; inputCode: string | null;