Merge pull request #1480 from Tria-plc/origin/freight_feature/transit

Origin/freight feature/transit
This commit is contained in:
marshal
2026-09-03 02:37:27 +03:00
committed by GitHub
33 changed files with 6771 additions and 1067 deletions

View File

@@ -6,6 +6,7 @@ import {
ForbiddenException, ForbiddenException,
Get, Get,
HttpCode, HttpCode,
NotFoundException,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Patch, Patch,
@@ -786,6 +787,64 @@ export class BookingsController {
} }
/** Owner-or-staff gate shared by the per-cancellation actions. */ /** Owner-or-staff gate shared by the per-cancellation actions. */
/**
* Scope a clearance READ that a transit agent may be making.
*
* Transit agents are portal accounts holding no permission and belonging to
* no company, so the audience guards admit them but the usual company-based
* ownership check would 404 every booking. This narrows them to the shipments
* assigned to them and leaves every other caller — staff and owning customers
* — on the path they already had. Purely widening: nothing that passed before
* starts failing here.
*/
private async assertTransitAgentScope(
bookingId: string,
user: TCurrentUser,
): Promise<void> {
const userId = user?.id;
if (!userId) return;
if (!(await this.bookingsService.isTransitAgent(userId))) return;
if (
!(await this.bookingsService.isTransitAgentForBooking(userId, bookingId))
) {
// Hidden behind a NotFound so booking ids stay unprobeable, matching the
// customer-ownership failure mode.
throw new NotFoundException(`Booking ${bookingId} not found`);
}
}
/**
* Gate a formerly staff-only clearance route that is now MixedAudience.
*
* Staff still pass on their permission. A portal caller must be a transit
* agent assigned to THIS booking — an ordinary customer is rejected, because
* relaxing the guard must not hand the whole customer base a route that was
* previously staff-only.
*
* Used for the Djibouti-desk WRITES too (DO/RO upload, RO amendment): the
* assigned agent files them in the desk's place, and the assignment is the
* only thing standing between a portal token and the customs record.
*/
private async assertPortalClearanceAccess(
bookingId: string,
user: TCurrentUser,
): Promise<void> {
if (
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
) {
return;
}
if (
!(await this.bookingsService.isTransitAgentForBooking(
user?.id,
bookingId,
))
) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
}
private async assertWagonCancellationActor( private async assertWagonCancellationActor(
cancellationId: string, cancellationId: string,
user: TCurrentUser, user: TCurrentUser,
@@ -1139,6 +1198,9 @@ export class BookingsController {
} }
@Get(":id/clearance") @Get(":id/clearance")
// A transit agent is a portal account, so MixedAudience admits them without a
// permission; `assertTransitAgentScope` below narrows them to the shipments
// actually assigned to them.
@MixedAudience([ @MixedAudience([
FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.clearanceView,
FREIGHT_PERMS.bookings.reviewDocuments, FREIGHT_PERMS.bookings.reviewDocuments,
@@ -1147,7 +1209,11 @@ export class BookingsController {
summary: summary:
"Document-clearance grid (required docs + upload + GL review status)", "Document-clearance grid (required docs + upload + GL review status)",
}) })
getClearance(@Param("id", ParseUUIDPipe) id: string) { async getClearance(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertTransitAgentScope(id, user);
return this.transitionService.getClearanceView(id); return this.transitionService.getClearanceView(id);
} }
@@ -1289,8 +1355,11 @@ export class BookingsController {
return { success: true }; return { success: true };
} }
// Was staff-only. Opened to the transit agent assigned to the shipment, who
// needs the clearance trail for the bookings they handle; every other portal
// account is still rejected by the scope check below.
@Get(":id/clearance/history") @Get(":id/clearance/history")
@BookingStaff([ @MixedAudience([
FREIGHT_PERMS.contracts.clearanceEtActions, FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions, FREIGHT_PERMS.contracts.clearanceDjActions,
]) ])
@@ -1298,7 +1367,11 @@ export class BookingsController {
summary: summary:
"Clearance action history for the booking — reviews, workflow steps, charges (newest first)", "Clearance action history for the booking — reviews, workflow steps, charges (newest first)",
}) })
getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) { async getClearanceHistory(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertPortalClearanceAccess(id, user);
return this.clearanceEventService.list(id); return this.clearanceEventService.list(id);
} }
@@ -1321,6 +1394,12 @@ export class BookingsController {
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) || hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions); hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
if (isStaff) return this.clearanceChargeService.list(id); if (isStaff) return this.clearanceChargeService.list(id);
// The transit agent handling this shipment sees the same customer-facing
// slice the customer does — charges actually sent, never the internal
// draft/billing view `list()` returns.
if (await this.bookingsService.isTransitAgentForBooking(user?.id, id)) {
return this.clearanceChargeService.listForCustomer(id);
}
const booking = await this.bookingsService.findById(id); const booking = await this.bookingsService.findById(id);
await this.bookingsService.assertCustomerCanAccessBooking( await this.bookingsService.assertCustomerCanAccessBooking(
user?.id, user?.id,
@@ -1788,8 +1867,10 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
// Djibouti-desk write, also filed by the transit agent assigned to this
// shipment — `assertPortalClearanceAccess` rejects every other portal caller.
@Post(":id/clearance/delivery-order") @Post(":id/clearance/delivery-order")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
async uploadBookingDeliveryOrder( async uploadBookingDeliveryOrder(
@@ -1799,6 +1880,7 @@ export class BookingsController {
@Body("doCollectedDate") doCollectedDate: string | undefined, @Body("doCollectedDate") doCollectedDate: string | undefined,
@CurrentUser() user: TCurrentUser, @CurrentUser() user: TCurrentUser,
) { ) {
await this.assertPortalClearanceAccess(id, user);
const booking = await this.bookingClearanceService.uploadDeliveryOrder( const booking = await this.bookingClearanceService.uploadDeliveryOrder(
id, id,
files ?? [], files ?? [],
@@ -1809,7 +1891,7 @@ export class BookingsController {
} }
@Post(":id/clearance/release-order") @Post(":id/clearance/release-order")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
async uploadBookingReleaseOrder( async uploadBookingReleaseOrder(
@@ -1818,6 +1900,7 @@ export class BookingsController {
@Body("vesselDepartureDate") vesselDepartureDate: string, @Body("vesselDepartureDate") vesselDepartureDate: string,
@CurrentUser() user: TCurrentUser, @CurrentUser() user: TCurrentUser,
) { ) {
await this.assertPortalClearanceAccess(id, user);
const result = await this.bookingClearanceService.uploadReleaseOrder( const result = await this.bookingClearanceService.uploadReleaseOrder(
id, id,
files ?? [], files ?? [],
@@ -1831,13 +1914,69 @@ 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") @Post(":id/clearance/ro-amendment")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
async requestBookingRoAmendment( async requestBookingRoAmendment(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: RoAmendmentDto, @Body() dto: RoAmendmentDto,
@CurrentUser() user: TCurrentUser, @CurrentUser() user: TCurrentUser,
) { ) {
await this.assertPortalClearanceAccess(id, user);
const booking = await this.bookingClearanceService.requestRoAmendment( const booking = await this.bookingClearanceService.requestRoAmendment(
id, id,
dto.note, dto.note,

View File

@@ -2065,6 +2065,65 @@ ${footer}
} }
} }
/**
* True when `userId` is a transit agent currently assigned to this booking.
*
* Deliberately NOT folded into {@link assertCustomerCanAccessBooking}: that
* assertion guards ~29 call sites, including wagon cancellations, rebooking
* and customer-truck writes. A transit agent must reach the clearance READS
* for the shipments they handle and nothing else, so the two ownership rules
* stay separate and each caller opts in explicitly.
*
* Queried directly rather than through TransitAssignmentsService: that module
* imports BookingsModule, so injecting it here would close an import cycle.
*/
/** Is this portal account a transit agent at all? */
async isTransitAgent(userId: string | undefined): Promise<boolean> {
if (!userId) return false;
const rows: { one: number }[] = await this.dataSource.query(
`SELECT 1 AS one
FROM freight.transit_agents a
WHERE a.user_id = $1 AND a.deleted_at IS NULL
LIMIT 1`,
[userId],
);
return rows.length > 0;
}
async isTransitAgentForBooking(
userId: string | undefined,
bookingId: string,
): Promise<boolean> {
if (!userId) return false;
const rows: { one: number }[] = await this.dataSource.query(
`SELECT 1 AS one
FROM freight.transit_assignments ta
JOIN freight.transit_agents a ON a.id = ta.transit_agent_id
WHERE a.user_id = $1
AND ta.booking_id = $2
AND ta.deleted_at IS NULL
AND a.deleted_at IS NULL
LIMIT 1`,
[userId, bookingId],
);
return rows.length > 0;
}
/**
* Authorize a clearance READ on one booking for either audience a portal
* account can be: the owning customer, or a transit agent assigned to it.
*
* Read-only by contract — every caller is a GET. Writes keep using
* {@link assertCustomerCanAccessBooking}, which a transit agent never passes.
*/
async assertCanReadBookingClearance(
userId: string | undefined,
booking: Booking,
): Promise<void> {
if (await this.isTransitAgentForBooking(userId, booking.id)) return;
await this.assertCustomerCanAccessBooking(userId, booking);
}
/** /**
* Build the customer-facing shipment tracking payload for a booking from the * Build the customer-facing shipment tracking payload for a booking from the
* train schedule it is assigned to and the live checkpoint log. The caller is * train schedule it is assigned to and the live checkpoint log. The caller is

View File

@@ -116,6 +116,7 @@ function makeService(overrides?: {
.fn() .fn()
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }), .mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
} as never, // transit agents } as never, // transit agents
{ ensureAssignment: jest.fn() } as never, // transit assignments
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository { findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope { getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
{ record: jest.fn() } as never, // clearanceEvents { record: jest.fn() } as never, // clearanceEvents

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { In } from 'typeorm'; import { In } from 'typeorm';
import { import {
ContractDocPhase, ContractDocPhase,
@@ -30,10 +30,12 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service'; import { GlOperationsService } from './gl-operations.service';
import { GlExchangeService } from './gl-exchange.service'; import { GlExchangeService } from './gl-exchange.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.service'; import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service';
import { YardScopeService } from '../rule-engine/services/yard-scope.service'; import { YardScopeService } from '../rule-engine/services/yard-scope.service';
import { ContractsRepository } from './contracts.repository'; import { ContractsRepository } from './contracts.repository';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; 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 { import {
buildClearanceDocHistory, buildClearanceDocHistory,
@@ -45,6 +47,8 @@ import { clearanceDocumentsOpen } from '../bookings/clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
export interface BookingClearanceView { export interface BookingClearanceView {
/** Booking creation stamp — the import DO is timed from it. */
bookingCreatedAt?: string | null;
bookingId: string; bookingId: string;
status: string; status: string;
includesCustoms: boolean; includesCustoms: boolean;
@@ -176,6 +180,7 @@ export class BookingClearanceService {
private readonly notifier: BookingLifecycleNotifierService, private readonly notifier: BookingLifecycleNotifierService,
private readonly glExchangeService: GlExchangeService, private readonly glExchangeService: GlExchangeService,
private readonly transitAgentsService: TransitAgentsService, private readonly transitAgentsService: TransitAgentsService,
private readonly transitAssignmentsService: TransitAssignmentsService,
private readonly contractsRepository: ContractsRepository, private readonly contractsRepository: ContractsRepository,
private readonly yardScope: YardScopeService, private readonly yardScope: YardScopeService,
private readonly clearanceEvents: ClearanceEventService, private readonly clearanceEvents: ClearanceEventService,
@@ -365,6 +370,7 @@ export class BookingClearanceService {
return { return {
bookingId, bookingId,
status: booking.status, status: booking.status,
bookingCreatedAt: booking.createdAt ? new Date(booking.createdAt).toISOString() : null,
includesCustoms, includesCustoms,
inputCode, inputCode,
outputCode, outputCode,
@@ -385,6 +391,7 @@ export class BookingClearanceService {
status: m.status, status: m.status,
ownerRegion: m.ownerRegion, ownerRegion: m.ownerRegion,
metadata: (m.metadata ?? null) as Record<string, unknown> | null, metadata: (m.metadata ?? null) as Record<string, unknown> | null,
triggeredAt: m.triggeredAt ? new Date(m.triggeredAt).toISOString() : null,
sortOrder: m.sortOrder, sortOrder: m.sortOrder,
})), })),
nextAction, nextAction,
@@ -593,6 +600,17 @@ export class BookingClearanceService {
transitAssigneeName: agent.name, transitAssigneeName: agent.name,
transitAssigneeAssignedAt: new Date(), transitAssigneeAssignedAt: new Date(),
} as never); } as never);
// The booking only stores the officer's NAME, which is what the clearance
// UI reads. The agent's own portal works off `transit_assignments` rows, so
// without this the shipment never reaches the officer's work list — the
// desk believes it handed the job over and nothing arrives.
await this.transitAssignmentsService.ensureAssignment(
bookingId,
transitAgentId,
userId,
);
await this.clearanceEvents.record({ await this.clearanceEvents.record({
bookingId, bookingId,
action: 'TRANSIT_ASSIGNEE_ASSIGNED', action: 'TRANSIT_ASSIGNEE_ASSIGNED',
@@ -1161,6 +1179,83 @@ export class BookingClearanceService {
return { booking: await this.bookingsService.findById(bookingId), hold: false }; 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( async requestRoAmendment(
bookingId: string, bookingId: string,
note?: string, note?: string,

View File

@@ -31,6 +31,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractNotifierService } from './contract-notifier.service'; import { ContractNotifierService } from './contract-notifier.service';
import { GlOperationsService } from './gl-operations.service'; import { GlOperationsService } from './gl-operations.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.service'; import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service';
import { import {
ClearanceMilestone, ClearanceMilestone,
type RiskAssignmentRecord, type RiskAssignmentRecord,
@@ -185,6 +186,7 @@ export class ContractClearanceService {
private readonly glOperationsService: GlOperationsService, private readonly glOperationsService: GlOperationsService,
private readonly notifier: ContractNotifierService, private readonly notifier: ContractNotifierService,
private readonly transitAgentsService: TransitAgentsService, private readonly transitAgentsService: TransitAgentsService,
private readonly transitAssignmentsService: TransitAssignmentsService,
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
) {} ) {}
@@ -480,6 +482,7 @@ export class ContractClearanceService {
status: m.status, status: m.status,
ownerRegion: m.ownerRegion, ownerRegion: m.ownerRegion,
metadata: (m.metadata ?? null) as Record<string, unknown> | null, metadata: (m.metadata ?? null) as Record<string, unknown> | null,
triggeredAt: m.triggeredAt ? new Date(m.triggeredAt).toISOString() : null,
sortOrder: m.sortOrder, sortOrder: m.sortOrder,
})), })),
nextAction, nextAction,
@@ -1230,6 +1233,18 @@ export class ContractClearanceService {
transitAssigneeAssignedByUserId: userId ?? null, transitAssigneeAssignedByUserId: userId ?? null,
}); });
// Mirror the name onto the officer's own work list, exactly as the
// per-booking path does. Contract-level clearance can be assigned before a
// booking exists; in that case there is nothing for the officer to work on
// yet, and the booking picks the assignment up when it is created.
if (cycle.bookingId) {
await this.transitAssignmentsService.ensureAssignment(
cycle.bookingId,
transitAgentId,
userId,
);
}
const updated = await this.contractsService.findById(contractId); const updated = await this.contractsService.findById(contractId);
this.notifier.transitAssigneeAssigned(updated, agent.name, previous); this.notifier.transitAssigneeAssigned(updated, agent.name, previous);
return updated; return updated;

View File

@@ -63,6 +63,7 @@ describe('ContractClearanceService — duty dispute', () => {
{} as never, // glOperationsService {} as never, // glOperationsService
notifier as never, notifier as never,
{} as never, // transitAgentsService {} as never, // transitAgentsService
{} as never, // transitAssignmentsService
{} as never, // dataSource {} as never, // dataSource
); );
build([ build([

View File

@@ -4,6 +4,7 @@ import {
Delete, Delete,
Get, Get,
HttpCode, HttpCode,
NotFoundException,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Patch, Patch,
@@ -1359,19 +1360,35 @@ export class ContractsController {
return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []); return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []);
} }
// Also filed by the transit agent assigned to the shipment — T1 is their own
// transit paperwork. Any other portal caller is rejected below.
@Post('bookings/:bookingId/t1-documents') @Post('bookings/:bookingId/t1-documents')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiOperation({ @ApiOperation({
summary: summary:
'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs', 'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs',
}) })
uploadT1Documents( async uploadT1Documents(
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFiles() files: Express.Multer.File[], @UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) { ) {
return this.glOperationsService.uploadT1Documents(bookingId, files ?? []); if (
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) &&
!(await this.bookingsService.isTransitAgentForBooking(
user?.id,
bookingId,
))
) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
return this.glOperationsService.uploadT1Documents(
bookingId,
files ?? [],
resolveAuthUserId(user),
);
} }
@Post('bookings/:bookingId/t1-close') @Post('bookings/:bookingId/t1-close')
@@ -1534,6 +1551,8 @@ export class ContractsController {
@MixedAudience(FREIGHT_PERMS.contracts.view) @MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' }) @ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' })
listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) { listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
// Reads are open to both audiences (a transit agent assigned to the
// shipment included); reporting an incident stays staff-only below.
return this.glOperationsService.listIncidents(bookingId); return this.glOperationsService.listIncidents(bookingId);
} }

View File

@@ -18,6 +18,7 @@ import { BookingsModule } from '../bookings/bookings.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { ContractTemplatesModule } from '../contract-templates/contract-templates.module'; import { ContractTemplatesModule } from '../contract-templates/contract-templates.module';
import { TransitAgentsModule } from '../transit-agents/transit-agents.module'; import { TransitAgentsModule } from '../transit-agents/transit-agents.module';
import { TransitAssignmentsModule } from '../transit-assignments/transit-assignments.module';
import { ContractsController } from './contracts.controller'; import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service'; import { ContractsService } from './contracts.service';
@@ -94,6 +95,10 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
// ContractDocumentViewModelBuilder when rendering contract PDFs. // ContractDocumentViewModelBuilder when rendering contract PDFs.
ContractTemplatesModule, ContractTemplatesModule,
TransitAgentsModule, TransitAgentsModule,
// Assigning a transit assignee must also land a row in the officer's own
// work list. This module is a leaf (it registers Booking as an entity
// rather than importing BookingsModule), so no cycle is closed here.
TransitAssignmentsModule,
// BookingsModule provides BookingsRepository/BookingPricingService used by the // BookingsModule provides BookingsRepository/BookingPricingService used by the
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
forwardRef(() => BookingsModule), forwardRef(() => BookingsModule),

View File

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

View File

@@ -4,6 +4,7 @@ import {
Delete, Delete,
Get, Get,
HttpCode, HttpCode,
NotFoundException,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Patch, Patch,
@@ -17,10 +18,11 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { actorLabel } from '../warehouses/current-actor.util'; import { actorLabel } from '../warehouses/current-actor.util';
import { BookingStaff } from '../../common/booking-guards'; import { BookingStaff, MixedAudience } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { hasFreightPermission } from '../../common/freight-permission.util'; import { hasFreightPermission } from '../../common/freight-permission.util';
import { resolveAuthUserId } from '../../common/resolve-auth-user-id'; import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
import { BookingsService } from '../bookings/bookings.service';
import { import {
GlExchangeService, GlExchangeService,
@@ -42,37 +44,61 @@ const asBool = (raw: string | boolean | undefined): boolean =>
@ApiBearerAuth() @ApiBearerAuth()
@Controller('gl-exchange') @Controller('gl-exchange')
export class GlExchangeController { export class GlExchangeController {
constructor(private readonly exchangeService: GlExchangeService) {} constructor(
private readonly exchangeService: GlExchangeService,
private readonly bookingsService: BookingsService,
) {}
// Read opened to the transit agent assigned to the shipment; the POST/PATCH/
// DELETE below stay staff-only, so an agent can read the desks' thread but
// never post to it.
@Get(':entityId') @Get(':entityId')
@BookingStaff(GL_EXCHANGE_PERMS) @MixedAudience(GL_EXCHANGE_PERMS)
@ApiOperation({ @ApiOperation({
summary: 'GL ET ↔ GL DJ shared documents for a booking or contract', summary: 'GL ET ↔ GL DJ shared documents for a booking or contract',
}) })
list( async list(
@Param('entityId', ParseUUIDPipe) entityId: string, @Param('entityId', ParseUUIDPipe) entityId: string,
@CurrentUser() user: TCurrentUser, @CurrentUser() user: TCurrentUser,
) { ) {
const isStaff = GL_EXCHANGE_PERMS.some((p) =>
hasFreightPermission(user, p),
);
if (
!isStaff &&
!(await this.bookingsService.isTransitAgentForBooking(
user?.id,
entityId,
))
) {
throw new NotFoundException(`Entity ${entityId} not found`);
}
return this.exchangeService.list(entityId, resolveAuthUserId(user)); return this.exchangeService.list(entityId, resolveAuthUserId(user));
} }
// Open to the transit agent assigned to the shipment as well as both desks:
// the officer on the ground is often the one holding the scan either desk
// needs. Their post is attributed to the TRANSIT side, never to a desk.
@Post(':entityId') @Post(':entityId')
@BookingStaff(GL_EXCHANGE_PERMS) @MixedAudience(GL_EXCHANGE_PERMS)
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Share a document with the other GL desk' }) @ApiOperation({
upload( summary: 'Share a document with the GL desks (either desk, or the assigned transit agent)',
})
async upload(
@Param('entityId', ParseUUIDPipe) entityId: string, @Param('entityId', ParseUUIDPipe) entityId: string,
@UploadedFile() file: Express.Multer.File | undefined, @UploadedFile() file: Express.Multer.File | undefined,
@Body('title') title: string, @Body('title') title: string,
@Body('visibleToCustomer') visibleToCustomer: string | undefined, @Body('visibleToCustomer') visibleToCustomer: string | undefined,
@CurrentUser() user: TCurrentUser, @CurrentUser() user: TCurrentUser,
) { ) {
const actor = await this.resolveActor(entityId, user);
return this.exchangeService.upload( return this.exchangeService.upload(
entityId, entityId,
file, file,
{ title, visibleToCustomer: asBool(visibleToCustomer) }, { title, visibleToCustomer: asBool(visibleToCustomer) },
this.actor(user), actor,
); );
} }
@@ -118,6 +144,36 @@ export class GlExchangeController {
* is Djibouti; everyone else (GL Ethiopia, and super admins who hold both) * is Djibouti; everyone else (GL Ethiopia, and super admins who hold both)
* posts as Ethiopia. * posts as Ethiopia.
*/ */
/**
* Who is posting, for a route both desks and the assigned transit agent may
* call. Staff keep the desk attribution below; a portal caller must be the
* agent assigned to this shipment and posts as TRANSIT, so a document is
* never credited to a desk that did not send it.
*/
private async resolveActor(
entityId: string,
user: TCurrentUser,
): Promise<GlExchangeActor> {
const isStaff = GL_EXCHANGE_PERMS.some((p) =>
hasFreightPermission(user, p),
);
if (isStaff) return this.actor(user);
if (
!(await this.bookingsService.isTransitAgentForBooking(
user?.id,
entityId,
))
) {
throw new NotFoundException(`Entity ${entityId} not found`);
}
return {
userId: resolveAuthUserId(user),
name: actorLabel(user) ?? null,
side: 'TRANSIT',
};
}
private actor(user: TCurrentUser): GlExchangeActor { private actor(user: TCurrentUser): GlExchangeActor {
const side: GlExchangeSide = const side: GlExchangeSide =
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) && !hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) &&

View File

@@ -17,7 +17,7 @@ import type { FileRecord } from '../files/entities/file.entity';
*/ */
export const GL_EXCHANGE_RESOURCE = 'gl_exchange'; export const GL_EXCHANGE_RESOURCE = 'gl_exchange';
export type GlExchangeSide = 'ET' | 'DJ'; export type GlExchangeSide = 'ET' | 'DJ' | 'TRANSIT';
export interface GlExchangeActor { export interface GlExchangeActor {
userId: string; userId: string;
@@ -180,7 +180,14 @@ export class GlExchangeService {
// Pre-title rows (none in practice) fall back to the filename so a list // Pre-title rows (none in practice) fall back to the filename so a list
// never renders a blank row. // never renders a blank row.
title: record.title ?? record.name, title: record.title ?? record.name,
side: record.code === 'DJ' ? 'DJ' : 'ET', // `files.code` carries the poster's side. Anything unrecognised reads as
// ET, which is how every pre-TRANSIT row was written.
side:
record.code === 'DJ'
? 'DJ'
: record.code === 'TRANSIT'
? 'TRANSIT'
: 'ET',
visibleToCustomer: record.visibleToCustomer, visibleToCustomer: record.visibleToCustomer,
uploadedById: record.uploadedByUserId, uploadedById: record.uploadedByUserId,
uploadedByName: record.uploadedByName, uploadedByName: record.uploadedByName,

View File

@@ -12,6 +12,7 @@ import { InvoiceLine } from '../billing/entities/invoice-line.entity';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { Booking } from '../bookings/entities/booking.entity'; import { Booking } from '../bookings/entities/booking.entity';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; 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 { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity'; import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity';
import { import {
@@ -55,6 +56,7 @@ export class GlOperationsService {
private readonly milestoneService: ClearanceMilestoneService, private readonly milestoneService: ClearanceMilestoneService,
private readonly billingService: BillingService, private readonly billingService: BillingService,
private readonly notifier: BookingLifecycleNotifierService, private readonly notifier: BookingLifecycleNotifierService,
private readonly clearanceEvents: ClearanceEventService,
) {} ) {}
private get bookings() { private get bookings() {
@@ -362,13 +364,15 @@ export class GlOperationsService {
} }
/** /**
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass * GL Djibouti / the transit agent uploads T1 transport documents (multi-file)
* is secured on the train schedule (which itself follows wagon allocation). * once the train has DEPARTED Djibouti. Replaces the previous batch, so the
* Replaces the previous batch; locked only once GL Ethiopia closes the T1. * batch's file stamps are always the last update; locked only once GL
* Ethiopia closes the T1.
*/ */
async uploadT1Documents( async uploadT1Documents(
bookingId: string, bookingId: string,
files: Express.Multer.File[], files: Express.Multer.File[],
userId?: string,
): Promise<{ uploaded: number }> { ): Promise<{ uploaded: number }> {
const booking = await this.getBooking(bookingId); const booking = await this.getBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') { if (booking.tradeDirection !== 'IMPORT') {
@@ -376,24 +380,24 @@ export class GlOperationsService {
} }
const state = await this.t1State(bookingId); const state = await this.t1State(bookingId);
if (!state.wagonAllocated) { if (!state.trainDepartedAt) {
throw new BadRequestException( throw new BadRequestException(
'Wagons must be allocated before T1 transport documents can be uploaded.', 'T1 transport documents can be uploaded once the train has departed.',
);
}
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.',
); );
} }
if (state.closed) { if (state.closed) {
throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.'); 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); 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 }; return { uploaded: files.length };
} }

View File

@@ -12,10 +12,17 @@ import {
isImportTransitPermitFileCode, isImportTransitPermitFileCode,
isExportTransportFileCode, isExportTransportFileCode,
isT1TransportFileCode, isT1TransportFileCode,
isGatePassFileCode,
isDjiboutiT1FileCode,
exportTransportFileLabel, exportTransportFileLabel,
t1TransportFileLabel, t1TransportFileLabel,
transitPermitFileLabel, transitPermitFileLabel,
gatePassFileLabel,
djiboutiT1FileLabel,
GATE_PASS_FILE_PREFIX,
DJIBOUTI_T1_FILE_PREFIX,
type ClearanceWorkflowFile, type ClearanceWorkflowFile,
type TransitArrivalDocumentKind,
} from '@edr/types'; } from '@edr/types';
/** Require at least one declaration file in the upload batch. */ /** Require at least one declaration file in the upload batch. */
@@ -46,6 +53,7 @@ type DeclarationFileStore = {
resource: string; resource: string;
code: string; code: string;
file: Express.Multer.File; file: Express.Multer.File;
uploadedByUserId?: string | null;
}): Promise<unknown>; }): 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). */ /** 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; 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. */ /** Build labeled phased-customs file rows from resource files. */
export function buildWorkflowFiles( export function buildWorkflowFiles(
files: Array<{ code?: string | null; id: string; name: string; url: string }>, files: WorkflowFileInput[],
tradeDirection: string, tradeDirection: string,
): ClearanceWorkflowFile[] { ): ClearanceWorkflowFile[] {
const fileByCode = new Map( const fileByCode = new Map(
@@ -465,7 +552,7 @@ export function buildWorkflowFiles(
label: entry.label, label: entry.label,
uploadedBy: entry.uploadedBy, uploadedBy: entry.uploadedBy,
category: entry.category, 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), label: declarationFileLabel(file.code, index),
uploadedBy: 'gl_et', uploadedBy: 'gl_et',
category: 'declaration', 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), label: draftDeclarationFileLabel(index),
uploadedBy: 'gl_et', uploadedBy: 'gl_et',
category: 'draft_declaration', 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), label: transitPermitFileLabel(file.code, index),
uploadedBy: 'gl_et', uploadedBy: 'gl_et',
category: 'transit', 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), label: deliveryOrderFileLabel(file.code, index),
uploadedBy: 'gl_dj', uploadedBy: 'gl_dj',
category: 'djibouti', 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), label: t1TransportFileLabel(file.code, index),
uploadedBy: 'gl_dj', uploadedBy: 'gl_dj',
category: 'djibouti', 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), label: releaseOrderFileLabel(file.code, index),
uploadedBy: 'gl_dj', uploadedBy: 'gl_dj',
category: 'djibouti', 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), label: exportTransportFileLabel(file.code, index),
uploadedBy: 'gl_et', uploadedBy: 'gl_et',
category: 'transit', 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; return out;

View File

@@ -61,6 +61,7 @@ describe('ContractClearanceService — transit assignee', () => {
{} as never, {} as never,
notifier as never, notifier as never,
transitAgentsService as never, transitAgentsService as never,
{ ensureAssignment: jest.fn() } as never, // transit assignments
{} as never, // dataSource {} as never, // dataSource
); );
}); });

View File

@@ -2,7 +2,9 @@ import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm"; import { TypeOrmModule } from "@nestjs/typeorm";
import { Booking } from "../bookings/entities/booking.entity"; import { Booking } from "../bookings/entities/booking.entity";
import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity";
import { FilesModule } from "../files/files.module"; import { FilesModule } from "../files/files.module";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { TransitAgentsModule } from "../transit-agents/transit-agents.module"; import { TransitAgentsModule } from "../transit-agents/transit-agents.module";
import { TransitAssignment } from "./entities/transit-assignment.entity"; import { TransitAssignment } from "./entities/transit-assignment.entity";
import { TransitAssignmentsController } from "./transit-assignments.controller"; 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: // `Booking` is registered as an ENTITY rather than importing BookingsModule:
// this module only confirms a booking id exists, and that module would drag // this module only confirms a booking id exists, and that module would drag
// its whole graph (billing, contracts, scheduling, first/last mile) along. // 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, FilesModule,
TransitAgentsModule, TransitAgentsModule,
], ],

