mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 16:43:39 +00:00
Merge pull request #1480 from Tria-plc/origin/freight_feature/transit
Origin/freight feature/transit
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
@@ -786,6 +787,64 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
/** Owner-or-staff gate shared by the per-cancellation actions. */
|
||||
/**
|
||||
* Scope a clearance READ that a transit agent may be making.
|
||||
*
|
||||
* Transit agents are portal accounts holding no permission and belonging to
|
||||
* no company, so the audience guards admit them but the usual company-based
|
||||
* ownership check would 404 every booking. This narrows them to the shipments
|
||||
* assigned to them and leaves every other caller — staff and owning customers
|
||||
* — on the path they already had. Purely widening: nothing that passed before
|
||||
* starts failing here.
|
||||
*/
|
||||
private async assertTransitAgentScope(
|
||||
bookingId: string,
|
||||
user: TCurrentUser,
|
||||
): Promise<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(
|
||||
cancellationId: string,
|
||||
user: TCurrentUser,
|
||||
@@ -1139,6 +1198,9 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(":id/clearance")
|
||||
// A transit agent is a portal account, so MixedAudience admits them without a
|
||||
// permission; `assertTransitAgentScope` below narrows them to the shipments
|
||||
// actually assigned to them.
|
||||
@MixedAudience([
|
||||
FREIGHT_PERMS.bookings.clearanceView,
|
||||
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||
@@ -1147,7 +1209,11 @@ export class BookingsController {
|
||||
summary:
|
||||
"Document-clearance grid (required docs + upload + GL review status)",
|
||||
})
|
||||
getClearance(@Param("id", ParseUUIDPipe) id: string) {
|
||||
async getClearance(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertTransitAgentScope(id, user);
|
||||
return this.transitionService.getClearanceView(id);
|
||||
}
|
||||
|
||||
@@ -1289,8 +1355,11 @@ export class BookingsController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Was staff-only. Opened to the transit agent assigned to the shipment, who
|
||||
// needs the clearance trail for the bookings they handle; every other portal
|
||||
// account is still rejected by the scope check below.
|
||||
@Get(":id/clearance/history")
|
||||
@BookingStaff([
|
||||
@MixedAudience([
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
])
|
||||
@@ -1298,7 +1367,11 @@ export class BookingsController {
|
||||
summary:
|
||||
"Clearance action history for the booking — reviews, workflow steps, charges (newest first)",
|
||||
})
|
||||
getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) {
|
||||
async getClearanceHistory(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
return this.clearanceEventService.list(id);
|
||||
}
|
||||
|
||||
@@ -1321,6 +1394,12 @@ export class BookingsController {
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
|
||||
if (isStaff) return this.clearanceChargeService.list(id);
|
||||
// The transit agent handling this shipment sees the same customer-facing
|
||||
// slice the customer does — charges actually sent, never the internal
|
||||
// draft/billing view `list()` returns.
|
||||
if (await this.bookingsService.isTransitAgentForBooking(user?.id, id)) {
|
||||
return this.clearanceChargeService.listForCustomer(id);
|
||||
}
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
@@ -1788,8 +1867,10 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
// Djibouti-desk write, also filed by the transit agent assigned to this
|
||||
// shipment — `assertPortalClearanceAccess` rejects every other portal caller.
|
||||
@Post(":id/clearance/delivery-order")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
async uploadBookingDeliveryOrder(
|
||||
@@ -1799,6 +1880,7 @@ export class BookingsController {
|
||||
@Body("doCollectedDate") doCollectedDate: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||||
id,
|
||||
files ?? [],
|
||||
@@ -1809,7 +1891,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(":id/clearance/release-order")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
async uploadBookingReleaseOrder(
|
||||
@@ -1818,6 +1900,7 @@ export class BookingsController {
|
||||
@Body("vesselDepartureDate") vesselDepartureDate: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
const result = await this.bookingClearanceService.uploadReleaseOrder(
|
||||
id,
|
||||
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")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
async requestBookingRoAmendment(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RoAmendmentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
const booking = await this.bookingClearanceService.requestRoAmendment(
|
||||
id,
|
||||
dto.note,
|
||||
|
||||
@@ -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
|
||||
* train schedule it is assigned to and the live checkpoint log. The caller is
|
||||
|
||||
@@ -116,6 +116,7 @@ function makeService(overrides?: {
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
|
||||
} as never, // transit agents
|
||||
{ ensureAssignment: jest.fn() } as never, // transit assignments
|
||||
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
|
||||
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
|
||||
{ record: jest.fn() } as never, // clearanceEvents
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import {
|
||||
ContractDocPhase,
|
||||
@@ -30,10 +30,12 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { GlExchangeService } from './gl-exchange.service';
|
||||
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
|
||||
import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service';
|
||||
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitArrivalUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES, transitArrivalDocumentMatcher } from './phased-clearance.util';
|
||||
import type { TransitArrivalDocumentKind } from '@edr/types';
|
||||
|
||||
import {
|
||||
buildClearanceDocHistory,
|
||||
@@ -45,6 +47,8 @@ import { clearanceDocumentsOpen } from '../bookings/clearance.util';
|
||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||
|
||||
export interface BookingClearanceView {
|
||||
/** Booking creation stamp — the import DO is timed from it. */
|
||||
bookingCreatedAt?: string | null;
|
||||
bookingId: string;
|
||||
status: string;
|
||||
includesCustoms: boolean;
|
||||
@@ -176,6 +180,7 @@ export class BookingClearanceService {
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly glExchangeService: GlExchangeService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
private readonly transitAssignmentsService: TransitAssignmentsService,
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly yardScope: YardScopeService,
|
||||
private readonly clearanceEvents: ClearanceEventService,
|
||||
@@ -365,6 +370,7 @@ export class BookingClearanceService {
|
||||
return {
|
||||
bookingId,
|
||||
status: booking.status,
|
||||
bookingCreatedAt: booking.createdAt ? new Date(booking.createdAt).toISOString() : null,
|
||||
includesCustoms,
|
||||
inputCode,
|
||||
outputCode,
|
||||
@@ -385,6 +391,7 @@ export class BookingClearanceService {
|
||||
status: m.status,
|
||||
ownerRegion: m.ownerRegion,
|
||||
metadata: (m.metadata ?? null) as Record<string, unknown> | null,
|
||||
triggeredAt: m.triggeredAt ? new Date(m.triggeredAt).toISOString() : null,
|
||||
sortOrder: m.sortOrder,
|
||||
})),
|
||||
nextAction,
|
||||
@@ -593,6 +600,17 @@ export class BookingClearanceService {
|
||||
transitAssigneeName: agent.name,
|
||||
transitAssigneeAssignedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
// The booking only stores the officer's NAME, which is what the clearance
|
||||
// UI reads. The agent's own portal works off `transit_assignments` rows, so
|
||||
// without this the shipment never reaches the officer's work list — the
|
||||
// desk believes it handed the job over and nothing arrives.
|
||||
await this.transitAssignmentsService.ensureAssignment(
|
||||
bookingId,
|
||||
transitAgentId,
|
||||
userId,
|
||||
);
|
||||
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'TRANSIT_ASSIGNEE_ASSIGNED',
|
||||
@@ -1161,6 +1179,83 @@ export class BookingClearanceService {
|
||||
return { booking: await this.bookingsService.findById(bookingId), hold: false };
|
||||
}
|
||||
|
||||
// ── Transit-agent arrival paperwork (export) ────────────────────────────
|
||||
// Gate pass and Djibouti T1 documents the assigned transit officer files at
|
||||
// Djibouti around train arrival. Append-only sets with per-file removal — see
|
||||
// `persistTransitArrivalUploads`. The clearance view stamps every file with
|
||||
// its upload time, so the portal can measure it against train departure and
|
||||
// arrival without a separate ledger.
|
||||
|
||||
private static readonly TRANSIT_ARRIVAL_LABELS: Record<
|
||||
TransitArrivalDocumentKind,
|
||||
{ name: string; uploaded: string; removed: string }
|
||||
> = {
|
||||
gate_pass: {
|
||||
name: 'gate pass',
|
||||
uploaded: 'GATE_PASS_DOCUMENTS_UPLOADED',
|
||||
removed: 'GATE_PASS_DOCUMENT_REMOVED',
|
||||
},
|
||||
djibouti_t1: {
|
||||
name: 'Djibouti T1',
|
||||
uploaded: 'DJIBOUTI_T1_DOCUMENTS_UPLOADED',
|
||||
removed: 'DJIBOUTI_T1_DOCUMENT_REMOVED',
|
||||
},
|
||||
};
|
||||
|
||||
async uploadTransitArrivalDocuments(
|
||||
bookingId: string,
|
||||
kind: TransitArrivalDocumentKind,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
): Promise<{ uploaded: number }> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException(
|
||||
'Gate pass and Djibouti T1 documents apply only to export bookings.',
|
||||
);
|
||||
}
|
||||
const labels = BookingClearanceService.TRANSIT_ARRIVAL_LABELS[kind];
|
||||
await persistTransitArrivalUploads(this.filesService, bookingId, kind, files ?? [], userId);
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: labels.uploaded,
|
||||
label: `Uploaded ${files.length} ${labels.name} document(s)`,
|
||||
actorId: userId ?? null,
|
||||
metadata: { kind, fileNames: (files ?? []).map((f) => f.originalname) },
|
||||
});
|
||||
return { uploaded: files.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove ONE gate pass / Djibouti T1 file. Only those two code families are
|
||||
* removable here: the route is reachable by the transit agent, and it must
|
||||
* never become a way to delete a declaration or a Release Order.
|
||||
*/
|
||||
async removeTransitArrivalDocument(
|
||||
bookingId: string,
|
||||
fileId: string,
|
||||
userId?: string,
|
||||
): Promise<void> {
|
||||
await this.loadBooking(bookingId);
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const file = files.find((f) => f.id === fileId);
|
||||
const kind = (['gate_pass', 'djibouti_t1'] as const).find((k) =>
|
||||
transitArrivalDocumentMatcher(k)(file?.code),
|
||||
);
|
||||
if (!file || !kind) {
|
||||
throw new NotFoundException('Document not found on this booking.');
|
||||
}
|
||||
await this.filesService.remove(fileId);
|
||||
const labels = BookingClearanceService.TRANSIT_ARRIVAL_LABELS[kind];
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: labels.removed,
|
||||
label: `Removed ${labels.name} document ${file.name}`,
|
||||
actorId: userId ?? null,
|
||||
metadata: { kind, fileName: file.name },
|
||||
});
|
||||
}
|
||||
|
||||
async requestRoAmendment(
|
||||
bookingId: string,
|
||||
note?: string,
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
|
||||
import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service';
|
||||
import {
|
||||
ClearanceMilestone,
|
||||
type RiskAssignmentRecord,
|
||||
@@ -185,6 +186,7 @@ export class ContractClearanceService {
|
||||
private readonly glOperationsService: GlOperationsService,
|
||||
private readonly notifier: ContractNotifierService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
private readonly transitAssignmentsService: TransitAssignmentsService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
@@ -480,6 +482,7 @@ export class ContractClearanceService {
|
||||
status: m.status,
|
||||
ownerRegion: m.ownerRegion,
|
||||
metadata: (m.metadata ?? null) as Record<string, unknown> | null,
|
||||
triggeredAt: m.triggeredAt ? new Date(m.triggeredAt).toISOString() : null,
|
||||
sortOrder: m.sortOrder,
|
||||
})),
|
||||
nextAction,
|
||||
@@ -1230,6 +1233,18 @@ export class ContractClearanceService {
|
||||
transitAssigneeAssignedByUserId: userId ?? null,
|
||||
});
|
||||
|
||||
// Mirror the name onto the officer's own work list, exactly as the
|
||||
// per-booking path does. Contract-level clearance can be assigned before a
|
||||
// booking exists; in that case there is nothing for the officer to work on
|
||||
// yet, and the booking picks the assignment up when it is created.
|
||||
if (cycle.bookingId) {
|
||||
await this.transitAssignmentsService.ensureAssignment(
|
||||
cycle.bookingId,
|
||||
transitAgentId,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.transitAssigneeAssigned(updated, agent.name, previous);
|
||||
return updated;
|
||||
|
||||
@@ -63,6 +63,7 @@ describe('ContractClearanceService — duty dispute', () => {
|
||||
{} as never, // glOperationsService
|
||||
notifier as never,
|
||||
{} as never, // transitAgentsService
|
||||
{} as never, // transitAssignmentsService
|
||||
{} as never, // dataSource
|
||||
);
|
||||
build([
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
@@ -1359,19 +1360,35 @@ export class ContractsController {
|
||||
return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []);
|
||||
}
|
||||
|
||||
// Also filed by the transit agent assigned to the shipment — T1 is their own
|
||||
// transit paperwork. Any other portal caller is rejected below.
|
||||
@Post('bookings/:bookingId/t1-documents')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs',
|
||||
})
|
||||
uploadT1Documents(
|
||||
async uploadT1Documents(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
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')
|
||||
@@ -1534,6 +1551,8 @@ export class ContractsController {
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' })
|
||||
listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
// Reads are open to both audiences (a transit agent assigned to the
|
||||
// shipment included); reporting an incident stays staff-only below.
|
||||
return this.glOperationsService.listIncidents(bookingId);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { ContractTemplatesModule } from '../contract-templates/contract-templates.module';
|
||||
import { TransitAgentsModule } from '../transit-agents/transit-agents.module';
|
||||
import { TransitAssignmentsModule } from '../transit-assignments/transit-assignments.module';
|
||||
|
||||
import { ContractsController } from './contracts.controller';
|
||||
import { ContractsService } from './contracts.service';
|
||||
@@ -94,6 +95,10 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
// ContractDocumentViewModelBuilder when rendering contract PDFs.
|
||||
ContractTemplatesModule,
|
||||
TransitAgentsModule,
|
||||
// Assigning a transit assignee must also land a row in the officer's own
|
||||
// work list. This module is a leaf (it registers Booking as an entity
|
||||
// rather than importing BookingsModule), so no cycle is closed here.
|
||||
TransitAssignmentsModule,
|
||||
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
||||
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
||||
forwardRef(() => BookingsModule),
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('GlOperationsService — final invoice approval', () => {
|
||||
{} as never, // milestoneService
|
||||
billingService as never,
|
||||
notifier as never,
|
||||
{ record: jest.fn() } as never, // clearanceEvents
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
@@ -17,10 +18,11 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { actorLabel } from '../warehouses/current-actor.util';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { BookingStaff, MixedAudience } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
|
||||
import {
|
||||
GlExchangeService,
|
||||
@@ -42,37 +44,61 @@ const asBool = (raw: string | boolean | undefined): boolean =>
|
||||
@ApiBearerAuth()
|
||||
@Controller('gl-exchange')
|
||||
export class GlExchangeController {
|
||||
constructor(private readonly exchangeService: GlExchangeService) {}
|
||||
constructor(
|
||||
private readonly exchangeService: GlExchangeService,
|
||||
private readonly bookingsService: BookingsService,
|
||||
) {}
|
||||
|
||||
// Read opened to the transit agent assigned to the shipment; the POST/PATCH/
|
||||
// DELETE below stay staff-only, so an agent can read the desks' thread but
|
||||
// never post to it.
|
||||
@Get(':entityId')
|
||||
@BookingStaff(GL_EXCHANGE_PERMS)
|
||||
@MixedAudience(GL_EXCHANGE_PERMS)
|
||||
@ApiOperation({
|
||||
summary: 'GL ET ↔ GL DJ shared documents for a booking or contract',
|
||||
})
|
||||
list(
|
||||
async list(
|
||||
@Param('entityId', ParseUUIDPipe) entityId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const isStaff = GL_EXCHANGE_PERMS.some((p) =>
|
||||
hasFreightPermission(user, p),
|
||||
);
|
||||
if (
|
||||
!isStaff &&
|
||||
!(await this.bookingsService.isTransitAgentForBooking(
|
||||
user?.id,
|
||||
entityId,
|
||||
))
|
||||
) {
|
||||
throw new NotFoundException(`Entity ${entityId} not found`);
|
||||
}
|
||||
return this.exchangeService.list(entityId, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
// Open to the transit agent assigned to the shipment as well as both desks:
|
||||
// the officer on the ground is often the one holding the scan either desk
|
||||
// needs. Their post is attributed to the TRANSIT side, never to a desk.
|
||||
@Post(':entityId')
|
||||
@BookingStaff(GL_EXCHANGE_PERMS)
|
||||
@MixedAudience(GL_EXCHANGE_PERMS)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Share a document with the other GL desk' })
|
||||
upload(
|
||||
@ApiOperation({
|
||||
summary: 'Share a document with the GL desks (either desk, or the assigned transit agent)',
|
||||
})
|
||||
async upload(
|
||||
@Param('entityId', ParseUUIDPipe) entityId: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body('title') title: string,
|
||||
@Body('visibleToCustomer') visibleToCustomer: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const actor = await this.resolveActor(entityId, user);
|
||||
return this.exchangeService.upload(
|
||||
entityId,
|
||||
file,
|
||||
{ title, visibleToCustomer: asBool(visibleToCustomer) },
|
||||
this.actor(user),
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,6 +144,36 @@ export class GlExchangeController {
|
||||
* is Djibouti; everyone else (GL Ethiopia, and super admins who hold both)
|
||||
* posts as Ethiopia.
|
||||
*/
|
||||
/**
|
||||
* Who is posting, for a route both desks and the assigned transit agent may
|
||||
* call. Staff keep the desk attribution below; a portal caller must be the
|
||||
* agent assigned to this shipment and posts as TRANSIT, so a document is
|
||||
* never credited to a desk that did not send it.
|
||||
*/
|
||||
private async resolveActor(
|
||||
entityId: string,
|
||||
user: TCurrentUser,
|
||||
): Promise<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 {
|
||||
const side: GlExchangeSide =
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) &&
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { FileRecord } from '../files/entities/file.entity';
|
||||
*/
|
||||
export const GL_EXCHANGE_RESOURCE = 'gl_exchange';
|
||||
|
||||
export type GlExchangeSide = 'ET' | 'DJ';
|
||||
export type GlExchangeSide = 'ET' | 'DJ' | 'TRANSIT';
|
||||
|
||||
export interface GlExchangeActor {
|
||||
userId: string;
|
||||
@@ -180,7 +180,14 @@ export class GlExchangeService {
|
||||
// Pre-title rows (none in practice) fall back to the filename so a list
|
||||
// never renders a blank row.
|
||||
title: record.title ?? record.name,
|
||||
side: record.code === 'DJ' ? 'DJ' : 'ET',
|
||||
// `files.code` carries the poster's side. Anything unrecognised reads as
|
||||
// ET, which is how every pre-TRANSIT row was written.
|
||||
side:
|
||||
record.code === 'DJ'
|
||||
? 'DJ'
|
||||
: record.code === 'TRANSIT'
|
||||
? 'TRANSIT'
|
||||
: 'ET',
|
||||
visibleToCustomer: record.visibleToCustomer,
|
||||
uploadedById: record.uploadedByUserId,
|
||||
uploadedByName: record.uploadedByName,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { InvoiceLine } from '../billing/entities/invoice-line.entity';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||
import { ClearanceEventService } from '../bookings/clearance-event.service';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity';
|
||||
import {
|
||||
@@ -55,6 +56,7 @@ export class GlOperationsService {
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly clearanceEvents: ClearanceEventService,
|
||||
) {}
|
||||
|
||||
private get bookings() {
|
||||
@@ -362,13 +364,15 @@ export class GlOperationsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
|
||||
* is secured on the train schedule (which itself follows wagon allocation).
|
||||
* Replaces the previous batch; locked only once GL Ethiopia closes the T1.
|
||||
* GL Djibouti / the transit agent uploads T1 transport documents (multi-file)
|
||||
* once the train has DEPARTED Djibouti. Replaces the previous batch, so the
|
||||
* batch's file stamps are always the last update; locked only once GL
|
||||
* Ethiopia closes the T1.
|
||||
*/
|
||||
async uploadT1Documents(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
): Promise<{ uploaded: number }> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
@@ -376,24 +380,24 @@ export class GlOperationsService {
|
||||
}
|
||||
|
||||
const state = await this.t1State(bookingId);
|
||||
if (!state.wagonAllocated) {
|
||||
if (!state.trainDepartedAt) {
|
||||
throw new BadRequestException(
|
||||
'Wagons must be allocated before T1 transport documents can be uploaded.',
|
||||
);
|
||||
}
|
||||
const gatepass = await this.gatepassForBooking(bookingId);
|
||||
if (!gatepass.granted) {
|
||||
throw new BadRequestException(
|
||||
'Secure the Djibouti gate pass on the train schedule before uploading T1 transport documents.',
|
||||
'T1 transport documents can be uploaded once the train has departed.',
|
||||
);
|
||||
}
|
||||
if (state.closed) {
|
||||
throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.');
|
||||
}
|
||||
// Departure no longer locks T1 docs — GL DJ may replace them any time until
|
||||
// GL Ethiopia closes/accepts the T1.
|
||||
|
||||
await persistT1TransportUploads(this.filesService, bookingId, files);
|
||||
// History row so the portal can tell a first upload from a replacement.
|
||||
await this.clearanceEvents.record({
|
||||
bookingId,
|
||||
action: 'T1_DOCUMENTS_UPLOADED',
|
||||
label: `Uploaded T1 transport documents (${files.length} file(s))`,
|
||||
actorId: userId ?? null,
|
||||
metadata: { fileNames: files.map((f) => f.originalname) },
|
||||
});
|
||||
return { uploaded: files.length };
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,17 @@ import {
|
||||
isImportTransitPermitFileCode,
|
||||
isExportTransportFileCode,
|
||||
isT1TransportFileCode,
|
||||
isGatePassFileCode,
|
||||
isDjiboutiT1FileCode,
|
||||
exportTransportFileLabel,
|
||||
t1TransportFileLabel,
|
||||
transitPermitFileLabel,
|
||||
gatePassFileLabel,
|
||||
djiboutiT1FileLabel,
|
||||
GATE_PASS_FILE_PREFIX,
|
||||
DJIBOUTI_T1_FILE_PREFIX,
|
||||
type ClearanceWorkflowFile,
|
||||
type TransitArrivalDocumentKind,
|
||||
} from '@edr/types';
|
||||
|
||||
/** Require at least one declaration file in the upload batch. */
|
||||
@@ -46,6 +53,7 @@ type DeclarationFileStore = {
|
||||
resource: string;
|
||||
code: string;
|
||||
file: Express.Multer.File;
|
||||
uploadedByUserId?: string | null;
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
|
||||
@@ -445,9 +453,88 @@ export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [
|
||||
/** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */
|
||||
export const DJ_BOOKING_QUEUE_STATUSES = PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES;
|
||||
|
||||
/** Prefix + matcher + label for each transit-agent arrival document set. */
|
||||
const TRANSIT_ARRIVAL_DOCUMENT_SETS: Record<
|
||||
TransitArrivalDocumentKind,
|
||||
{ prefix: string; matches: (code: string | null | undefined) => boolean; label: (i?: number) => string }
|
||||
> = {
|
||||
gate_pass: { prefix: GATE_PASS_FILE_PREFIX, matches: isGatePassFileCode, label: gatePassFileLabel },
|
||||
djibouti_t1: { prefix: DJIBOUTI_T1_FILE_PREFIX, matches: isDjiboutiT1FileCode, label: djiboutiT1FileLabel },
|
||||
};
|
||||
|
||||
export function transitArrivalDocumentMatcher(
|
||||
kind: TransitArrivalDocumentKind,
|
||||
): (code: string | null | undefined) => boolean {
|
||||
return TRANSIT_ARRIVAL_DOCUMENT_SETS[kind].matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* APPEND a batch of transit-agent arrival documents (gate pass / Djibouti T1)
|
||||
* to a booking. Unlike the DO/RO persisters this never deletes what is already
|
||||
* there: the officer collects these one at a time as the paperwork comes in,
|
||||
* and each file is removed individually. Codes continue from the highest
|
||||
* existing index so a removed file's slot is never reused.
|
||||
*/
|
||||
export async function persistTransitArrivalUploads(
|
||||
store: DeclarationFileStore,
|
||||
bookingId: string,
|
||||
kind: TransitArrivalDocumentKind,
|
||||
files: Express.Multer.File[],
|
||||
uploadedByUserId?: string | null,
|
||||
): Promise<void> {
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No documents uploaded');
|
||||
}
|
||||
const set = TRANSIT_ARRIVAL_DOCUMENT_SETS[kind];
|
||||
const existing = await store.findByResource(bookingId, 'bookings');
|
||||
const nextIndex =
|
||||
existing
|
||||
.filter((f) => set.matches(f.code))
|
||||
.map((f) => Number.parseInt((f.code ?? '').slice(set.prefix.length), 10))
|
||||
.filter((n) => Number.isFinite(n))
|
||||
.reduce((max, n) => Math.max(max, n + 1), 0);
|
||||
|
||||
await Promise.all(
|
||||
files.map((file, index) =>
|
||||
store.upload({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: `${set.prefix}${nextIndex + index}`,
|
||||
file: { ...file, fieldname: `${set.prefix}${nextIndex + index}` },
|
||||
uploadedByUserId: uploadedByUserId ?? null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
type WorkflowFileInput = {
|
||||
code?: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
createdAt?: Date | string | null;
|
||||
updatedAt?: Date | string | null;
|
||||
size?: number | null;
|
||||
mimeType?: string | null;
|
||||
};
|
||||
|
||||
function toWorkflowFileRef(file: WorkflowFileInput): NonNullable<ClearanceWorkflowFile['file']> {
|
||||
const iso = (v: Date | string | null | undefined) =>
|
||||
v ? new Date(v).toISOString() : null;
|
||||
return {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
url: file.url,
|
||||
uploadedAt: iso(file.createdAt),
|
||||
updatedAt: iso(file.updatedAt),
|
||||
size: file.size ?? null,
|
||||
mimeType: file.mimeType ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build labeled phased-customs file rows from resource files. */
|
||||
export function buildWorkflowFiles(
|
||||
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
||||
files: WorkflowFileInput[],
|
||||
tradeDirection: string,
|
||||
): ClearanceWorkflowFile[] {
|
||||
const fileByCode = new Map(
|
||||
@@ -465,7 +552,7 @@ export function buildWorkflowFiles(
|
||||
label: entry.label,
|
||||
uploadedBy: entry.uploadedBy,
|
||||
category: entry.category,
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
file: toWorkflowFileRef(file),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -481,7 +568,7 @@ export function buildWorkflowFiles(
|
||||
label: declarationFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_et',
|
||||
category: 'declaration',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
file: toWorkflowFileRef(file),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -497,7 +584,7 @@ export function buildWorkflowFiles(
|
||||
label: draftDeclarationFileLabel(index),
|
||||
uploadedBy: 'gl_et',
|
||||
category: 'draft_declaration',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
file: toWorkflowFileRef(file),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -514,7 +601,7 @@ export function buildWorkflowFiles(
|
||||
label: transitPermitFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_et',
|
||||
category: 'transit',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
file: toWorkflowFileRef(file),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -530,7 +617,7 @@ export function buildWorkflowFiles(
|
||||
label: deliveryOrderFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_dj',
|
||||
category: 'djibouti',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
file: toWorkflowFileRef(file),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -546,7 +633,7 @@ export function buildWorkflowFiles(
|
||||
label: t1TransportFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_dj',
|
||||
category: 'djibouti',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
file: toWorkflowFileRef(file),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -564,7 +651,7 @@ export function buildWorkflowFiles(
|
||||
label: releaseOrderFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_dj',
|
||||
category: 'djibouti',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
file: toWorkflowFileRef(file),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -580,9 +667,32 @@ export function buildWorkflowFiles(
|
||||
label: exportTransportFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_et',
|
||||
category: 'transit',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
file: toWorkflowFileRef(file),
|
||||
});
|
||||
});
|
||||
|
||||
// Transit-agent arrival paperwork, ordered by slot index (upload order).
|
||||
const byIndex = (prefix: string) => (a: WorkflowFileInput, b: WorkflowFileInput) =>
|
||||
Number.parseInt((a.code ?? '').slice(prefix.length), 10) -
|
||||
Number.parseInt((b.code ?? '').slice(prefix.length), 10);
|
||||
|
||||
for (const kind of ['gate_pass', 'djibouti_t1'] as const) {
|
||||
const set = TRANSIT_ARRIVAL_DOCUMENT_SETS[kind];
|
||||
files
|
||||
.filter((f) => f.code && set.matches(f.code) && !included.has(f.code))
|
||||
.sort(byIndex(set.prefix))
|
||||
.forEach((file, index) => {
|
||||
if (!file.code) return;
|
||||
included.add(file.code);
|
||||
out.push({
|
||||
code: file.code,
|
||||
label: set.label(index),
|
||||
uploadedBy: 'gl_dj',
|
||||
category: 'djibouti',
|
||||
file: toWorkflowFileRef(file),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('ContractClearanceService — transit assignee', () => {
|
||||
{} as never,
|
||||
notifier as never,
|
||||
transitAgentsService as never,
|
||||
{ ensureAssignment: jest.fn() } as never, // transit assignments
|
||||
{} as never, // dataSource
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,9 @@ import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||
import { TransitAgentsModule } from "../transit-agents/transit-agents.module";
|
||||
import { TransitAssignment } from "./entities/transit-assignment.entity";
|
||||
import { TransitAssignmentsController } from "./transit-assignments.controller";
|
||||
@@ -14,7 +16,15 @@ import { TransitAssignmentsService } from "./transit-assignments.service";
|
||||
// `Booking` is registered as an ENTITY rather than importing BookingsModule:
|
||||
// this module only confirms a booking id exists, and that module would drag
|
||||
// its whole graph (billing, contracts, scheduling, first/last mile) along.
|
||||
TypeOrmModule.forFeature([TransitAssignment, Booking]),
|
||||
// Milestones and train schedules are read for the agent's dashboard
|
||||
// timings (declaration stamps, departure/arrival fallbacks) — entities
|
||||
// only, for the same reason as Booking.
|
||||
TypeOrmModule.forFeature([
|
||||
TransitAssignment,
|
||||
Booking,
|
||||
ClearanceMilestone,
|
||||
TrainSchedule,
|
||||
]),
|
||||
FilesModule,
|
||||
TransitAgentsModule,
|
||||
],
|
||||
|
||||
@@ -38,6 +38,8 @@ describe("TransitAssignmentsService", () => {
|
||||
remove: jest.Mock;
|
||||
};
|
||||
let service: TransitAssignmentsService;
|
||||
let milestones: { find: jest.Mock };
|
||||
let trainSchedules: { find: jest.Mock };
|
||||
|
||||
const row = (over: Partial<TransitAssignment> = {}) =>
|
||||
({
|
||||
@@ -81,11 +83,16 @@ describe("TransitAssignmentsService", () => {
|
||||
remove: jest.fn(),
|
||||
};
|
||||
|
||||
milestones = { find: jest.fn().mockResolvedValue([]) };
|
||||
trainSchedules = { find: jest.fn().mockResolvedValue([]) };
|
||||
|
||||
service = new TransitAssignmentsService(
|
||||
assignments as never,
|
||||
agents as never,
|
||||
bookings as never,
|
||||
files as never,
|
||||
milestones as never,
|
||||
trainSchedules as never,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -294,222 +301,127 @@ describe("TransitAssignmentsService", () => {
|
||||
|
||||
describe("myStats", () => {
|
||||
const at = (iso: string) => new Date(iso);
|
||||
const DEPARTED = at("2026-08-27T20:00:00Z");
|
||||
|
||||
const withRows = (rows: Record<string, unknown>[]) => {
|
||||
assignments.findByTransitAgent.mockResolvedValue(
|
||||
rows.map((r, i) => row({ id: `ta-${i}`, ...r } as never)),
|
||||
rows.map((r, i) => row({ id: `ta-${i}`, bookingId: `bk-${i}`, ...r } as never)),
|
||||
);
|
||||
};
|
||||
const bookingFiles = (entries: Record<string, Array<[string, string]>>) => {
|
||||
files.findByResourceIdsGrouped.mockImplementation(
|
||||
async (_ids: string[], resource: string) =>
|
||||
resource === "bookings"
|
||||
? new Map(
|
||||
Object.entries(entries).map(([bookingId, list]) => [
|
||||
bookingId,
|
||||
list.map(([code, iso]) => ({ code, createdAt: at(iso) })),
|
||||
]),
|
||||
)
|
||||
: new Map(),
|
||||
);
|
||||
files.findByResourceIdsGrouped.mockResolvedValue(new Map());
|
||||
};
|
||||
|
||||
it("uses the median, so one reopened assignment cannot skew the headline", async () => {
|
||||
it("measures transit from the train's departure to its arrival, using the median", async () => {
|
||||
withRows([
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-28T10:35:00Z"),
|
||||
},
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-28T12:10:00Z"),
|
||||
},
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-28T13:45:00Z"),
|
||||
},
|
||||
// 47h outlier: a mean would report ~12h, which describes nobody.
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-30T08:00:00Z"),
|
||||
},
|
||||
{ booking: { loadedAt: DEPARTED, arrivedAt: at("2026-08-28T06:00:00Z"), tradeDirection: "EXPORT" } },
|
||||
{ booking: { loadedAt: DEPARTED, arrivedAt: at("2026-08-28T08:00:00Z"), tradeDirection: "EXPORT" } },
|
||||
// 3-day outlier: a mean would describe none of the three.
|
||||
{ booking: { loadedAt: DEPARTED, arrivedAt: at("2026-08-30T20:00:00Z"), tradeDirection: "EXPORT" } },
|
||||
// Still rolling: contributes nothing, not zero.
|
||||
{ booking: { loadedAt: DEPARTED, arrivedAt: null, tradeDirection: "EXPORT" } },
|
||||
]);
|
||||
bookingFiles({});
|
||||
|
||||
const stats = await service.myStats("user-1");
|
||||
|
||||
// 95/190/285/2820 -> even count, so the median averages the middle two.
|
||||
// A mean would be 848 minutes, describing none of the four.
|
||||
expect(stats.performance.medianClearanceMinutes).toBe(238);
|
||||
expect(stats.performance.slowestClearanceMinutes).toBe(2820);
|
||||
expect(stats.timings.transit).toEqual({
|
||||
median: 720,
|
||||
fastest: 600,
|
||||
slowest: 4320,
|
||||
measured: 3,
|
||||
});
|
||||
expect(stats.totals.inTransit).toBe(1);
|
||||
expect(stats.totals.arrived).toBe(3);
|
||||
});
|
||||
|
||||
it("bands clearance times into the SLA buckets", async () => {
|
||||
withRows([
|
||||
it("times the Release Order from the declaration to the LAST RO upload", async () => {
|
||||
withRows([{ booking: { tradeDirection: "EXPORT", loadedAt: null, arrivedAt: null } }]);
|
||||
milestones.find.mockResolvedValue([
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-28T10:30:00Z"),
|
||||
},
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-28T13:00:00Z"),
|
||||
},
|
||||
{
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: at("2026-08-29T09:00:00Z"),
|
||||
bookingId: "bk-0",
|
||||
milestoneCode: "DECLARED",
|
||||
status: "COMPLETED",
|
||||
triggeredAt: at("2026-08-27T08:00:00Z"),
|
||||
},
|
||||
]);
|
||||
bookingFiles({
|
||||
"bk-0": [
|
||||
["release_order_0", "2026-08-27T09:30:00Z"],
|
||||
// Replaced batch — the later stamp is the one that counts.
|
||||
["release_order_1", "2026-08-27T11:00:00Z"],
|
||||
],
|
||||
});
|
||||
|
||||
const stats = await service.myStats("user-1");
|
||||
const [item] = stats.items;
|
||||
|
||||
expect(stats.sla).toEqual({ under2h: 1, under6h: 1, over6h: 1 });
|
||||
expect(stats.performance.onTimeRate).toBe(67);
|
||||
expect(item.declaredAt).toBe("2026-08-27T08:00:00.000Z");
|
||||
expect(item.roAt).toBe("2026-08-27T11:00:00.000Z");
|
||||
expect(item.timings.declarationToRo).toBe(180);
|
||||
expect(stats.timings.declarationToRo.median).toBe(180);
|
||||
expect(item.nextAction).toEqual({ kind: "wait", label: "Awaiting train departure" });
|
||||
});
|
||||
|
||||
it("counts coverage only over dispatched bookings", async () => {
|
||||
it("points the officer at the next upload the detail page would actually allow", async () => {
|
||||
withRows([
|
||||
{ booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } },
|
||||
{ booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } },
|
||||
// Scheduled bookings cannot receive documents yet, so counting them
|
||||
// would report a failure the agent could not have avoided.
|
||||
{ booking: { arrivedAt: null, schedulingStatus: "SCHEDULED" } },
|
||||
// Import, nothing filed: the DO comes first.
|
||||
{ booking: { tradeDirection: "IMPORT", loadedAt: null, arrivedAt: null } },
|
||||
// Import with a DO but no departure yet: T1 is still locked.
|
||||
{ booking: { tradeDirection: "IMPORT", loadedAt: null, arrivedAt: null } },
|
||||
// Import, departed, no T1: upload it.
|
||||
{ booking: { tradeDirection: "IMPORT", loadedAt: DEPARTED, arrivedAt: null } },
|
||||
// Export, arrived with an RO but no gate pass yet.
|
||||
{
|
||||
booking: {
|
||||
tradeDirection: "EXPORT",
|
||||
loadedAt: DEPARTED,
|
||||
arrivedAt: at("2026-08-28T06:00:00Z"),
|
||||
},
|
||||
},
|
||||
]);
|
||||
milestones.find.mockResolvedValue([
|
||||
{ bookingId: "bk-3", milestoneCode: "DECLARED", status: "COMPLETED", triggeredAt: at("2026-08-26T08:00:00Z") },
|
||||
]);
|
||||
bookingFiles({
|
||||
"bk-1": [["delivery_order_0", "2026-08-26T10:00:00Z"]],
|
||||
"bk-2": [["delivery_order_0", "2026-08-26T10:00:00Z"]],
|
||||
"bk-3": [["release_order_0", "2026-08-26T10:00:00Z"]],
|
||||
});
|
||||
|
||||
const stats = await service.myStats("user-1");
|
||||
const byBooking = new Map(stats.items.map((i) => [i.bookingId, i]));
|
||||
|
||||
expect(stats.coverage.dispatched).toBe(2);
|
||||
expect(stats.coverage.withDocuments).toBe(0);
|
||||
expect(byBooking.get("bk-0")?.nextAction.document).toBe("do");
|
||||
expect(byBooking.get("bk-1")?.nextAction).toEqual({
|
||||
kind: "wait",
|
||||
label: "Awaiting train departure",
|
||||
});
|
||||
expect(byBooking.get("bk-2")?.nextAction.document).toBe("t1");
|
||||
expect(byBooking.get("bk-3")?.nextAction.document).toBe("gate_pass");
|
||||
expect(stats.pending).toEqual({ ro: 0, do: 1, t1: 1, gatePass: 1, djiboutiT1: 0 });
|
||||
expect(stats.totals.actionNeeded).toBe(3);
|
||||
});
|
||||
|
||||
it("reports nulls rather than zero when nothing has been measured", async () => {
|
||||
withRows([{ status: TransitAssignmentStatus.NotStarted }]);
|
||||
withRows([{ status: TransitAssignmentStatus.NotStarted, booking: { arrivedAt: null } }]);
|
||||
bookingFiles({});
|
||||
|
||||
const stats = await service.myStats("user-1");
|
||||
|
||||
expect(stats.performance.medianClearanceMinutes).toBeNull();
|
||||
expect(stats.performance.onTimeRate).toBeNull();
|
||||
expect(stats.timings.transit.median).toBeNull();
|
||||
expect(stats.timings.arrivalToFinish.median).toBeNull();
|
||||
expect(stats.totals.open).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("customerName", () => {
|
||||
it("flattens the booking's company name", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
booking: {
|
||||
id: "bk-1",
|
||||
arrivedAt: ARRIVED,
|
||||
schedulingStatus: "DISPATCHED",
|
||||
company: { name: "SHAFICI PHARMACEUTICAL" },
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await service.findById("ta-1")).customerName).toBe(
|
||||
"SHAFICI PHARMACEUTICAL",
|
||||
);
|
||||
});
|
||||
|
||||
it("is null when the booking has no company", async () => {
|
||||
expect((await service.findById("ta-1")).customerName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canUploadDocuments", () => {
|
||||
it("is true for an open assignment on a dispatched booking", async () => {
|
||||
expect((await service.findById("ta-1")).canUploadDocuments).toBe(true);
|
||||
});
|
||||
|
||||
it("is false before dispatch", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
booking: {
|
||||
id: "bk-1",
|
||||
arrivedAt: null,
|
||||
schedulingStatus: "SCHEDULED",
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
expect((await service.findById("ta-1")).canUploadDocuments).toBe(false);
|
||||
});
|
||||
|
||||
it("is false once finished", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
expect((await service.findById("ta-1")).canUploadDocuments).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("portal scoping", () => {
|
||||
it("hides another agent's assignment behind a NotFound", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({ transitAgentId: "someone-else" }),
|
||||
);
|
||||
|
||||
await expect(service.findMineById("user-1", "ta-1")).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an account that is not a transit agent", async () => {
|
||||
agents.findByUserId.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findMine("user-1")).rejects.toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it("pins the query to the session's agent and passes the filters through", async () => {
|
||||
await service.findMine("user-1", {
|
||||
search: "BK-2026",
|
||||
status: TransitAssignmentStatus.InProgress,
|
||||
schedulingStatus: "DISPATCHED",
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const [agentId, filter, skip, take] =
|
||||
assignments.findByTransitAgentPaginated.mock.calls[0];
|
||||
// The agent id comes from the session, never from the query — otherwise
|
||||
// one agent could page through another agent's work.
|
||||
expect(agentId).toBe("ag-1");
|
||||
expect(filter).toMatchObject({
|
||||
search: "BK-2026",
|
||||
status: TransitAssignmentStatus.InProgress,
|
||||
schedulingStatus: "DISPATCHED",
|
||||
});
|
||||
expect(skip).toBe(10);
|
||||
expect(take).toBe(10);
|
||||
});
|
||||
|
||||
it("reports pagination meta", async () => {
|
||||
assignments.findByTransitAgentPaginated.mockResolvedValue([[], 45]);
|
||||
|
||||
const result = await service.findMine("user-1", { pageSize: 20 });
|
||||
|
||||
expect(result.meta).toEqual({
|
||||
total: 45,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
totalPages: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("save moves the assignment to IN_PROGRESS, finish closes it", async () => {
|
||||
await service.submitMine("user-1", "ta-1", { finish: false });
|
||||
expect(assignments.update.mock.calls[0][1].status).toBe(
|
||||
TransitAssignmentStatus.InProgress,
|
||||
);
|
||||
|
||||
assignments.update.mockClear();
|
||||
await service.submitMine("user-1", "ta-1", { finish: true });
|
||||
expect(assignments.update.mock.calls[0][1].status).toBe(
|
||||
TransitAssignmentStatus.Finished,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to re-submit an already finished assignment", async () => {
|
||||
assignments.findOneWithRelations.mockResolvedValue(
|
||||
row({
|
||||
status: TransitAssignmentStatus.Finished,
|
||||
finishedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.submitMine("user-1", "ta-1", { finish: true }),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,10 +7,19 @@ import {
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { In, Repository } from "typeorm";
|
||||
import {
|
||||
isDeliveryOrderFileCode,
|
||||
isDjiboutiT1FileCode,
|
||||
isGatePassFileCode,
|
||||
isReleaseOrderFileCode,
|
||||
isT1TransportFileCode,
|
||||
} from "@edr/types";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||
import { TransitAgentsRepository } from "../transit-agents/transit-agents.repository";
|
||||
import { FileRecord } from "../files/entities/file.entity";
|
||||
import { CreateTransitAssignmentDto } from "./dto/create-transit-assignment.dto";
|
||||
@@ -66,6 +75,86 @@ export type TransitAssignmentView = TransitAssignment & {
|
||||
files?: TransitAssignmentFileView[];
|
||||
};
|
||||
|
||||
export type TransitTradeDirection = "IMPORT" | "EXPORT";
|
||||
export type TransitDocumentKind = "ro" | "do" | "t1" | "gate_pass" | "djibouti_t1";
|
||||
|
||||
export interface TransitNextAction {
|
||||
kind: "upload" | "wait" | "done";
|
||||
label: string;
|
||||
document?: TransitDocumentKind;
|
||||
}
|
||||
|
||||
/** Minutes, or null when nothing has been measured yet — never zero. */
|
||||
export interface TransitTimingSummary {
|
||||
median: number | null;
|
||||
fastest: number | null;
|
||||
slowest: number | null;
|
||||
measured: number;
|
||||
}
|
||||
|
||||
export interface TransitStatItem {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
reference: string | null;
|
||||
customerName: string | null;
|
||||
tradeDirection: TransitTradeDirection;
|
||||
status: TransitAssignmentStatus;
|
||||
schedulingStatus: string | null;
|
||||
trainLabel: string | null;
|
||||
assignedAt: string;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
bookingCreatedAt: string | null;
|
||||
departedAt: string | null;
|
||||
arrivedAt: string | null;
|
||||
declaredAt: string | null;
|
||||
roAt: string | null;
|
||||
doAt: string | null;
|
||||
t1At: string | null;
|
||||
t1Closed: boolean;
|
||||
gatePassAt: string | null;
|
||||
djiboutiT1At: string | null;
|
||||
documents: {
|
||||
ro: number;
|
||||
do: number;
|
||||
t1: number;
|
||||
gatePass: number;
|
||||
djiboutiT1: number;
|
||||
own: number;
|
||||
};
|
||||
timings: {
|
||||
transit: number | null;
|
||||
declarationToRo: number | null;
|
||||
bookingToDo: number | null;
|
||||
departureToT1: number | null;
|
||||
arrivalToT1: number | null;
|
||||
arrivalToGatePass: number | null;
|
||||
arrivalToDjiboutiT1: number | null;
|
||||
arrivalToFinish: number | null;
|
||||
};
|
||||
nextAction: TransitNextAction;
|
||||
}
|
||||
|
||||
export interface TransitStats {
|
||||
totals: {
|
||||
assignments: number;
|
||||
open: number;
|
||||
notStarted: number;
|
||||
inProgress: number;
|
||||
finished: number;
|
||||
imports: number;
|
||||
exports: number;
|
||||
awaitingDeparture: number;
|
||||
inTransit: number;
|
||||
arrived: number;
|
||||
actionNeeded: number;
|
||||
};
|
||||
timings: Record<keyof TransitStatItem["timings"], TransitTimingSummary>;
|
||||
documents: TransitStatItem["documents"];
|
||||
pending: { ro: number; do: number; t1: number; gatePass: number; djiboutiT1: number };
|
||||
items: TransitStatItem[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransitAssignmentsService {
|
||||
constructor(
|
||||
@@ -77,6 +166,10 @@ export class TransitAssignmentsService {
|
||||
@InjectRepository(Booking)
|
||||
private readonly bookingsRepository: Repository<Booking>,
|
||||
private readonly filesService: FilesService,
|
||||
@InjectRepository(ClearanceMilestone)
|
||||
private readonly milestonesRepository: Repository<ClearanceMilestone>,
|
||||
@InjectRepository(TrainSchedule)
|
||||
private readonly trainSchedulesRepository: Repository<TrainSchedule>,
|
||||
) {}
|
||||
|
||||
private static minutesBetween(
|
||||
@@ -179,98 +272,302 @@ export class TransitAssignmentsService {
|
||||
* reopened days later drags an average far enough to make the whole panel
|
||||
* lie about typical performance.
|
||||
*/
|
||||
async myStats(userId: string) {
|
||||
/**
|
||||
* The agent's dashboard, every figure derived from stamps that already exist:
|
||||
* the train's departure and arrival, the booking's clearance milestones, and
|
||||
* the upload time of each document on the booking (RO / DO / T1 / gate pass /
|
||||
* Djibouti T1). Replaced batches carry a fresh stamp, so an "uploaded" time
|
||||
* here is always the LAST update, matching the detail page.
|
||||
*
|
||||
* Nothing is stored: a corrected timestamp cannot leave a stale number behind.
|
||||
*/
|
||||
async myStats(userId: string): Promise<TransitStats> {
|
||||
const agent = await this.requireAgentForUser(userId);
|
||||
const rows = await this.assignmentsRepository.findByTransitAgent(agent.id);
|
||||
const bookingIds = [...new Set(rows.map((r) => r.bookingId))];
|
||||
|
||||
const docCounts = rows.length
|
||||
? await this.filesService.findByResourceIdsGrouped(
|
||||
rows.map((r) => r.id),
|
||||
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||||
)
|
||||
: new Map<string, unknown[]>();
|
||||
const [ownDocs, bookingDocs, milestones, schedules] = await Promise.all([
|
||||
rows.length
|
||||
? this.filesService.findByResourceIdsGrouped(
|
||||
rows.map((r) => r.id),
|
||||
TRANSIT_ASSIGNMENT_FILE_RESOURCE,
|
||||
)
|
||||
: new Map<string, FileRecord[]>(),
|
||||
bookingIds.length
|
||||
? this.filesService.findByResourceIdsGrouped(bookingIds, "bookings")
|
||||
: new Map<string, FileRecord[]>(),
|
||||
bookingIds.length
|
||||
? this.milestonesRepository.find({
|
||||
where: { bookingId: In(bookingIds) },
|
||||
select: ["bookingId", "milestoneCode", "status", "triggeredAt"],
|
||||
})
|
||||
: [],
|
||||
(() => {
|
||||
const ids = [
|
||||
...new Set(
|
||||
rows
|
||||
.map((r) => r.booking?.trainScheduleId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
return ids.length
|
||||
? this.trainSchedulesRepository.find({
|
||||
where: { id: In(ids) },
|
||||
select: [
|
||||
"id",
|
||||
"trainNumber",
|
||||
"voyageNumber",
|
||||
"actualDepartureAt",
|
||||
"actualArrivalAt",
|
||||
],
|
||||
})
|
||||
: [];
|
||||
})(),
|
||||
]);
|
||||
|
||||
const minutes = (from?: Date | null, to?: Date | null) =>
|
||||
from && to ? Math.floor((to.getTime() - from.getTime()) / 60_000) : null;
|
||||
const scheduleById = new Map(schedules.map((sch) => [sch.id, sch]));
|
||||
const milestonesByBooking = new Map<string, ClearanceMilestone[]>();
|
||||
for (const m of milestones) {
|
||||
if (!m.bookingId) continue;
|
||||
const bucket = milestonesByBooking.get(m.bookingId);
|
||||
if (bucket) bucket.push(m);
|
||||
else milestonesByBooking.set(m.bookingId, [m]);
|
||||
}
|
||||
|
||||
const iso = (d?: Date | string | null): string | null =>
|
||||
d ? new Date(d).toISOString() : null;
|
||||
const minutes = (from?: string | null, to?: string | null): number | null =>
|
||||
from && to
|
||||
? Math.floor((new Date(to).getTime() - new Date(from).getTime()) / 60_000)
|
||||
: null;
|
||||
/** Latest upload stamp among files matching a code family. */
|
||||
const latest = (
|
||||
files: FileRecord[],
|
||||
matches: (code: string | null | undefined) => boolean,
|
||||
): { at: string | null; count: number } => {
|
||||
const hits = files.filter((f) => matches(f.code));
|
||||
return {
|
||||
count: hits.length,
|
||||
at: hits.reduce<string | null>((max, f) => {
|
||||
const stamp = iso(f.createdAt);
|
||||
return stamp && (!max || stamp > max) ? stamp : max;
|
||||
}, null),
|
||||
};
|
||||
};
|
||||
/** Earliest upload stamp — for append-only sets the FIRST document matters. */
|
||||
const earliest = (
|
||||
files: FileRecord[],
|
||||
matches: (code: string | null | undefined) => boolean,
|
||||
): { at: string | null; count: number } => {
|
||||
const hits = files.filter((f) => matches(f.code));
|
||||
return {
|
||||
count: hits.length,
|
||||
at: hits.reduce<string | null>((min, f) => {
|
||||
const stamp = iso(f.createdAt);
|
||||
return stamp && (!min || stamp < min) ? stamp : min;
|
||||
}, null),
|
||||
};
|
||||
};
|
||||
|
||||
const items: TransitStatItem[] = rows.map((row) => {
|
||||
const booking = row.booking;
|
||||
const tradeDirection: TransitTradeDirection =
|
||||
booking?.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT";
|
||||
const schedule = booking?.trainScheduleId
|
||||
? scheduleById.get(booking.trainScheduleId)
|
||||
: undefined;
|
||||
|
||||
// Same rule as the clearance view's train state: the booking's own
|
||||
// load/unload stamps first, the schedule's actuals only as a fallback for
|
||||
// legacy bookings that predate per-booking loading.
|
||||
const departedAt = iso(booking?.loadedAt ?? schedule?.actualDepartureAt);
|
||||
const arrivedAt = iso(
|
||||
booking?.arrivedAt ??
|
||||
(booking?.loadedAt ? null : schedule?.actualArrivalAt),
|
||||
);
|
||||
|
||||
const files = bookingDocs.get(row.bookingId) ?? [];
|
||||
const ms = milestonesByBooking.get(row.bookingId) ?? [];
|
||||
const milestone = (code: string) => ms.find((m) => m.milestoneCode === code);
|
||||
const done = (code: string) => {
|
||||
const m = milestone(code);
|
||||
return m?.status === "COMPLETED" || m?.status === "SKIPPED";
|
||||
};
|
||||
|
||||
const declared = done("DECLARED");
|
||||
const declaredAt = iso(milestone("DECLARED")?.triggeredAt);
|
||||
const ro = latest(files, isReleaseOrderFileCode);
|
||||
const deliveryOrder = latest(files, isDeliveryOrderFileCode);
|
||||
const t1 = latest(files, isT1TransportFileCode);
|
||||
const gatePass = earliest(files, isGatePassFileCode);
|
||||
const djiboutiT1 = earliest(files, isDjiboutiT1FileCode);
|
||||
const t1Closed = milestone("T1_CLOSED")?.status === "COMPLETED";
|
||||
const bookingCreatedAt = iso(booking?.createdAt);
|
||||
const finishedAt = iso(row.finishedAt);
|
||||
const finished = row.status === TransitAssignmentStatus.Finished;
|
||||
|
||||
const timings: TransitStatItem["timings"] = {
|
||||
transit: minutes(departedAt, arrivedAt),
|
||||
declarationToRo: tradeDirection === "EXPORT" ? minutes(declaredAt, ro.at) : null,
|
||||
bookingToDo:
|
||||
tradeDirection === "IMPORT" ? minutes(bookingCreatedAt, deliveryOrder.at) : null,
|
||||
departureToT1: tradeDirection === "IMPORT" ? minutes(departedAt, t1.at) : null,
|
||||
arrivalToT1: tradeDirection === "IMPORT" ? minutes(arrivedAt, t1.at) : null,
|
||||
arrivalToGatePass:
|
||||
tradeDirection === "EXPORT" ? minutes(arrivedAt, gatePass.at) : null,
|
||||
arrivalToDjiboutiT1:
|
||||
tradeDirection === "EXPORT" ? minutes(arrivedAt, djiboutiT1.at) : null,
|
||||
arrivalToFinish: minutes(arrivedAt, finishedAt),
|
||||
};
|
||||
|
||||
// What the officer should do next on this shipment — the same gates the
|
||||
// detail page enforces, so the dashboard never points at a locked button.
|
||||
let nextAction: TransitNextAction;
|
||||
if (finished) {
|
||||
nextAction = { kind: "done", label: "Assignment finished" };
|
||||
} else if (tradeDirection === "EXPORT") {
|
||||
if (!declared) {
|
||||
nextAction = { kind: "wait", label: "Awaiting customs declaration" };
|
||||
} else if (ro.count === 0) {
|
||||
nextAction = { kind: "upload", label: "Upload Release Order", document: "ro" };
|
||||
} else if (!departedAt) {
|
||||
nextAction = { kind: "wait", label: "Awaiting train departure" };
|
||||
} else if (!arrivedAt) {
|
||||
nextAction = { kind: "wait", label: "Train in transit" };
|
||||
} else if (gatePass.count === 0) {
|
||||
nextAction = { kind: "upload", label: "Upload gate pass", document: "gate_pass" };
|
||||
} else if (djiboutiT1.count === 0) {
|
||||
nextAction = {
|
||||
kind: "upload",
|
||||
label: "Upload Djibouti T1",
|
||||
document: "djibouti_t1",
|
||||
};
|
||||
} else {
|
||||
nextAction = { kind: "done", label: "Paperwork complete" };
|
||||
}
|
||||
} else if (deliveryOrder.count === 0) {
|
||||
nextAction = { kind: "upload", label: "Upload Delivery Order", document: "do" };
|
||||
} else if (!departedAt) {
|
||||
nextAction = { kind: "wait", label: "Awaiting train departure" };
|
||||
} else if (t1.count === 0 && !t1Closed) {
|
||||
nextAction = { kind: "upload", label: "Upload T1 documents", document: "t1" };
|
||||
} else if (!arrivedAt) {
|
||||
nextAction = { kind: "wait", label: "Train in transit" };
|
||||
} else {
|
||||
nextAction = { kind: "done", label: t1Closed ? "T1 closed" : "Paperwork complete" };
|
||||
}
|
||||
|
||||
const items = rows.map((row) => {
|
||||
const arrivedAt = row.booking?.arrivedAt ?? null;
|
||||
return {
|
||||
id: row.id,
|
||||
reference: row.booking?.reference ?? null,
|
||||
customerName: row.booking?.company?.name ?? null,
|
||||
bookingId: row.bookingId,
|
||||
reference: booking?.reference ?? null,
|
||||
customerName: booking?.company?.name ?? null,
|
||||
tradeDirection,
|
||||
status: row.status,
|
||||
schedulingStatus: row.booking?.schedulingStatus ?? null,
|
||||
/** Dispatch (cargo loaded) to the train arriving. */
|
||||
transitMinutes: minutes(row.booking?.loadedAt, arrivedAt),
|
||||
/** Arrival to the agent picking the work up. */
|
||||
pickupMinutes: minutes(arrivedAt, row.startedAt),
|
||||
/** Arrival to the work being finished — the headline metric. */
|
||||
clearanceMinutes: minutes(arrivedAt, row.finishedAt),
|
||||
documentCount: (docCounts.get(row.id) ?? []).length,
|
||||
schedulingStatus: booking?.schedulingStatus ?? null,
|
||||
trainLabel: schedule?.voyageNumber ?? schedule?.trainNumber ?? null,
|
||||
assignedAt: iso(row.assignedAt) ?? new Date(0).toISOString(),
|
||||
startedAt: iso(row.startedAt),
|
||||
finishedAt,
|
||||
bookingCreatedAt,
|
||||
departedAt,
|
||||
arrivedAt,
|
||||
declaredAt,
|
||||
roAt: ro.at,
|
||||
doAt: deliveryOrder.at,
|
||||
t1At: t1.at,
|
||||
t1Closed,
|
||||
gatePassAt: gatePass.at,
|
||||
djiboutiT1At: djiboutiT1.at,
|
||||
documents: {
|
||||
ro: ro.count,
|
||||
do: deliveryOrder.count,
|
||||
t1: t1.count,
|
||||
gatePass: gatePass.count,
|
||||
djiboutiT1: djiboutiT1.count,
|
||||
own: (ownDocs.get(row.id) ?? []).length,
|
||||
},
|
||||
timings,
|
||||
nextAction,
|
||||
};
|
||||
});
|
||||
|
||||
const median = (values: number[]): number | null => {
|
||||
if (!values.length) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
// Most recently moving shipment first: arrival, else departure, else when
|
||||
// it was handed to the agent.
|
||||
const activity = (i: TransitStatItem) =>
|
||||
i.arrivedAt ?? i.departedAt ?? i.assignedAt;
|
||||
items.sort((a, b) => activity(b).localeCompare(activity(a)));
|
||||
|
||||
const summarize = (values: Array<number | null>): TransitTimingSummary => {
|
||||
const measured = values.filter((v): v is number => v !== null && v >= 0);
|
||||
if (!measured.length) {
|
||||
return { median: null, fastest: null, slowest: null, measured: 0 };
|
||||
}
|
||||
const sorted = [...measured].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2
|
||||
? sorted[mid]
|
||||
: Math.round((sorted[mid - 1] + sorted[mid]) / 2);
|
||||
return {
|
||||
median:
|
||||
sorted.length % 2
|
||||
? sorted[mid]
|
||||
: Math.round((sorted[mid - 1] + sorted[mid]) / 2),
|
||||
fastest: sorted[0],
|
||||
slowest: sorted[sorted.length - 1],
|
||||
measured: sorted.length,
|
||||
};
|
||||
};
|
||||
const timing = (key: keyof TransitStatItem["timings"]) =>
|
||||
summarize(items.map((i) => i.timings[key]));
|
||||
|
||||
const cleared = items
|
||||
.map((i) => i.clearanceMinutes)
|
||||
.filter((v): v is number => v !== null);
|
||||
const pickups = items
|
||||
.map((i) => i.pickupMinutes)
|
||||
.filter((v): v is number => v !== null);
|
||||
|
||||
// SLA bands, in minutes: inside 2h, inside 6h, beyond.
|
||||
const sla = {
|
||||
under2h: cleared.filter((v) => v <= 120).length,
|
||||
under6h: cleared.filter((v) => v > 120 && v <= 360).length,
|
||||
over6h: cleared.filter((v) => v > 360).length,
|
||||
};
|
||||
|
||||
// Coverage counts only bookings that COULD have documents — uploads are
|
||||
// gated on dispatch, so counting scheduled ones would invent a failure.
|
||||
const dispatched = items.filter((i) => i.schedulingStatus === "DISPATCHED");
|
||||
const withDocs = dispatched.filter((i) => i.documentCount > 0).length;
|
||||
const open = items.filter((i) => i.status !== TransitAssignmentStatus.Finished);
|
||||
const pendingFor = (document: TransitDocumentKind) =>
|
||||
items.filter(
|
||||
(i) => i.nextAction.kind === "upload" && i.nextAction.document === document,
|
||||
).length;
|
||||
const sumDocs = (key: keyof TransitStatItem["documents"]) =>
|
||||
items.reduce((sum, i) => sum + i.documents[key], 0);
|
||||
|
||||
return {
|
||||
totals: {
|
||||
assignments: items.length,
|
||||
open: items.filter((i) => i.status !== TransitAssignmentStatus.Finished)
|
||||
open: open.length,
|
||||
notStarted: items.filter((i) => i.status === TransitAssignmentStatus.NotStarted)
|
||||
.length,
|
||||
finished: items.filter(
|
||||
(i) => i.status === TransitAssignmentStatus.Finished,
|
||||
).length,
|
||||
readyForDocuments: items.filter(
|
||||
(i) =>
|
||||
i.schedulingStatus === "DISPATCHED" &&
|
||||
i.status !== TransitAssignmentStatus.Finished,
|
||||
).length,
|
||||
documents: items.reduce((sum, i) => sum + i.documentCount, 0),
|
||||
inProgress: items.filter((i) => i.status === TransitAssignmentStatus.InProgress)
|
||||
.length,
|
||||
finished: items.length - open.length,
|
||||
imports: items.filter((i) => i.tradeDirection === "IMPORT").length,
|
||||
exports: items.filter((i) => i.tradeDirection === "EXPORT").length,
|
||||
awaitingDeparture: open.filter((i) => !i.departedAt).length,
|
||||
inTransit: open.filter((i) => i.departedAt && !i.arrivedAt).length,
|
||||
arrived: open.filter((i) => Boolean(i.arrivedAt)).length,
|
||||
actionNeeded: items.filter((i) => i.nextAction.kind === "upload").length,
|
||||
},
|
||||
performance: {
|
||||
medianClearanceMinutes: median(cleared),
|
||||
medianPickupMinutes: median(pickups),
|
||||
fastestClearanceMinutes: cleared.length ? Math.min(...cleared) : null,
|
||||
slowestClearanceMinutes: cleared.length ? Math.max(...cleared) : null,
|
||||
onTimeRate: cleared.length
|
||||
? Math.round(((sla.under2h + sla.under6h) / cleared.length) * 100)
|
||||
: null,
|
||||
measured: cleared.length,
|
||||
timings: {
|
||||
transit: timing("transit"),
|
||||
declarationToRo: timing("declarationToRo"),
|
||||
bookingToDo: timing("bookingToDo"),
|
||||
departureToT1: timing("departureToT1"),
|
||||
arrivalToT1: timing("arrivalToT1"),
|
||||
arrivalToGatePass: timing("arrivalToGatePass"),
|
||||
arrivalToDjiboutiT1: timing("arrivalToDjiboutiT1"),
|
||||
arrivalToFinish: timing("arrivalToFinish"),
|
||||
},
|
||||
sla,
|
||||
coverage: {
|
||||
dispatched: dispatched.length,
|
||||
withDocuments: withDocs,
|
||||
documents: {
|
||||
ro: sumDocs("ro"),
|
||||
do: sumDocs("do"),
|
||||
t1: sumDocs("t1"),
|
||||
gatePass: sumDocs("gatePass"),
|
||||
djiboutiT1: sumDocs("djiboutiT1"),
|
||||
own: sumDocs("own"),
|
||||
},
|
||||
/** Newest first, for the timeline and the recent-activity list. */
|
||||
items: items.slice(0, 12),
|
||||
pending: {
|
||||
ro: pendingFor("ro"),
|
||||
do: pendingFor("do"),
|
||||
t1: pendingFor("t1"),
|
||||
gatePass: pendingFor("gate_pass"),
|
||||
djiboutiT1: pendingFor("djibouti_t1"),
|
||||
},
|
||||
items: items.slice(0, 20),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -393,6 +690,50 @@ export class TransitAssignmentsService {
|
||||
return this.findMineById(userId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make `transitAgentId` the officer working `bookingId`, as the clearance
|
||||
* desk's "assign transit assignee" step means it.
|
||||
*
|
||||
* The booking itself only records the officer's NAME, which is all the
|
||||
* clearance UI needs; the officer's own portal reads `transit_assignments`.
|
||||
* This keeps the two in step, and is deliberately forgiving where `create()`
|
||||
* is strict:
|
||||
* - assigning the same agent twice is a no-op, not a 409 — the desk may
|
||||
* re-save the step without meaning to start over;
|
||||
* - a REASSIGNMENT retires the previous officer's row, so a shipment does
|
||||
* not sit in the work list of someone who no longer handles it. Finished
|
||||
* rows stay, since they are that officer's record of work already done.
|
||||
*/
|
||||
async ensureAssignment(
|
||||
bookingId: string,
|
||||
transitAgentId: string,
|
||||
assignedByUserId?: string,
|
||||
): Promise<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(
|
||||
dto: CreateTransitAssignmentDto,
|
||||
assignedByUserId?: string,
|
||||
|
||||
@@ -42,6 +42,9 @@ const SIDES: Record<Freight.GlExchangeDocument["side"], { label: string; color:
|
||||
{
|
||||
ET: { label: "GL Ethiopia", color: "edr-green" },
|
||||
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 {
|
||||
@@ -99,6 +102,7 @@ export function GlExchangePanel({ entityId }: GlExchangePanelProps) {
|
||||
() => ({
|
||||
et: documents.filter((d) => d.side === "ET").length,
|
||||
dj: documents.filter((d) => d.side === "DJ").length,
|
||||
transit: documents.filter((d) => d.side === "TRANSIT").length,
|
||||
shared: documents.filter((d) => d.visibleToCustomer).length,
|
||||
}),
|
||||
[documents],
|
||||
@@ -141,6 +145,11 @@ export function GlExchangePanel({ entityId }: GlExchangePanelProps) {
|
||||
<Badge variant="light" color="blue" radius="sm" tt="none">
|
||||
{stats.dj} from GL Djibouti
|
||||
</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">
|
||||
{stats.shared} visible to customer
|
||||
</Badge>
|
||||
|
||||
@@ -782,7 +782,7 @@ export function PhasedClearanceActionPanel({
|
||||
|
||||
<Stepper.Step
|
||||
label="T1 transport documents"
|
||||
description="GL Djibouti uploads after the gate pass is secured"
|
||||
description="GL Djibouti uploads after the train departs"
|
||||
icon={
|
||||
t1Uploaded || clearance.t1?.closed ? (
|
||||
<CheckCircle2 size={14} />
|
||||
|
||||
@@ -662,6 +662,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
cardTitleKey: "name",
|
||||
columns: [
|
||||
{ id: "name", header: "Name", accessorKey: "name" },
|
||||
{ id: "email", header: "Email", accessorKey: "email" },
|
||||
{ id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber" },
|
||||
{ id: "validFrom", header: "Valid from", accessorKey: "validFrom", format: "date" },
|
||||
{ id: "validTo", header: "Valid to", accessorKey: "validTo", format: "date" },
|
||||
{
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
} from "./pages/shipping-line";
|
||||
import {
|
||||
TransitAgentBookingsPage,
|
||||
TransitAgentBookingDetailPage,
|
||||
TransitAgentOverviewPage,
|
||||
} from "./pages/transit-agent";
|
||||
import FaqPage from "./pages/support/FaqPage";
|
||||
@@ -545,6 +546,10 @@ const App = () => {
|
||||
path="/transit-agent/bookings"
|
||||
element={<TransitAgentBookingsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/transit-agent/bookings/:id"
|
||||
element={<TransitAgentBookingDetailPage />}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -1,2 +1,3 @@
|
||||
export { default as TransitAgentOverviewPage } from "./TransitAgentOverviewPage";
|
||||
export { default as TransitAgentBookingsPage } from "./TransitAgentBookingsPage";
|
||||
export { default as TransitAgentBookingDetailPage } from "./TransitAgentBookingDetailPage";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { client } from "@/utils/api";
|
||||
|
||||
const BASE = "/api/transit-assignments/my";
|
||||
@@ -65,42 +67,91 @@ export interface TransitAssignmentListResult {
|
||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}
|
||||
|
||||
/** One row behind the overview's timeline and activity list. */
|
||||
export type TransitTradeDirection = "IMPORT" | "EXPORT";
|
||||
export type TransitDocumentKind = "ro" | "do" | "t1" | "gate_pass" | "djibouti_t1";
|
||||
|
||||
/** What the officer should do next on a shipment — mirrors the detail page's gates. */
|
||||
export interface TransitNextAction {
|
||||
kind: "upload" | "wait" | "done";
|
||||
label: string;
|
||||
document?: TransitDocumentKind;
|
||||
}
|
||||
|
||||
/** Minutes, or null when nothing has been measured yet — never zero. */
|
||||
export interface TransitTimingSummary {
|
||||
median: number | null;
|
||||
fastest: number | null;
|
||||
slowest: number | null;
|
||||
measured: number;
|
||||
}
|
||||
|
||||
export type TransitTimingKey =
|
||||
| "transit"
|
||||
| "declarationToRo"
|
||||
| "bookingToDo"
|
||||
| "departureToT1"
|
||||
| "arrivalToT1"
|
||||
| "arrivalToGatePass"
|
||||
| "arrivalToDjiboutiT1"
|
||||
| "arrivalToFinish";
|
||||
|
||||
/** One shipment on the overview: train stamps, document stamps, derived timings. */
|
||||
export interface TransitStatItem {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
reference: string | null;
|
||||
customerName: string | null;
|
||||
tradeDirection: TransitTradeDirection;
|
||||
status: TransitAssignmentStatus;
|
||||
schedulingStatus: string | null;
|
||||
transitMinutes: number | null;
|
||||
pickupMinutes: number | null;
|
||||
clearanceMinutes: number | null;
|
||||
documentCount: number;
|
||||
trainLabel: string | null;
|
||||
assignedAt: string;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
bookingCreatedAt: string | null;
|
||||
departedAt: string | null;
|
||||
arrivedAt: string | null;
|
||||
declaredAt: string | null;
|
||||
roAt: string | null;
|
||||
doAt: string | null;
|
||||
t1At: string | null;
|
||||
t1Closed: boolean;
|
||||
gatePassAt: string | null;
|
||||
djiboutiT1At: string | null;
|
||||
documents: {
|
||||
ro: number;
|
||||
do: number;
|
||||
t1: number;
|
||||
gatePass: number;
|
||||
djiboutiT1: number;
|
||||
own: number;
|
||||
};
|
||||
timings: Record<TransitTimingKey, number | null>;
|
||||
nextAction: TransitNextAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overview figures, all derived server-side from existing timestamps. Every
|
||||
* duration is minutes, and null means "not measurable yet" rather than zero —
|
||||
* an unfinished assignment has no clearance time.
|
||||
* Overview figures, all derived server-side from existing timestamps: the
|
||||
* train's departure and arrival, clearance milestones, and each document's
|
||||
* upload time (a replaced batch counts from its last update).
|
||||
*/
|
||||
export interface TransitStats {
|
||||
totals: {
|
||||
assignments: number;
|
||||
open: number;
|
||||
notStarted: number;
|
||||
inProgress: number;
|
||||
finished: number;
|
||||
readyForDocuments: number;
|
||||
documents: number;
|
||||
imports: number;
|
||||
exports: number;
|
||||
awaitingDeparture: number;
|
||||
inTransit: number;
|
||||
arrived: number;
|
||||
actionNeeded: number;
|
||||
};
|
||||
performance: {
|
||||
medianClearanceMinutes: number | null;
|
||||
medianPickupMinutes: number | null;
|
||||
fastestClearanceMinutes: number | null;
|
||||
slowestClearanceMinutes: number | null;
|
||||
onTimeRate: number | null;
|
||||
measured: number;
|
||||
};
|
||||
sla: { under2h: number; under6h: number; over6h: number };
|
||||
coverage: { dispatched: number; withDocuments: number };
|
||||
timings: Record<TransitTimingKey, TransitTimingSummary>;
|
||||
documents: TransitStatItem["documents"];
|
||||
pending: { ro: number; do: number; t1: number; gatePass: number; djiboutiT1: number };
|
||||
items: TransitStatItem[];
|
||||
}
|
||||
|
||||
@@ -169,4 +220,162 @@ export const transitAssignmentsService = {
|
||||
const { data } = await client.post(`${BASE}/${id}/submit`, input);
|
||||
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" } },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -99,7 +99,16 @@ export interface ClearanceWorkflowFile {
|
||||
label: string;
|
||||
uploadedBy: ClearanceWorkflowFileOwner;
|
||||
category: ClearanceWorkflowFileCategory;
|
||||
file: { id: string; name: string; url: string } | null;
|
||||
file: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
/** When this file record was stored — a replaced batch carries the new stamp. */
|
||||
uploadedAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
size?: number | null;
|
||||
mimeType?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const CATALOG_BY_CODE = new Map(
|
||||
@@ -239,6 +248,39 @@ export function exportTransportFileLabel(code: string, index?: number): string {
|
||||
return code;
|
||||
}
|
||||
|
||||
// ── Transit agent arrival paperwork (export) ────────────────────────────────
|
||||
// Filed by the assigned transit officer at Djibouti around train arrival. Both
|
||||
// are append-only multi-file sets: each upload adds files, and a file can be
|
||||
// removed on its own, unlike the DO/RO batches that replace as a whole.
|
||||
|
||||
/** Multi-file Djibouti gate pass uploads use `gate_pass_0`, `gate_pass_1`, … */
|
||||
export const GATE_PASS_FILE_PREFIX = "gate_pass_";
|
||||
|
||||
export function isGatePassFileCode(code: string | null | undefined): boolean {
|
||||
if (!code) return false;
|
||||
return code.toLowerCase().startsWith(GATE_PASS_FILE_PREFIX);
|
||||
}
|
||||
|
||||
export function gatePassFileLabel(index?: number): string {
|
||||
return index != null ? `Gate pass ${index + 1}` : "Gate pass";
|
||||
}
|
||||
|
||||
/** Multi-file Djibouti T1 uploads use `djibouti_t1_0`, `djibouti_t1_1`, … */
|
||||
export const DJIBOUTI_T1_FILE_PREFIX = "djibouti_t1_";
|
||||
|
||||
export function isDjiboutiT1FileCode(code: string | null | undefined): boolean {
|
||||
if (!code) return false;
|
||||
return code.toLowerCase().startsWith(DJIBOUTI_T1_FILE_PREFIX);
|
||||
}
|
||||
|
||||
export function djiboutiT1FileLabel(index?: number): string {
|
||||
return index != null ? `Djibouti T1 ${index + 1}` : "Djibouti T1";
|
||||
}
|
||||
|
||||
/** The two transit-agent arrival document sets, keyed by the API route segment. */
|
||||
export const TRANSIT_ARRIVAL_DOCUMENT_KINDS = ["gate_pass", "djibouti_t1"] as const;
|
||||
export type TransitArrivalDocumentKind = (typeof TRANSIT_ARRIVAL_DOCUMENT_KINDS)[number];
|
||||
|
||||
export function catalogEntriesForTradeDirection(
|
||||
tradeDirection: string,
|
||||
): ClearanceWorkflowFileCatalogEntry[] {
|
||||
|
||||
@@ -1155,8 +1155,11 @@ export interface GlExchangeDocument {
|
||||
/** Booking or contract id the document is attached to. */
|
||||
entityId: 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;
|
||||
uploadedById: string | null;
|
||||
uploadedByName: string | null;
|
||||
@@ -1174,6 +1177,8 @@ export interface GlExchangeDocument {
|
||||
|
||||
/** The clearance view for a booking, driving both portals' clearance UI. */
|
||||
export interface ClearanceView {
|
||||
/** When the booking was created — the import DO clock starts here. */
|
||||
bookingCreatedAt?: string | null;
|
||||
status: string;
|
||||
includesCustoms: boolean;
|
||||
inputCode: string | null;
|
||||
|
||||
Reference in New Issue
Block a user