feat(transit-agent): timed document uploads and a real-data overview

This commit is contained in:
marshal
2026-09-02 23:36:35 +00:00
parent 42ca020a1f
commit 3fd94c9314
20 changed files with 3717 additions and 1152 deletions

View File

@@ -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(

View File

@@ -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<string, unknown> | 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<void> {
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,

View File

@@ -482,6 +482,7 @@ export class ContractClearanceService {
status: m.status,
ownerRegion: m.ownerRegion,
metadata: (m.metadata ?? null) as Record<string, unknown> | null,
triggeredAt: m.triggeredAt ? new Date(m.triggeredAt).toISOString() : null,
sortOrder: m.sortOrder,
})),
nextAction,

View File

@@ -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')

View File

@@ -50,6 +50,7 @@ describe('GlOperationsService — final invoice approval', () => {
{} as never, // milestoneService
billingService as never,
notifier as never,
{ record: jest.fn() } as never, // clearanceEvents
);
});

View File

@@ -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 };
}

View File

@@ -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<unknown>;
};
@@ -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<void> {
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<ClearanceWorkflowFile['file']> {
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;

View File

@@ -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,
],

View File

@@ -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<TransitAssignment> = {}) =>
({
@@ -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<string, unknown>[]) => {
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<string, Array<[string, string]>>) => {
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);
});
});
});

View File