View File

@@ -38,6 +38,8 @@ describe("TransitAssignmentsService", () => {
remove: jest.Mock; remove: jest.Mock;
}; };
let service: TransitAssignmentsService; let service: TransitAssignmentsService;
let milestones: { find: jest.Mock };
let trainSchedules: { find: jest.Mock };
const row = (over: Partial<TransitAssignment> = {}) => const row = (over: Partial<TransitAssignment> = {}) =>
({ ({
@@ -81,11 +83,16 @@ describe("TransitAssignmentsService", () => {
remove: jest.fn(), remove: jest.fn(),
}; };
milestones = { find: jest.fn().mockResolvedValue([]) };
trainSchedules = { find: jest.fn().mockResolvedValue([]) };
service = new TransitAssignmentsService( service = new TransitAssignmentsService(
assignments as never, assignments as never,
agents as never, agents as never,
bookings as never, bookings as never,
files as never, files as never,
milestones as never,
trainSchedules as never,
); );
}); });
@@ -294,222 +301,127 @@ describe("TransitAssignmentsService", () => {
describe("myStats", () => { describe("myStats", () => {
const at = (iso: string) => new Date(iso); const at = (iso: string) => new Date(iso);
const DEPARTED = at("2026-08-27T20:00:00Z");
const withRows = (rows: Record<string, unknown>[]) => { const withRows = (rows: Record<string, unknown>[]) => {
assignments.findByTransitAgent.mockResolvedValue( 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([ withRows([
{ { booking: { loadedAt: DEPARTED, arrivedAt: at("2026-08-28T06:00:00Z"), tradeDirection: "EXPORT" } },
status: TransitAssignmentStatus.Finished, { booking: { loadedAt: DEPARTED, arrivedAt: at("2026-08-28T08:00:00Z"), tradeDirection: "EXPORT" } },
finishedAt: at("2026-08-28T10:35:00Z"), // 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.
status: TransitAssignmentStatus.Finished, { booking: { loadedAt: DEPARTED, arrivedAt: null, tradeDirection: "EXPORT" } },
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"),
},
]); ]);
bookingFiles({});
const stats = await service.myStats("user-1"); const stats = await service.myStats("user-1");
// 95/190/285/2820 -> even count, so the median averages the middle two. expect(stats.timings.transit).toEqual({
// A mean would be 848 minutes, describing none of the four. median: 720,
expect(stats.performance.medianClearanceMinutes).toBe(238); fastest: 600,
expect(stats.performance.slowestClearanceMinutes).toBe(2820); 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 () => { it("times the Release Order from the declaration to the LAST RO upload", async () => {
withRows([ withRows([{ booking: { tradeDirection: "EXPORT", loadedAt: null, arrivedAt: null } }]);
milestones.find.mockResolvedValue([
{ {
status: TransitAssignmentStatus.Finished, bookingId: "bk-0",
finishedAt: at("2026-08-28T10:30:00Z"), milestoneCode: "DECLARED",
}, status: "COMPLETED",
{ triggeredAt: at("2026-08-27T08:00:00Z"),
status: TransitAssignmentStatus.Finished,
finishedAt: at("2026-08-28T13:00:00Z"),
},
{
status: TransitAssignmentStatus.Finished,
finishedAt: at("2026-08-29T09: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 stats = await service.myStats("user-1");
const [item] = stats.items;
expect(stats.sla).toEqual({ under2h: 1, under6h: 1, over6h: 1 }); expect(item.declaredAt).toBe("2026-08-27T08:00:00.000Z");
expect(stats.performance.onTimeRate).toBe(67); 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([ withRows([
{ booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } }, // Import, nothing filed: the DO comes first.
{ booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } }, { booking: { tradeDirection: "IMPORT", loadedAt: null, arrivedAt: null } },
// Scheduled bookings cannot receive documents yet, so counting them // Import with a DO but no departure yet: T1 is still locked.
// would report a failure the agent could not have avoided. { booking: { tradeDirection: "IMPORT", loadedAt: null, arrivedAt: null } },
{ booking: { arrivedAt: null, schedulingStatus: "SCHEDULED" } }, // 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 stats = await service.myStats("user-1");
const byBooking = new Map(stats.items.map((i) => [i.bookingId, i]));
expect(stats.coverage.dispatched).toBe(2); expect(byBooking.get("bk-0")?.nextAction.document).toBe("do");
expect(stats.coverage.withDocuments).toBe(0); 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 () => { 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"); const stats = await service.myStats("user-1");
expect(stats.performance.medianClearanceMinutes).toBeNull(); expect(stats.timings.transit.median).toBeNull();
expect(stats.performance.onTimeRate).toBeNull(); expect(stats.timings.arrivalToFinish.median).toBeNull();
expect(stats.totals.open).toBe(1); 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"; } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; 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 { Booking } from "../bookings/entities/booking.entity";
import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity";
import { FilesService } from "../files/files.service"; import { FilesService } from "../files/files.service";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { TransitAgentsRepository } from "../transit-agents/transit-agents.repository"; import { TransitAgentsRepository } from "../transit-agents/transit-agents.repository";
import { FileRecord } from "../files/entities/file.entity"; import { FileRecord } from "../files/entities/file.entity";
import { CreateTransitAssignmentDto } from "./dto/create-transit-assignment.dto"; import { CreateTransitAssignmentDto } from "./dto/create-transit-assignment.dto";
@@ -66,6 +75,86 @@ export type TransitAssignmentView = TransitAssignment & {
files?: TransitAssignmentFileView[]; 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() @Injectable()
export class TransitAssignmentsService { export class TransitAssignmentsService {
constructor( constructor(
@@ -77,6 +166,10 @@ export class TransitAssignmentsService {
@InjectRepository(Booking) @InjectRepository(Booking)
private readonly bookingsRepository: Repository<Booking>, private readonly bookingsRepository: Repository<Booking>,
private readonly filesService: FilesService, private readonly filesService: FilesService,
@InjectRepository(ClearanceMilestone)
private readonly milestonesRepository: Repository<ClearanceMilestone>,
@InjectRepository(TrainSchedule)
private readonly trainSchedulesRepository: Repository<TrainSchedule>,
) {} ) {}
private static minutesBetween( private static minutesBetween(
@@ -179,98 +272,302 @@ export class TransitAssignmentsService {
* reopened days later drags an average far enough to make the whole panel * reopened days later drags an average far enough to make the whole panel
* lie about typical performance. * 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 agent = await this.requireAgentForUser(userId);
const rows = await this.assignmentsRepository.findByTransitAgent(agent.id); const rows = await this.assignmentsRepository.findByTransitAgent(agent.id);
const bookingIds = [...new Set(rows.map((r) => r.bookingId))];
const docCounts = rows.length const [ownDocs, bookingDocs, milestones, schedules] = await Promise.all([
? await this.filesService.findByResourceIdsGrouped( rows.length
rows.map((r) => r.id), ? this.filesService.findByResourceIdsGrouped(
TRANSIT_ASSIGNMENT_FILE_RESOURCE, rows.map((r) => r.id),
) TRANSIT_ASSIGNMENT_FILE_RESOURCE,
: new Map<string, unknown[]>(); )
: 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) => const scheduleById = new Map(schedules.map((sch) => [sch.id, sch]));
from && to ? Math.floor((to.getTime() - from.getTime()) / 60_000) : null; 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 { return {
id: row.id, id: row.id,
reference: row.booking?.reference ?? null, bookingId: row.bookingId,
customerName: row.booking?.company?.name ?? null, reference: booking?.reference ?? null,
customerName: booking?.company?.name ?? null,
tradeDirection,
status: row.status, status: row.status,
schedulingStatus: row.booking?.schedulingStatus ?? null, schedulingStatus: booking?.schedulingStatus ?? null,
/** Dispatch (cargo loaded) to the train arriving. */ trainLabel: schedule?.voyageNumber ?? schedule?.trainNumber ?? null,
transitMinutes: minutes(row.booking?.loadedAt, arrivedAt), assignedAt: iso(row.assignedAt) ?? new Date(0).toISOString(),
/** Arrival to the agent picking the work up. */ startedAt: iso(row.startedAt),
pickupMinutes: minutes(arrivedAt, row.startedAt), finishedAt,
/** Arrival to the work being finished — the headline metric. */ bookingCreatedAt,
clearanceMinutes: minutes(arrivedAt, row.finishedAt), departedAt,
documentCount: (docCounts.get(row.id) ?? []).length, 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 => { // Most recently moving shipment first: arrival, else departure, else when
if (!values.length) return null; // it was handed to the agent.
const sorted = [...values].sort((a, b) => a - b); 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); const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 return {
? sorted[mid] median:
: Math.round((sorted[mid - 1] + sorted[mid]) / 2); 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 const open = items.filter((i) => i.status !== TransitAssignmentStatus.Finished);
.map((i) => i.clearanceMinutes) const pendingFor = (document: TransitDocumentKind) =>
.filter((v): v is number => v !== null); items.filter(
const pickups = items (i) => i.nextAction.kind === "upload" && i.nextAction.document === document,
.map((i) => i.pickupMinutes) ).length;
.filter((v): v is number => v !== null); const sumDocs = (key: keyof TransitStatItem["documents"]) =>
items.reduce((sum, i) => sum + i.documents[key], 0);
// 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;
return { return {
totals: { totals: {
assignments: items.length, assignments: items.length,
open: items.filter((i) => i.status !== TransitAssignmentStatus.Finished) open: open.length,
notStarted: items.filter((i) => i.status === TransitAssignmentStatus.NotStarted)
.length, .length,
finished: items.filter( inProgress: items.filter((i) => i.status === TransitAssignmentStatus.InProgress)
(i) => i.status === TransitAssignmentStatus.Finished, .length,
).length, finished: items.length - open.length,
readyForDocuments: items.filter( imports: items.filter((i) => i.tradeDirection === "IMPORT").length,
(i) => exports: items.filter((i) => i.tradeDirection === "EXPORT").length,
i.schedulingStatus === "DISPATCHED" && awaitingDeparture: open.filter((i) => !i.departedAt).length,
i.status !== TransitAssignmentStatus.Finished, inTransit: open.filter((i) => i.departedAt && !i.arrivedAt).length,
).length, arrived: open.filter((i) => Boolean(i.arrivedAt)).length,
documents: items.reduce((sum, i) => sum + i.documentCount, 0), actionNeeded: items.filter((i) => i.nextAction.kind === "upload").length,
}, },
performance: { timings: {
medianClearanceMinutes: median(cleared), transit: timing("transit"),
medianPickupMinutes: median(pickups), declarationToRo: timing("declarationToRo"),
fastestClearanceMinutes: cleared.length ? Math.min(...cleared) : null, bookingToDo: timing("bookingToDo"),
slowestClearanceMinutes: cleared.length ? Math.max(...cleared) : null, departureToT1: timing("departureToT1"),
onTimeRate: cleared.length arrivalToT1: timing("arrivalToT1"),
? Math.round(((sla.under2h + sla.under6h) / cleared.length) * 100) arrivalToGatePass: timing("arrivalToGatePass"),
: null, arrivalToDjiboutiT1: timing("arrivalToDjiboutiT1"),
measured: cleared.length, arrivalToFinish: timing("arrivalToFinish"),
}, },
sla, documents: {
coverage: { ro: sumDocs("ro"),
dispatched: dispatched.length, do: sumDocs("do"),
withDocuments: withDocs, t1: sumDocs("t1"),
gatePass: sumDocs("gatePass"),
djiboutiT1: sumDocs("djiboutiT1"),
own: sumDocs("own"),
}, },
/** Newest first, for the timeline and the recent-activity list. */ pending: {
items: items.slice(0, 12), ro: pendingFor("ro"),
do: pendingFor("do"),
t1: pendingFor("t1"),
gatePass: pendingFor("gate_pass"),
djiboutiT1: pendingFor("djibouti_t1"),
},
items: items.slice(0, 20),
}; };
} }
@@ -393,6 +690,50 @@ export class TransitAssignmentsService {
return this.findMineById(userId, id); return this.findMineById(userId, id);
} }
/**
* Make `transitAgentId` the officer working `bookingId`, as the clearance
* desk's "assign transit assignee" step means it.
*
* The booking itself only records the officer's NAME, which is all the
* clearance UI needs; the officer's own portal reads `transit_assignments`.
* This keeps the two in step, and is deliberately forgiving where `create()`
* is strict:
* - assigning the same agent twice is a no-op, not a 409 — the desk may
* re-save the step without meaning to start over;
* - a REASSIGNMENT retires the previous officer's row, so a shipment does
* not sit in the work list of someone who no longer handles it. Finished
* rows stay, since they are that officer's record of work already done.
*/
async ensureAssignment(
bookingId: string,
transitAgentId: string,
assignedByUserId?: string,
): Promise<void> {
const existing =
await this.assignmentsRepository.findByBooking(bookingId);
for (const row of existing) {
if (
row.transitAgentId !== transitAgentId &&
row.status !== TransitAssignmentStatus.Finished
) {
await this.assignmentsRepository.softDelete(row.id);
}
}
if (existing.some((row) => row.transitAgentId === transitAgentId)) return;
await this.assignmentsRepository.create({
bookingId,
transitAgentId,
status: TransitAssignmentStatus.NotStarted,
startedAt: null,
finishedAt: null,
assignedByUserId: assignedByUserId ?? null,
note: null,
});
}
async create( async create(
dto: CreateTransitAssignmentDto, dto: CreateTransitAssignmentDto,
assignedByUserId?: string, assignedByUserId?: string,

View File

@@ -42,6 +42,9 @@ const SIDES: Record<Freight.GlExchangeDocument["side"], { label: string; color:
{ {
ET: { label: "GL Ethiopia", color: "edr-green" }, ET: { label: "GL Ethiopia", color: "edr-green" },
DJ: { label: "GL Djibouti", color: "blue" }, DJ: { label: "GL Djibouti", color: "blue" },
// The transit officer assigned to the shipment can post here too — both
// desks see it, and it is never attributed to either of them.
TRANSIT: { label: "Transit agent", color: "grape" },
}; };
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
@@ -99,6 +102,7 @@ export function GlExchangePanel({ entityId }: GlExchangePanelProps) {
() => ({ () => ({
et: documents.filter((d) => d.side === "ET").length, et: documents.filter((d) => d.side === "ET").length,
dj: documents.filter((d) => d.side === "DJ").length, dj: documents.filter((d) => d.side === "DJ").length,
transit: documents.filter((d) => d.side === "TRANSIT").length,
shared: documents.filter((d) => d.visibleToCustomer).length, shared: documents.filter((d) => d.visibleToCustomer).length,
}), }),
[documents], [documents],
@@ -141,6 +145,11 @@ export function GlExchangePanel({ entityId }: GlExchangePanelProps) {
<Badge variant="light" color="blue" radius="sm" tt="none"> <Badge variant="light" color="blue" radius="sm" tt="none">
{stats.dj} from GL Djibouti {stats.dj} from GL Djibouti
</Badge> </Badge>
{stats.transit > 0 ? (
<Badge variant="light" color="grape" radius="sm" tt="none">
{stats.transit} from the transit agent
</Badge>
) : null}
<Badge variant="light" color="gray" radius="sm" tt="none"> <Badge variant="light" color="gray" radius="sm" tt="none">
{stats.shared} visible to customer {stats.shared} visible to customer
</Badge> </Badge>

View File

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

View File

@@ -662,6 +662,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
cardTitleKey: "name", cardTitleKey: "name",
columns: [ columns: [
{ id: "name", header: "Name", accessorKey: "name" }, { id: "name", header: "Name", accessorKey: "name" },
{ id: "email", header: "Email", accessorKey: "email" },
{ id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber" },
{ id: "validFrom", header: "Valid from", accessorKey: "validFrom", format: "date" }, { id: "validFrom", header: "Valid from", accessorKey: "validFrom", format: "date" },
{ id: "validTo", header: "Valid to", accessorKey: "validTo", format: "date" }, { id: "validTo", header: "Valid to", accessorKey: "validTo", format: "date" },
{ {

View File

@@ -71,6 +71,7 @@ import {
} from "./pages/shipping-line"; } from "./pages/shipping-line";
import { import {
TransitAgentBookingsPage, TransitAgentBookingsPage,
TransitAgentBookingDetailPage,
TransitAgentOverviewPage, TransitAgentOverviewPage,
} from "./pages/transit-agent"; } from "./pages/transit-agent";
import FaqPage from "./pages/support/FaqPage"; import FaqPage from "./pages/support/FaqPage";
@@ -545,6 +546,10 @@ const App = () => {
path="/transit-agent/bookings" path="/transit-agent/bookings"
element={<TransitAgentBookingsPage />} element={<TransitAgentBookingsPage />}
/> />
<Route
path="/transit-agent/bookings/:id"
element={<TransitAgentBookingDetailPage />}
/>
</Route> </Route>
</Route> </Route>
)} )}

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

@@ -0,0 +1,166 @@
import {
Badge,
Box,
Button,
Card,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { Download, Eye, FileText } from "lucide-react";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { fetchViewableFile } from "@/services/files.service";
const CATEGORY_LABELS: Record<
Freight.ClearanceWorkflowFileCategory,
string
> = {
declaration: "Declaration",
draft_declaration: "Draft declaration",
duty: "Duty & taxes",
transit: "Transit",
djibouti: "Djibouti",
};
const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [
"draft_declaration",
"declaration",
"duty",
"transit",
"djibouti",
];
const OWNER_LABELS: Record<Freight.ClearanceWorkflowFileOwner, string> = {
customer: "Customer",
gl_et: "GL Ethiopia",
gl_dj: "GL Djibouti",
};
export interface ClearanceWorkflowFilesPanelProps {
files: Freight.ClearanceWorkflowFile[];
onView: (file: { name: string; url: string }) => void;
onDownload?: (file: { id: string; name: string }) => void;
title?: string;
}
export function ClearanceWorkflowFilesPanel({
files,
onView,
onDownload,
title = "Customs workflow documents",
}: ClearanceWorkflowFilesPanelProps) {
if (files.length === 0) return null;
const grouped = CATEGORY_ORDER.map((category) => ({
category,
label: CATEGORY_LABELS[category],
items: files.filter((f) => f.category === category),
})).filter((g) => g.items.length > 0);
return (
<Card withBorder radius="md" p="md">
<Group gap={10} mb="sm" wrap="nowrap">
<ThemeIcon variant="light" color="edr-green" radius="md" size={32}>
<FileText size={16} />
</ThemeIcon>
<Text fw={600} fz={15}>
{title}
</Text>
</Group>
<Stack gap="md">
{grouped.map((group) => (
<Box key={group.category}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb={8}>
{group.label}
</Text>
<Stack gap={8}>
{group.items.map((item) => (
<WorkflowFileRow
key={item.code}
item={item}
onView={onView}
onDownload={onDownload}
/>
))}
</Stack>
</Box>
))}
</Stack>
</Card>
);
}
function WorkflowFileRow({
item,
onView,
onDownload,
}: {
item: Freight.ClearanceWorkflowFile;
onView: (file: { name: string; url: string }) => void;
onDownload?: (file: { id: string; name: string }) => void;
}) {
const file = item.file;
if (!file) return null;
const canPreview = isViewable({ name: file.name, url: "" });
return (
<Paper withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap" align="center">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={36}>
<FileText size={17} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{item.label}
</Text>
<Group gap={6} wrap="nowrap" mt={2}>
<Badge size="xs" variant="light" color="gray" radius="sm" tt="none">
{OWNER_LABELS[item.uploadedBy]}
</Badge>
<Text size="xs" c="dimmed" truncate>
{file.name}
</Text>
</Group>
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{canPreview ? (
<Tooltip label="Preview">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
void fetchViewableFile(file.id, file.name).then(onView)
}
>
View
</Button>
</Tooltip>
) : null}
{onDownload ? (
<Tooltip label="Download">
<Button
size="compact-xs"
variant="light"
radius="md"
leftSection={<Download size={13} />}
onClick={() => onDownload({ id: file.id, name: file.name })}
>
Download
</Button>
</Tooltip>
) : null}
</Group>
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,434 @@
import {
Alert,
Box,
Button,
Card,
Group,
Modal,
Progress,
Stack,
Stepper,
Text,
Textarea,
ThemeIcon,
} from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowRight,
CheckCircle2,
Ship,
X,
} from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { isReleaseOrderFileCode } from "@edr/types";
import { transitAssignmentsService } from "@/services/transit-assignments.service";
interface WizardStep {
label: string;
description: string;
done: boolean;
}
type MilestoneRow = { milestoneCode?: string | null; status?: string | null };
/** SKIPPED counts as done — a step that does not apply must not stall the flow. */
function isMilestoneDone(
milestones: MilestoneRow[] | undefined,
code: string,
): boolean {
const m = milestones?.find((x) => x.milestoneCode === code);
return m?.status === "COMPLETED" || m?.status === "SKIPPED";
}
/**
* The 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.
*
* The steps are rendered from the clearance payload rather than re-derived.
*/
export function TransitClearanceActionPanel({
bookingId,
clearance,
tradeDirection,
onChanged,
}: {
bookingId: string;
clearance?: Freight.ClearanceView;
tradeDirection?: string | null;
onChanged: () => void;
}) {
const queryClient = useQueryClient();
const [amendOpen, setAmendOpen] = useState(false);
const isImport = tradeDirection === "IMPORT";
const workflowFiles = clearance?.workflowFiles ?? [];
const hasRo = workflowFiles.some(
(f) => isReleaseOrderFileCode(f.code) && f.file,
);
const roHoldReason = clearance?.roHoldReason ?? null;
const refresh = () => {
void queryClient.invalidateQueries({ queryKey: ["transit-clearance"] });
void queryClient.invalidateQueries({
queryKey: ["transit-clearance-history"],
});
onChanged();
};
// ── Steps ────────────────────────────────────────────────────────────────
// The same wizards the GL Djibouti desk sees: 12 import steps, 11 export.
// Every `done` reads off the clearance payload — milestones, booking
// milestones, or a server-set flag — so a step can never claim to be
// complete when the server does not consider it so.
// This page is always booking-scoped, and for a booking the clearance view's
// milestones ARE the booking milestones — the GL detail page passes the same
// array for both on its `kind === "booking"` branch.
const ms = clearance?.milestones;
const done = (code: string) => isMilestoneDone(ms, code);
const bookingDone = (code: string) => isMilestoneDone(ms, code);
const dutyRequired = clearance?.dutyRequired ?? false;
const steps: WizardStep[] = isImport
? [
{
label: "Customer documents",
description: "Reviewed and approved by GL Ethiopia",
done: done("DOCUMENTS_APPROVED"),
},
{
label: "Request transit assignee",
description: clearance?.transitAssignee?.name
? `Transit assignee: ${clearance.transitAssignee.name}`
: "GL Djibouti names the officer handling this shipment",
done: Boolean(clearance?.transitAssignee?.name),
},
{
label: "Draft declaration",
description: "Customer accepts the estimated price",
done: done("DRAFT_DECLARATION_ACCEPTED") || done("DECLARED"),
},
{
label: "Customs declaration",
description: "GL Ethiopia uploads declaration documents",
done: done("DECLARED"),
},
{
label: "Duty & tax",
description: "Amount advised and notice attached",
// A shipment with no duty due skips this rather than stalling on it.
done: !dutyRequired || done("DUTY_TAXES_ADVISED"),
},
{
label: "Customer payment",
description: "Customer uploads the duty payment slip",
done: !dutyRequired || done("DUTY_TAX_PAID"),
},
{
label: "Transit Permit",
description: "Transit permit documents uploaded",
done: done("TRANSIT_PERMIT_UPLOADED"),
},
{
label: "Finalize pre-clearance",
description: "GL Ethiopia hands off to GL Djibouti",
done: Boolean(clearance?.preClearanceFinalized),
},
{
label: "Delivery Order",
description: "You upload the DO with its collection dates",
done: done("DO_COLLECTED"),
},
{
label: "Create booking",
description: "GL Ethiopia books for the customer",
done: Boolean(clearance?.t1) || bookingDone("FREIGHT_PAYMENT_SETTLED"),
},
{
label: "Freight payment",
description: "Customer pays the train and service charges",
done: bookingDone("FREIGHT_PAYMENT_SETTLED"),
},
{
label: "Gate pass",
description: "Secured after payment and wagon allocation",
done: Boolean(clearance?.gatepassGranted),
},
]
: [
{
label: "Customer documents",
description: "Reviewed and approved by GL Ethiopia",
done: done("DOCUMENTS_APPROVED"),
},
{
label: "Request transit assignee",
description: clearance?.transitAssignee?.name
? `Transit assignee: ${clearance.transitAssignee.name}`
: "GL Djibouti names the officer handling this shipment",
done: Boolean(clearance?.transitAssignee?.name),
},
{
label: "Customs declaration",
description: "GL Ethiopia uploads — releases the export",
done: done("DECLARED"),
},
{
label: "Release Order",
description: "You upload the RO with the vessel departure date",
done: done("RELEASE_ORDER_SECURED") || hasRo,
},
{
label: "Create booking",
description: "GL Ethiopia books for the customer",
done: bookingDone("FREIGHT_PAYMENT_SETTLED") || Boolean(clearance?.t1),
},
{
label: "Payment & wagon allocation",
description: "Customer pays; operations allocates wagons",
done:
bookingDone("FREIGHT_PAYMENT_SETTLED") &&
(bookingDone("WAGON_ALLOCATED") ||
Boolean(clearance?.train?.wagonAllocated)),
},
{
label: "Transport document",
description: "GL Ethiopia uploads after wagon allocation",
done: bookingDone("EXPORT_TRANSPORT_ISSUED"),
},
{
label: "Train to Djibouti",
description: "Departure and arrival",
done: Boolean(clearance?.train?.arrivedAt),
},
{
label: "Accept T1",
description: "You close the T1 once the train arrives",
done: Boolean(clearance?.t1Closed),
},
{
label: "Gate pass",
description: "Secured on the train schedule after arrival",
done: Boolean(clearance?.gatepassGranted),
},
{
label: "Offload",
description: "Cargo comes off the train",
done: Boolean(clearance?.offloaded ?? clearance?.offload?.offloaded),
},
];
// The wizard sits on the FIRST step not yet done — matching the GL desk's
// "Step N of M", which counts the step being worked on, not the ones behind it.
const firstPending = steps.findIndex((s) => !s.done);
const activeStep = firstPending === -1 ? steps.length : firstPending;
const percent = Math.round((activeStep / steps.length) * 100);
const currentStep = steps[activeStep] ?? null;
return (
<>
<Card withBorder radius="md" p={0} style={{ overflow: "hidden" }}>
{/* Header: title, "Step N of M", and the progress bar — the GL desk's
own wizard chrome. */}
<Group
justify="space-between"
wrap="nowrap"
px={18}
py={12}
style={{ borderBottom: "1px solid var(--mantine-color-edr-divider-6)" }}
>
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={32}>
<Ship size={16} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fw={700} fz={14} c="edr-text">
{isImport
? "Import pre-booking clearance"
: "Export customs clearance"}
</Text>
<Text fz={11.5} c="dimmed">
Step {Math.min(activeStep + 1, steps.length)} of {steps.length}
{currentStep ? ` · ${currentStep.label}` : ""}
</Text>
</Box>
</Group>
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
<Progress
value={percent}
color="edr-green"
radius="xl"
size={6}
w={110}
/>
<Text fz={11.5} c="#67788A" fw={600}>
{percent}%
</Text>
</Group>
</Group>
{/* Whose desk the flow is sitting on right now. */}
{clearance?.nextAction ? (
<Group
gap={10}
wrap="nowrap"
px={18}
py={12}
style={{
background: "#E9F1FC",
borderBottom: "1px solid #EFF3F7",
}}
>
<ArrowRight size={15} color="#1D6FD1" style={{ flexShrink: 0 }} />
<Text
fz={10.5}
fw={700}
lts="0.4px"
c="#1D6FD1"
style={{ flexShrink: 0 }}
>
{clearance.nextAction.actor.replace("_", " ").toUpperCase()}
</Text>
<Text fz={11.5} fw={600} c="edr-text" style={{ minWidth: 0 }}>
{clearance.nextAction.action}
</Text>
</Group>
) : null}
<Box p="md">
{roHoldReason ? (
<Alert
color="red"
variant="light"
mb="sm"
icon={<AlertTriangle size={16} />}
title="RO amendment hold"
>
{roHoldReason}
</Alert>
) : null}
<Stepper
active={activeStep}
orientation="vertical"
size="sm"
iconSize={26}
allowNextStepsSelect={false}
mb="md"
>
{steps.map((s) => (
<Stepper.Step
key={s.label}
label={s.label}
description={s.description}
icon={s.done ? <CheckCircle2 size={14} /> : undefined}
/>
))}
</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
variant="subtle"
color="red"
radius="md"
leftSection={<AlertTriangle size={15} />}
onClick={() => setAmendOpen(true)}
>
Request RO amendment
</Button>
) : null}
</Stack>
</Box>
</Card>
<RoAmendmentModal
opened={amendOpen}
bookingId={bookingId}
onClose={() => setAmendOpen(false)}
onSuccess={refresh}
/>
</>
);
}
function RoAmendmentModal({
opened,
bookingId,
onClose,
onSuccess,
}: {
opened: boolean;
bookingId: string;
onClose: () => void;
onSuccess: () => void;
}) {
const [note, setNote] = useState("");
const submit = useMutation({
mutationFn: () =>
transitAssignmentsService.requestRoAmendment(bookingId, note.trim()),
onSuccess: () => {
toast.success("RO amendment requested");
setNote("");
onSuccess();
onClose();
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Request failed"),
});
return (
<Modal
opened={opened}
onClose={onClose}
radius="md"
size="md"
title={
<Group gap={8}>
<AlertTriangle size={18} />
<Text fw={700}>Request RO amendment</Text>
</Group>
}
>
<Stack gap="md">
<Alert color="orange" variant="light" icon={<AlertTriangle size={16} />}>
This puts the shipment on hold until GL Ethiopia amends the Release
Order.
</Alert>
<Textarea
label="What needs to change?"
placeholder="Describe the correction needed on the Release Order"
minRows={3}
autosize
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
required
withAsterisk
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={submit.isPending}>
Cancel
</Button>
<Button
color="red"
loading={submit.isPending}
disabled={note.trim().length === 0}
leftSection={<X size={16} />}
onClick={() => submit.mutate()}
>
Request amendment
</Button>
</Group>
</Stack>
</Modal>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,3 @@
export { default as TransitAgentOverviewPage } from "./TransitAgentOverviewPage"; export { default as TransitAgentOverviewPage } from "./TransitAgentOverviewPage";
export { default as TransitAgentBookingsPage } from "./TransitAgentBookingsPage"; export { default as TransitAgentBookingsPage } from "./TransitAgentBookingsPage";
export { default as TransitAgentBookingDetailPage } from "./TransitAgentBookingDetailPage";

View File

@@ -0,0 +1,150 @@
/*
* Scoped to .edr-transit-table — the DataTable container div on the
* transit agent bookings list. Mirrors the portal's /bookings table
* (bookings-table.css): content-sized columns with a 100px floor, no
* truncation, horizontal scroll when the table outgrows the card, sticky
* header row and a sticky shadowed action column.
*/
.edr-transit-table {
overflow-x: auto;
max-width: 100%;
min-width: 0;
}
/*
* width: max-content — the table is exactly as wide as its columns' content
* needs, never squeezed to fit the viewport; the container scrolls instead.
* min-width: 100% keeps it filling the card when content is narrow.
*/
.edr-transit-table table {
table-layout: auto;
width: max-content;
min-width: 100%;
}
/* 100px floor, no ceiling: cells grow to fit their text, nothing is clipped. */
.edr-transit-table th,
.edr-transit-table td:not([colspan]) {
min-width: 100px;
max-width: none;
overflow: visible;
text-overflow: clip;
white-space: nowrap;
}
/*
* Booking (col 1) and Contract (col 2) carry free-text company/contract names.
* Cap those two columns and let their content wrap onto 2+ lines so a very long
* name (e.g. "SHAFICI PHARMACEUTICAL MEDICAL SUPPLIES WHOLESALER PARTINERSHIP")
* stacks inside its own cell instead of shoving the next column off-screen.
* Everything below the header row so the header labels still sit on one line.
*/
.edr-transit-table tbody td:not([colspan]):nth-child(1) {
max-width: 240px;
white-space: normal;
}
.edr-transit-table tbody td:not([colspan]):nth-child(2) {
max-width: 200px;
white-space: normal;
}
/*
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
* cell that resolves against min-content and clips the label. Let badges size
* to their text so the column grows to fit them.
*/
.edr-transit-table .mantine-Badge-root {
max-width: none;
}
/*
* Opt-out for long free text (company/customer names). The blanket nowrap rule
* above keeps every cell on one line so columns size to content; a very long
* name would otherwise force the column absurdly wide. Mark such text with
* `cell-wrap` to cap it and wrap onto 2+ lines instead of pushing the layout.
*/
.edr-transit-table .cell-wrap,
.edr-transit-table .mantine-Group-root > .cell-wrap {
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
min-width: 0;
max-width: 100%;
line-height: 1.3;
}
/*
* Mantine Group's preventGrowOverflow caps every child at 100%/N of the cell.
* In an auto-width table cell that resolves against min-content and collapses
* the badges/text in the Type, Route and Status columns to nothing. Let group
* children size to their content; the column grows and the container scrolls.
*/
.edr-transit-table .mantine-Group-root > * {
max-width: none;
flex-shrink: 0;
}
/* Sticky header row. */
.edr-transit-table thead th {
position: sticky;
top: 0;
z-index: 1;
}
/*
* Sticky action column, shrunk to its content. The width overrides the inline
* width DataTable stamps from tanstack's column size — hence !important.
* `:not([colspan])` keeps the full-width error/empty rows out.
*/
.edr-transit-table th:last-child,
.edr-transit-table td:last-child:not([colspan]) {
width: 1% !important;
min-width: 0;
position: sticky;
right: 0;
box-shadow: -10px 0 14px -8px rgba(16, 32, 47, 0.12);
}
/*
* Sticky cells sit above the scrolling ones, so they need their own opaque
* background or the columns underneath show through.
*/
.edr-transit-table td:last-child:not([colspan]) {
background: var(--mantine-color-body);
z-index: 2;
}
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
.edr-transit-table tbody tr:hover td:last-child:not([colspan]) {
background: #f7fbf9;
}
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
.edr-transit-table th:last-child {
background: var(--mantine-color-gray-0);
z-index: 3;
}
/* ── Design pass: flat head band, 64px rows, hairline dividers ─────────── */
.edr-transit-table thead th {
height: 38px;
padding-top: 0;
padding-bottom: 0;
background: var(--mantine-color-gray-0);
border-bottom: 1px solid var(--mantine-color-edr-divider-6);
}
.edr-transit-table tbody td:not([colspan]) {
height: 64px;
padding-top: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--mantine-color-edr-divider-6);
}
.edr-transit-table tbody tr:last-child td:not([colspan]) {
border-bottom: 0;
}
.edr-transit-table tbody tr:hover td {
background: #f7fbf9;
}

View File

@@ -1,3 +1,5 @@
import type { Freight } from "@edr/types";
import { client } from "@/utils/api"; import { client } from "@/utils/api";
const BASE = "/api/transit-assignments/my"; const BASE = "/api/transit-assignments/my";
@@ -65,42 +67,91 @@ export interface TransitAssignmentListResult {
meta: { total: number; page: number; pageSize: number; totalPages: number }; 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 { export interface TransitStatItem {
id: string; id: string;
bookingId: string;
reference: string | null; reference: string | null;
customerName: string | null; customerName: string | null;
tradeDirection: TransitTradeDirection;
status: TransitAssignmentStatus; status: TransitAssignmentStatus;
schedulingStatus: string | null; schedulingStatus: string | null;
transitMinutes: number | null; trainLabel: string | null;
pickupMinutes: number | null; assignedAt: string;
clearanceMinutes: number | null; startedAt: string | null;
documentCount: number; 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 * Overview figures, all derived server-side from existing timestamps: the
* duration is minutes, and null means "not measurable yet" rather than zero — * train's departure and arrival, clearance milestones, and each document's
* an unfinished assignment has no clearance time. * upload time (a replaced batch counts from its last update).
*/ */
export interface TransitStats { export interface TransitStats {
totals: { totals: {
assignments: number; assignments: number;
open: number; open: number;
notStarted: number;
inProgress: number;
finished: number; finished: number;
readyForDocuments: number; imports: number;
documents: number; exports: number;
awaitingDeparture: number;
inTransit: number;
arrived: number;
actionNeeded: number;
}; };
performance: { timings: Record<TransitTimingKey, TransitTimingSummary>;
medianClearanceMinutes: number | null; documents: TransitStatItem["documents"];
medianPickupMinutes: number | null; pending: { ro: number; do: number; t1: number; gatePass: number; djiboutiT1: number };
fastestClearanceMinutes: number | null;
slowestClearanceMinutes: number | null;
onTimeRate: number | null;
measured: number;
};
sla: { under2h: number; under6h: number; over6h: number };
coverage: { dispatched: number; withDocuments: number };
items: TransitStatItem[]; items: TransitStatItem[];
} }
@@ -169,4 +220,162 @@ export const transitAssignmentsService = {
const { data } = await client.post(`${BASE}/${id}/submit`, input); const { data } = await client.post(`${BASE}/${id}/submit`, input);
return data.data ?? data; return data.data ?? data;
}, },
// ── Clearance reads for an assigned booking ──────────────────────────────
// These hit the shared booking/contract endpoints, not `/my`. The API scopes
// them to shipments this agent is assigned to and 404s anything else, so the
// booking id below is safe to pass straight through.
/** Clearance action history — reviews, workflow steps, charges (newest first). */
clearanceHistory: async (
bookingId: string,
): Promise<Freight.ClearanceHistoryEvent[]> => {
const { data } = await client.get(
`/api/bookings/${bookingId}/clearance/history`,
);
return data.data ?? data;
},
/** GL Ethiopia ↔ GL Djibouti shared document thread (read-only here). */
glExchange: async (
bookingId: string,
): Promise<Freight.GlExchangeDocument[]> => {
const { data } = await client.get(`/api/gl-exchange/${bookingId}`);
return data.data ?? data;
},
/** Cargo exception / damage reports logged against the shipment. */
incidents: async (bookingId: string): Promise<Freight.IClearanceIncident[]> => {
const { data } = await client.get(
`/api/contracts/bookings/${bookingId}/incidents`,
);
return data.data ?? data;
},
// ── Djibouti-desk writes, filed by the assigned agent ────────────────────
// The API gates these on the assignment (see `assertPortalClearanceAccess`),
// so an agent can only ever write to a shipment handed to them.
/** Delivery Order (imports). Both dates are required by the API. */
uploadDeliveryOrder: async (
bookingId: string,
files: File[],
dates: { vesselArrivalDate: string; doCollectedDate: string },
): Promise<void> => {
const form = new FormData();
for (const f of files) form.append("files", f);
form.append("vesselArrivalDate", dates.vesselArrivalDate);
form.append("doCollectedDate", dates.doCollectedDate);
await client.post(
`/api/bookings/${bookingId}/clearance/delivery-order`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
},
/**
* Release Order (exports). The API may answer with a HOLD rather than an
* error when the vessel date is too soon — surfaced, not swallowed.
*/
uploadReleaseOrder: async (
bookingId: string,
files: File[],
vesselDepartureDate: string,
): Promise<{ hold?: boolean; holdReason?: string | null }> => {
const form = new FormData();
for (const f of files) form.append("files", f);
form.append("vesselDepartureDate", vesselDepartureDate);
const { data } = await client.post(
`/api/bookings/${bookingId}/clearance/release-order`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return data.data ?? data;
},
/** Ask GL Ethiopia to amend the RO — puts the shipment on hold. */
requestRoAmendment: async (
bookingId: string,
note: string,
): Promise<void> => {
await client.post(`/api/bookings/${bookingId}/clearance/ro-amendment`, {
note,
});
},
/**
* Share a document with both GL desks. Posted as the TRANSIT side, so it is
* never credited to a desk. `visibleToCustomer` additionally surfaces it in
* the customer's own portal.
*/
shareExchangeDocument: async (
bookingId: string,
input: { file: File; title: string; visibleToCustomer: boolean },
): Promise<Freight.GlExchangeDocument> => {
const form = new FormData();
form.append("file", input.file);
form.append("title", input.title.trim());
form.append("visibleToCustomer", String(input.visibleToCustomer));
const { data } = await client.post(`/api/gl-exchange/${bookingId}`, form, {
headers: { "Content-Type": "multipart/form-data" },
});
return data.data ?? data;
},
// ── 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[],
): Promise<void> => {
const form = new FormData();
for (const f of files) form.append("files", f);
await client.post(
`/api/contracts/bookings/${bookingId}/t1-documents`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
},
}; };

View File

@@ -99,7 +99,16 @@ export interface ClearanceWorkflowFile {
label: string; label: string;
uploadedBy: ClearanceWorkflowFileOwner; uploadedBy: ClearanceWorkflowFileOwner;
category: ClearanceWorkflowFileCategory; 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( const CATALOG_BY_CODE = new Map(
@@ -239,6 +248,39 @@ export function exportTransportFileLabel(code: string, index?: number): string {
return code; 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( export function catalogEntriesForTradeDirection(
tradeDirection: string, tradeDirection: string,
): ClearanceWorkflowFileCatalogEntry[] { ): ClearanceWorkflowFileCatalogEntry[] {

View File

@@ -1155,8 +1155,11 @@ export interface GlExchangeDocument {
/** Booking or contract id the document is attached to. */ /** Booking or contract id the document is attached to. */
entityId: string; entityId: string;
title: string; title: string;
/** Desk that uploaded it. */ /**
side: "ET" | "DJ"; * Who uploaded it: either Global Logistics desk, or the transit agent
* assigned to the shipment. Both desks see all three.
*/
side: "ET" | "DJ" | "TRANSIT";
visibleToCustomer: boolean; visibleToCustomer: boolean;
uploadedById: string | null; uploadedById: string | null;
uploadedByName: string | null; uploadedByName: string | null;
@@ -1174,6 +1177,8 @@ export interface GlExchangeDocument {
/** The clearance view for a booking, driving both portals' clearance UI. */ /** The clearance view for a booking, driving both portals' clearance UI. */
export interface ClearanceView { export interface ClearanceView {
/** When the booking was created — the import DO clock starts here. */
bookingCreatedAt?: string | null;
status: string; status: string;
includesCustoms: boolean; includesCustoms: boolean;
inputCode: string | null; inputCode: string | null;