@@ -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<keyof TransitStatItem["timings"], TransitTimingSummary>;
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<Booking>,
private readonly filesService: FilesService,
@InjectRepository(ClearanceMilestone)
private readonly milestonesRepository: Repository<ClearanceMilestone>,
@InjectRepository(TrainSchedule)
private readonly trainSchedulesRepository: Repository<TrainSchedule>,
) {}
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<TransitStats> {
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<string, unknown[]>();
const [ownDocs, bookingDocs, milestones, schedules] = await Promise.all([
rows.length
? this.filesService.findByResourceIdsGrouped(
rows.map((r) => r.id),
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
)
: new Map<string, FileRecord[]>(),
bookingIds.length
? this.filesService.findByResourceIdsGrouped(bookingIds, "bookings")
: new Map<string, FileRecord[]>(),
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<string, ClearanceMilestone[]>();
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<string | null>((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<string | null>((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<number | null>): 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),
};
}

View File

@@ -782,7 +782,7 @@ export function PhasedClearanceActionPanel({
<Stepper.Step
label="T1 transport documents"
description="GL Djibouti uploads after the gate pass is secured"
description="GL Djibouti uploads after the train departs"
icon={
t1Uploaded || clearance.t1?.closed ? (
<CheckCircle2 size={14} />

View File

@@ -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<HTMLInputElement>(null);
const [dragOver, setDragOver] = useState(false);
// One object URL per staged image, revoked when the list changes.
const thumbs = useMemo(() => {
const map = new Map<string, string>();
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 (
<Stack gap={8}>
<Box>
<Text fz={13} fw={700} style={{ color: INK }}>
{label}
</Text>
{description ? (
<Text fz={12} c="dimmed" mt={2}>
{description}
</Text>
) : null}
</Box>
<input
ref={inputRef}
type="file"
multiple
accept={accept}
hidden
disabled={disabled}
onChange={(e) => add(Array.from(e.target.files ?? []))}
/>
<Box
onDragOver={(e) => {
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",
}}
>
<Stack gap={8} align="center">
<ThemeIcon
variant="light"
color={dragOver ? "edr-green" : hasFiles ? "edr-green" : "gray"}
radius="xl"
size={hasFiles ? 40 : 48}
>
{hasFiles ? <Plus size={20} /> : <UploadCloud size={24} />}
</ThemeIcon>
<Box>
<Text fz={13} fw={600} style={{ color: INK }}>
{dragOver
? "Drop to add"
: hasFiles
? "Add more files"
: "Drag & drop your files here"}
</Text>
<Text fz={12} c="dimmed" mt={4}>
or <span style={{ color: GREEN, fontWeight: 700 }}>browse</span> {" "}
{acceptHint}. Add as many as you need.
</Text>
</Box>
</Stack>
</Box>
{hasFiles ? (
<Stack gap={6}>
<Group justify="space-between" px={2}>
<Text fz={11} fw={700} c="edr-green" tt="uppercase">
{files.length} file{files.length === 1 ? "" : "s"} ready to upload
</Text>
<UnstyledButton
onClick={() => onChange([])}
disabled={disabled}
style={{ fontSize: 11.5, color: "#6B7C8E", fontWeight: 600 }}
>
Clear all
</UnstyledButton>
</Group>
{files.map((f) => {
const key = fileKey(f);
const thumb = thumbs.get(key);
return (
<Group
key={key}
gap={12}
wrap="nowrap"
p="xs"
style={{
borderRadius: 12,
border: `1px solid ${GREEN}`,
background: "linear-gradient(135deg, #F2FBF6 0%, #fff 75%)",
minWidth: 0,
}}
>
{thumb ? (
<Box
style={{
width: 40,
height: 40,
flexShrink: 0,
borderRadius: 8,
overflow: "hidden",
border: `1px solid ${BORDER}`,
}}
>
<img
src={thumb}
alt=""
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</Box>
) : (
<ThemeIcon variant="light" color="edr-green" radius="md" size={40}>
<FileText size={18} />
</ThemeIcon>
)}
<Box style={{ minWidth: 0, flex: 1 }}>
<Text fz={13} fw={600} style={{ color: INK }} truncate>
{f.name}
</Text>
<Text fz={11} c="dimmed">
{formatBytes(f.size)}
</Text>
</Box>
<ActionIcon
variant="subtle"
color="red"
radius="md"
aria-label={`Remove ${f.name}`}
onClick={() => remove(f)}
disabled={disabled}
>
<Trash2 size={15} />
</ActionIcon>
</Group>
);
})}
</Stack>
) : null}
</Stack>
);
}

View File

@@ -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() {
</div>
</Group>
<Tooltip
label="Documents are locked once you finish the assignment"
disabled={!locked}
withArrow
>
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={() => setDocsOpen(true)}
>
{locked ? "View my documents" : "My documents"}
</Button>
</Tooltip>
</Group>
</Stack>
@@ -692,6 +676,30 @@ export default function TransitAgentBookingDetailPage() {
{/* ── Clearance workflow ─────────────────────────────────── */}
<Tabs.Panel value="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 ? (
<Box mb="md">
{booking.tradeDirection === "IMPORT" ? (
<TransitImportDocumentsPanel
bookingId={bookingId}
clearance={clearance}
history={historyEvents}
onView={view}
onChanged={() => void assignmentQuery.refetch()}
/>
) : (
<TransitExportDocumentsPanel
bookingId={bookingId}
clearance={clearance}
history={historyEvents}
onView={view}
onChanged={() => void assignmentQuery.refetch()}
/>
)}
</Box>
) : null}
<Grid>
<Grid.Col span={{ base: 12, lg: 7 }}>
<SectionCard icon={ClipboardList} title="Clearance status">
@@ -1174,10 +1182,6 @@ export default function TransitAgentBookingDetailPage() {
onShared={() => void exchangeQuery.refetch()}
/>
<TransitAgentDocumentsModal
assignment={docsOpen ? (assignment as TransitAssignment) : null}
onClose={() => setDocsOpen(false)}
/>
{viewer}
</Box>
);

View File

@@ -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<T>({
*/
export default function TransitAgentBookingsPage() {
const navigate = useNavigate();
const [active, setActive] = useState<TransitAssignment | null>(null);
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [tab, setTab] = useState<TabKey>("all");
@@ -506,30 +502,6 @@ export default function TransitAgentBookingsPage() {
</Group>
),
},
{
id: "action",
header: () => <span className={headerCell}>Documents</span>,
cell: ({ row }) => {
const locked = !row.original.canUploadDocuments;
return (
<Button
size="compact-sm"
radius="md"
variant={locked ? "subtle" : "light"}
color={locked ? "gray" : undefined}
leftSection={
locked ? <Lock size={13} /> : <FileText size={13} />
}
onClick={(e) => {
e.stopPropagation();
setActive(row.original);
}}
>
{locked ? "View" : "Documents"}
</Button>
);
},
},
{
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() {
</Stack>
</Card>
</Stack>
<TransitAgentDocumentsModal
assignment={active}
onClose={() => setActive(null)}
/>
</Box>
);
}

View File

@@ -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 (
<Stack gap={6}>
<Text fz={13} fw={600}>
{label}
</Text>
<Text fz={11.5} c="dimmed">
{description}
</Text>
<input
type="file"
multiple
accept={accept}
onChange={(e) => 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 ? (
<Group gap={6}>
{files.map((f) => (
<Badge key={f.name} size="sm" variant="light" radius="sm" tt="none">
{f.name}
</Badge>
))}
</Group>
) : null}
</Stack>
);
}
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<UploadKind | null>(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({
))}
</Stepper>
{/* 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. */}
<Stack gap={8}>
{isImport ? (
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
onClick={() => setUploadKind("do")}
>
{hasDo ? "Replace Delivery Order" : "Upload Delivery Order"}
</Button>
) : (
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
onClick={() => setUploadKind("ro")}
>
{hasRo ? "Replace Release Order" : "Upload Release Order"}
</Button>
)}
<Button
variant="light"
radius="md"
leftSection={<FileStack size={15} />}
onClick={() => setT1Open(true)}
>
Upload T1 documents
</Button>
{!isImport ? (
<Button
variant="subtle"
@@ -464,23 +350,6 @@ export function TransitClearanceActionPanel({
</Box>
</Card>
<UploadOrderModal
kind={uploadKind}
bookingId={bookingId}
replaceMode={uploadKind === "do" ? hasDo : hasRo}
vesselArrivalDate={clearance?.vesselArrivalDate ?? null}
doCollectedDate={clearance?.doCollectedDate ?? null}
onClose={() => setUploadKind(null)}
onSuccess={refresh}
/>
<T1UploadModal
opened={t1Open}
bookingId={bookingId}
onClose={() => setT1Open(false)}
onSuccess={refresh}
/>
<RoAmendmentModal
opened={amendOpen}
bookingId={bookingId}
@@ -491,270 +360,6 @@ export function TransitClearanceActionPanel({
);
}
/** DO/RO upload — mirrors the backoffice's GlClearanceUploadModal. */
function UploadOrderModal({
kind,
bookingId,
replaceMode,
vesselArrivalDate,
doCollectedDate,
onClose,
onSuccess,
}: {
kind: UploadKind | null;
bookingId: string;
replaceMode: boolean;
vesselArrivalDate: string | null;
doCollectedDate: string | null;
onClose: () => void;
onSuccess: () => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const [vesselDate, setVesselDate] = useState<Date | null>(null);
const [doDates, setDoDates] = useState<DoDates>({
vesselArrival: vesselArrivalDate ? new Date(vesselArrivalDate) : null,
doCollected: doCollectedDate ? new Date(doCollectedDate) : null,
});
const isDo = kind === "do";
const today = todayMidnight();
const doMin =
doDates.vesselArrival && doDates.vesselArrival > today
? doDates.vesselArrival
: today;
const outOfOrder =
Boolean(doDates.vesselArrival && doDates.doCollected) &&
!doDatesComplete(doDates);
const close = () => {
setFiles([]);
onClose();
};
const submit = useMutation({
mutationFn: async () => {
if (isDo) {
return transitAssignmentsService.uploadDeliveryOrder(bookingId, files, {
vesselArrivalDate: toIsoDate(doDates.vesselArrival)!,
doCollectedDate: toIsoDate(doDates.doCollected)!,
});
}
return transitAssignmentsService.uploadReleaseOrder(
bookingId,
files,
toIsoDate(vesselDate)!,
);
},
onSuccess: (result) => {
// The RO endpoint answers with a hold instead of an error when the vessel
// date is too soon — say so rather than reporting a clean success.
if (result && typeof result === "object" && "hold" in result && result.hold) {
toast.error(result.holdReason ?? "Vessel date too soon");
} else {
toast.success(
isDo
? replaceMode
? "Delivery Order updated"
: "Delivery Order uploaded"
: replaceMode
? "Release Order updated"
: "Release Order uploaded",
);
}
setFiles([]);
onSuccess();
close();
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Upload failed"),
});
const blocked =
files.length === 0 ||
(isDo ? !doDatesComplete(doDates) : !vesselDate);
return (
<Modal
opened={kind != null}
onClose={close}
radius="md"
size="md"
title={
<Group gap={8}>
<Ship size={18} />
<Text fw={700}>
{isDo ? "Upload Delivery Order" : "Upload Release Order"}
</Text>
</Group>
}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{isDo
? "Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected. Both dates are required."
: "Upload the Release Order and confirm the vessel departure date."}
</Text>
{isDo ? (
<Group grow align="flex-start" gap="sm" wrap="wrap">
<DateInput
label="Vessel arrival date"
placeholder="Select date"
value={doDates.vesselArrival}
onChange={(v) =>
setDoDates((d) => ({
...d,
vesselArrival: v ? new Date(v) : null,
}))
}
minDate={today}
size="sm"
required
withAsterisk
/>
<DateInput
label="DO collected date"
placeholder="Select date"
value={doDates.doCollected}
onChange={(v) =>
setDoDates((d) => ({
...d,
doCollected: v ? new Date(v) : null,
}))
}
minDate={doMin}
size="sm"
required
withAsterisk
error={
outOfOrder
? "Cannot be before the vessel arrival date."
: undefined
}
/>
</Group>
) : (
<DateInput
label="Vessel departure date"
placeholder="Select date"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
minDate={today}
size="sm"
required
/>
)}
<FilePicker
label={isDo ? "Delivery Order files" : "Release Order files"}
description={
isDo
? "Any file type. Add as many files as needed."
: "PDF or image. Add as many files as needed."
}
accept={isDo ? undefined : "application/pdf,image/*"}
files={files}
onChange={setFiles}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={close}
disabled={submit.isPending}
>
Cancel
</Button>
<Button
color="edr-green"
loading={submit.isPending}
disabled={blocked}
leftSection={<Upload size={16} />}
onClick={() => submit.mutate()}
>
{replaceMode
? isDo
? "Replace DO"
: "Replace RO"
: isDo
? "Upload DO"
: "Upload RO"}
</Button>
</Group>
</Stack>
</Modal>
);
}
function T1UploadModal({
opened,
bookingId,
onClose,
onSuccess,
}: {
opened: boolean;
bookingId: string;
onClose: () => void;
onSuccess: () => void;
}) {
const [files, setFiles] = useState<File[]>([]);
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 (
<Modal
opened={opened}
onClose={onClose}
radius="md"
size="md"
title={
<Group gap={8}>
<FileStack size={18} />
<Text fw={700}>Upload T1 transit documents</Text>
</Group>
}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
T1 documents are filed after wagon allocation and lock once the train
departs.
</Text>
<FilePicker
label="T1 documents"
description="Any file type. Add as many files as needed."
files={files}
onChange={setFiles}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={submit.isPending}>
Cancel
</Button>
<Button
color="edr-green"
loading={submit.isPending}
disabled={files.length === 0}
leftSection={<Upload size={16} />}
onClick={() => submit.mutate()}
>
Upload
</Button>
</Group>
</Stack>
</Modal>
);
}
function RoAmendmentModal({
opened,
bookingId,

File diff suppressed because it is too large Load Diff

View File

@@ -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<TransitTimingKey, number | null>;
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<TransitTimingKey, TransitTimingSummary>;
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<void> => {
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[],

View File

@@ -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[] {

View File

@@ -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;