feat(bookings): add agent-driven clearance flow with forwarder/transit-agent panels and BookingClearedByAgent migration

This commit is contained in:
marshal
2026-09-08 08:49:33 +00:00
parent 850e0753d2
commit 7af3ded7d7
14 changed files with 1716 additions and 57 deletions

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* A without-customs booking the customer handed to a registered clearing agent
* (a freight forwarder on the platform) now runs the phased clearance workflow,
* with the forwarder doing the GL Ethiopia steps and the Djibouti agent it
* names doing the Djibouti steps. The flag marks such a booking so every
* "phased customs" gate admits it alongside GL-cleared customs bookings.
*/
export class BookingClearedByAgent3970000000000 implements MigrationInterface {
name = "BookingClearedByAgent3970000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS cleared_by_agent boolean NOT NULL DEFAULT false
`);
// Bookings already handed to a registered Ethiopian agent before the flag
// existed join the workflow too. Their milestones are seeded lazily the
// first time the clearance view is read (BookingClearanceService), since
// the catalog lives in code.
await queryRunner.query(`
UPDATE freight.bookings b
SET cleared_by_agent = true
WHERE b.customs_clearing_enabled = false
AND b.contract_id IS NOT NULL
AND b.deleted_at IS NULL
AND EXISTS (
SELECT 1
FROM freight.transit_assignments ta
JOIN freight.transit_agents a ON a.id = ta.transit_agent_id
WHERE ta.booking_id = b.id
AND ta.deleted_at IS NULL
AND a.deleted_at IS NULL
AND a.country = 'ET'
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS cleared_by_agent
`);
}
}

View File

@@ -1800,8 +1800,10 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Also the clearing agent (forwarder) on an agent-cleared booking — see
// assertStaffOrClearingAgent; it does GL Ethiopia's part of the workflow.
@Post(":id/clearance/declaration")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
@@ -1812,6 +1814,7 @@ export class BookingsController {
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrClearingAgent(id, user, FREIGHT_PERMS.contracts.clearanceEtActions);
const booking = await this.bookingClearanceService.uploadDeclaration(
id,
files ?? [],
@@ -1853,8 +1856,10 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Also the clearing agent (forwarder) on an agent-cleared booking — see
// assertStaffOrClearingAgent; it does GL Ethiopia's part of the workflow.
@Post(":id/clearance/draft-declaration")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
@@ -1868,6 +1873,7 @@ export class BookingsController {
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrClearingAgent(id, user, FREIGHT_PERMS.contracts.clearanceEtActions);
const booking = await this.bookingClearanceService.uploadDraftDeclaration(
id,
files ?? [],
@@ -1878,8 +1884,10 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Also the clearing agent (forwarder) on an agent-cleared booking — see
// assertStaffOrClearingAgent; it does GL Ethiopia's part of the workflow.
@Post(":id/clearance/draft-declaration/skip")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL ET skips the draft-declaration round: no estimate is sent to the customer, the real declaration is filed directly and duty & tax passes by default",
@@ -1888,6 +1896,7 @@ export class BookingsController {
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrClearingAgent(id, user, FREIGHT_PERMS.contracts.clearanceEtActions);
const booking = await this.bookingClearanceService.skipDraftDeclaration(
id,
resolveAuthUserId(user),
@@ -1932,13 +1941,16 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Also the clearing agent (forwarder) on an agent-cleared booking — see
// assertStaffOrClearingAgent; it does GL Ethiopia's part of the workflow.
@Post(":id/clearance/finalize-pre-clearance")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: "GL ET finalizes import pre-clearance on booking" })
async finalizeBookingPreClearance(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrClearingAgent(id, user, FREIGHT_PERMS.contracts.clearanceEtActions);
const booking = await this.bookingClearanceService.finalizePreClearance(
id,
resolveAuthUserId(user),
@@ -1966,8 +1978,10 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Also the clearing agent (forwarder) on an agent-cleared booking — see
// assertStaffOrClearingAgent; it does GL Ethiopia's part of the workflow.
@Post(":id/clearance/transit-permit")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
async uploadBookingTransitPermit(
@@ -1975,6 +1989,7 @@ export class BookingsController {
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrClearingAgent(id, user, FREIGHT_PERMS.contracts.clearanceEtActions);
const booking = await this.bookingClearanceService.uploadTransitPermit(
id,
files ?? [],
@@ -2101,12 +2116,15 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Also the clearing agent (forwarder) on an agent-cleared booking — see
// assertStaffOrClearingAgent; it does GL Ethiopia's part of the workflow.
@Post(":id/clearance/export-release")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions)
async confirmBookingExportRelease(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrClearingAgent(id, user, FREIGHT_PERMS.contracts.clearanceEtActions);
const booking = await this.bookingClearanceService.confirmExportRelease(
id,
resolveAuthUserId(user),

View File

@@ -370,6 +370,16 @@ export class Booking extends BaseEntity {
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
customsClearingEnabled!: boolean;
/**
* A without-customs booking the customer handed to a registered clearing
* agent (a freight forwarder on the platform). It runs the phased clearance
* workflow like a customs booking, except the forwarder performs the GL
* Ethiopia steps from its portal and the Djibouti agent it names performs
* the Djibouti steps; the customer still creates the booking itself.
*/
@Column({ name: 'cleared_by_agent', type: 'boolean', default: false })
clearedByAgent!: boolean;
@Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true })
customsClearingAgent?: string | null;

View File

@@ -87,6 +87,8 @@ export interface BookingClearanceView {
metadata?: Record<string, unknown> | null;
sortOrder: number;
}>;
/** Handed to a registered clearing agent: the forwarder does GL Ethiopia's steps. */
clearedByAgent?: boolean;
nextAction?: {
actor: string;
action: string;
@@ -188,8 +190,10 @@ export class BookingClearanceService {
) {}
private async assertPhasedCustoms(booking: Booking): Promise<void> {
if (!booking.customsClearingEnabled) {
throw new BadRequestException('Phased clearance applies only to customs bookings.');
if (!booking.customsClearingEnabled && !booking.clearedByAgent) {
throw new BadRequestException(
'Phased clearance applies only to customs bookings and bookings cleared by a registered agent.',
);
}
if (!booking.contractId) {
throw new BadRequestException('Booking is not linked to a contract.');
@@ -301,6 +305,83 @@ export class BookingClearanceService {
const allApproved = await this.isClearanceFullyApproved(booking);
let milestones = await this.workflowService.listMilestonesForBooking(bookingId);
// An agent-cleared booking that predates the flag (or was flagged by the
// migration) has no timeline yet. Seed the pre-booking half now, exactly
// as initiation would have, with the duty round off — idempotent, since
// ensure* only adds codes that are missing.
if (booking.clearedByAgent && milestones.length === 0) {
const direction = booking.tradeDirection ?? 'IMPORT';
await this.milestoneService.seedPreBookingMilestonesOnBooking(bookingId, direction);
if (direction === 'IMPORT') {
for (const code of ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']) {
await this.milestoneService.skipForBooking(bookingId, code).catch(() => undefined);
}
if (booking.dutyRequired !== false) {
await this.bookingsRepository.update(bookingId, { dutyRequired: false } as never);
}
}
milestones = await this.workflowService.listMilestonesForBooking(bookingId);
}
// An agent-cleared booking completed before it joined the workflow has
// only the pre-booking half of its timeline: the post-booking milestones
// are seeded at completion, and that ran before the flag existed. Add
// them now (ensure* only adds what is missing), duty round off, and stamp
// the wagon allocation the scheduler already made — the payment stamp
// follows in the self-heal below.
if (
booking.clearedByAgent &&
milestones.length > 0 &&
Number(booking.totalAmount ?? 0) > 0 &&
!milestones.some((m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED')
) {
const direction = booking.tradeDirection ?? 'IMPORT';
await this.milestoneService.ensureBookingMilestones(bookingId, direction);
if (direction === 'IMPORT') {
for (const code of ['SECOND_DUTY_ADVISED', 'SECOND_DUTY_PAID']) {
await this.milestoneService.skipForBooking(bookingId, code).catch(() => undefined);
}
}
milestones = await this.workflowService.listMilestonesForBooking(bookingId);
}
if (
booking.clearedByAgent &&
(booking.schedulingStatus === 'SCHEDULED' ||
booking.schedulingStatus === 'DISPATCHED' ||
Boolean(booking.trainScheduleId)) &&
milestones.some(
(m) => m.milestoneCode === 'WAGON_ALLOCATED' && m.status === 'PENDING',
)
) {
await this.workflowService.completeMilestoneForBooking(bookingId, 'WAGON_ALLOCATED');
milestones = await this.workflowService.listMilestonesForBooking(bookingId);
}
// Self-heal the document milestones on an agent-cleared booking: the
// forwarder may have uploaded, reviewed and approved before the booking
// joined the workflow (flagged by migration, or milestones seeded just
// above), so the review path never stamped them. Completing is idempotent.
if (booking.clearedByAgent && milestones.length > 0) {
const state = (code: string) =>
milestones.find((m) => m.milestoneCode === code)?.status;
const isDone = (code: string) =>
state(code) === 'COMPLETED' || state(code) === 'SKIPPED';
const direction = booking.tradeDirection ?? 'IMPORT';
const hasCustomerFiles = files.length > 0;
let healed = false;
if (hasCustomerFiles && !isDone('PENDING_DOCUMENT_REVIEW')) {
await this.workflowService.onCustomerDocsUploadedForBooking(bookingId, direction);
healed = true;
}
if (allApproved && !isDone('DOCUMENTS_APPROVED')) {
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
healed = true;
}
if (healed) {
milestones = await this.workflowService.listMilestonesForBooking(bookingId);
}
}
// Self-heal: a booking that has settled its freight payment must have
// FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an
// export FCFS booking (linked to its train at booking time) paid via the
@@ -396,6 +477,7 @@ export class BookingClearanceService {
sortOrder: m.sortOrder,
})),
nextAction,
clearedByAgent: Boolean(booking.clearedByAgent),
dutyRequired: booking.dutyRequired ?? null,
roHold: Boolean(booking.roHoldReason),
roHoldReason: booking.roHoldReason ?? null,
@@ -541,10 +623,15 @@ export class BookingClearanceService {
);
}
/** Any contract booking (ONE_TIME or GENERAL) whose service bundles customs. */
/**
* Any contract booking (ONE_TIME or GENERAL) whose service bundles customs —
* or one the customer handed to a registered clearing agent, which runs the
* same phased workflow with the forwarder in GL Ethiopia's place.
*/
isPhasedCustomsBooking(booking: Booking): boolean {
return (
Boolean(booking.customsClearingEnabled) && Boolean(booking.contractId)
(Boolean(booking.customsClearingEnabled) || Boolean(booking.clearedByAgent)) &&
Boolean(booking.contractId)
);
}
@@ -660,8 +747,14 @@ export class BookingClearanceService {
}
// Import only: the declaration is filed against whoever physically handles
// the shipment in Djibouti, so that name must be in first. Exports have no
// such handshake — their Djibouti steps come after the declaration.
if (tradeDirection === 'IMPORT' && !booking.transitAssigneeName) {
// such handshake — their Djibouti steps come after the declaration. An
// agent-cleared booking has no GL Djibouti desk to ask: the forwarder names
// the Djibouti agent itself, whenever it likes, so the gate does not apply.
if (
tradeDirection === 'IMPORT' &&
!booking.transitAssigneeName &&
!booking.clearedByAgent
) {
throw new BadRequestException(
booking.transitAssigneeRequestedAt
? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.'
@@ -700,6 +793,30 @@ export class BookingClearanceService {
metadata: { fileNames: files.map((f) => f.originalname) },
});
// Agent-cleared export: the Djibouti agent's Release Order may already be
// secured (it does not wait for the declaration there), in which case this
// declaration is the last pre-operation step — release the export now so
// the customer can create the booking. See uploadReleaseOrder for the
// mirror case.
if (booking.clearedByAgent && tradeDirection === 'EXPORT') {
const after = await this.workflowService.listMilestonesForBooking(bookingId);
const isDone = (code: string) =>
after.some(
(m) =>
m.milestoneCode === code &&
(m.status === 'COMPLETED' || m.status === 'SKIPPED'),
);
if (isDone('RELEASE_ORDER_SECURED') && !isDone('EXPORT_RELEASED')) {
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
await this.clearanceEvents.record({
bookingId,
action: 'EXPORT_RELEASE_CONFIRMED',
label: 'Export released — declaration and Release Order both on file',
actorId: userId ?? null,
});
}
}
return this.bookingsService.findById(bookingId);
}
@@ -1141,11 +1258,24 @@ export class BookingClearanceService {
if (booking.tradeDirection !== 'EXPORT') {
throw new BadRequestException('Release Order applies only to export bookings.');
}
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'EXPORT',
'RELEASE_ORDER_SECURED',
);
if (booking.clearedByAgent) {
// The Djibouti agent may secure the RO as soon as the customer's
// documents are approved — it does not wait for the forwarder's
// declaration, which can be filed before or after it.
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const approved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
if (approved?.status !== 'COMPLETED' && approved?.status !== 'SKIPPED') {
throw new BadRequestException(
'The customer documents must be approved before the Release Order can be uploaded.',
);
}
} else {
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'EXPORT',
'RELEASE_ORDER_SECURED',
);
}
if (!vesselDepartureDate?.trim()) {
throw new BadRequestException('Vessel departure date is required');
@@ -1195,8 +1325,20 @@ export class BookingClearanceService {
);
// Release Order is now the last GL DJ pre-operation action (it follows the
// declaration) — release immediately so booking creation unlocks without a
// separate confirm click.
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
// separate confirm click. On an agent-cleared booking the RO may come
// FIRST, so the export is released by whichever of the two lands second:
// here only once the declaration is already on file, otherwise from the
// declaration upload.
const declared = booking.clearedByAgent
? (await this.workflowService.listMilestonesForBooking(bookingId)).some(
(m) =>
m.milestoneCode === 'DECLARED' &&
(m.status === 'COMPLETED' || m.status === 'SKIPPED'),
)
: true;
if (declared) {
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
}
return { booking: await this.bookingsService.findById(bookingId), hold: false };
}

View File

@@ -552,6 +552,11 @@ export class ContractBookingService {
paymentCurrency: this.resolveShipmentCurrency(contract, null),
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
// Handed to a registered clearing agent: the phased workflow runs on
// this booking with the forwarder in GL Ethiopia's place. Duty is
// never advised on that path, so it is settled up front.
clearedByAgent: Boolean(clearingAgent?.assigned),
...(clearingAgent?.assigned ? { dutyRequired: false } : {}),
customsClearingAgent:
clearingAgent?.fields.customsClearingAgent ??
contract.customsClearingAgent ??
@@ -580,12 +585,20 @@ export class ContractBookingService {
// Customs: the instance runs the phased ET/DJ workflow, so its pre-booking
// milestones exist from initiation (the post-booking half is seeded when the
// booking is completed). Self-clearance has no milestone timeline.
if (contract.customsClearingEnabled) {
// booking is completed). Self-clearance has no milestone timeline — unless
// a registered clearing agent was named, which runs the same workflow with
// the forwarder in GL Ethiopia's place and no duty round.
if (contract.customsClearingEnabled || clearingAgent?.assigned) {
await this.milestoneService.seedPreBookingMilestonesOnBooking(
booking.id,
contract.tradeDirection,
);
if (clearingAgent?.assigned && contract.tradeDirection === 'IMPORT') {
await this.skipAgentClearedDutyMilestones(booking.id, [
'DUTY_TAXES_ADVISED',
'DUTY_TAX_PAID',
]);
}
}
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
@@ -609,6 +622,28 @@ export class ContractBookingService {
return { booking: result ?? booking, warnings: [] };
}
/**
* Duty is never advised on an agent-cleared import: the forwarder settles it
* with customs outside the platform. The milestones are skipped rather than
* left pending, or every later step would wait on them.
*/
private async skipAgentClearedDutyMilestones(
bookingId: string,
codes: string[],
): Promise<void> {
for (const code of codes) {
try {
await this.milestoneService.skipForBooking(bookingId, code);
} catch (err) {
this.logger.warn(
`Could not skip ${code} on agent-cleared booking ${bookingId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
}
/**
* The booking company as its own clearing agent — when it holds the transit
* agent or freight forwarder role, it clears its own customs, and its own
@@ -1398,11 +1433,18 @@ export class ContractBookingService {
// a consolidation pairing replay must not duplicate the timeline. The
// contract itself is never moved to ACTIVE_SHIPMENT_IN_PROGRESS any more; it
// holds no clearance state at all.
if (contract.customsClearingEnabled) {
if (contract.customsClearingEnabled || booking.clearedByAgent) {
await this.milestoneService.ensureBookingMilestones(
bookingId,
contract.tradeDirection,
);
// The post-arrival duty round is off the agent-cleared path as well.
if (booking.clearedByAgent && contract.tradeDirection === 'IMPORT') {
await this.skipAgentClearedDutyMilestones(bookingId, [
'SECOND_DUTY_ADVISED',
'SECOND_DUTY_PAID',
]);
}
}
// Contract bookings are born past the billable gate (the contract is already

View File

@@ -1360,13 +1360,20 @@ export class ContractsController {
// ── GL operational actions on a booking (doc §11§13) ──────────────────────
@Post('bookings/:bookingId/risk')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'GL ET assigns a customs risk level (GREEN/YELLOW/RED)' })
assignRisk(
@MixedAudience(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary: 'GL ET — or the assigned clearing agent — assigns a customs risk level (GREEN/YELLOW/RED)',
})
async assignRisk(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: AssignRiskDto,
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrAgentOnAgentClearedBooking(
bookingId,
user,
FREIGHT_PERMS.bookings.operations,
);
return this.milestoneService.assignRisk(
bookingId,
dto.riskLevel,
@@ -1408,17 +1415,46 @@ export class ContractsController {
}
@Post('bookings/:bookingId/transport-document')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL ET uploads export transit permit documents (multi-file)' })
uploadTransportDocument(
@ApiOperation({
summary: 'GL ET — or the assigned clearing agent — uploads export transport documents (multi-file)',
})
async uploadTransportDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrAgentOnAgentClearedBooking(
bookingId,
user,
FREIGHT_PERMS.contracts.clearanceEtActions,
);
return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []);
}
/**
* Staff pass on their permission. A portal caller must be an agent assigned
* to THIS booking (the forwarder or the Djibouti agent it named) and the
* booking must be agent-cleared — on a GL-cleared customs booking these
* steps stay with the desks. Hidden behind a NotFound like ownership checks.
*/
private async assertStaffOrAgentOnAgentClearedBooking(
bookingId: string,
user: TCurrentUser,
staffPermission: string,
): Promise<void> {
if (staffPermission && hasFreightPermission(user, staffPermission)) return;
const booking = await this.bookingsService.findById(bookingId);
if (
!booking.clearedByAgent ||
!(await this.bookingsService.isTransitAgentForBooking(user?.id, bookingId))
) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
}
// 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')
@@ -1450,19 +1486,27 @@ export class ContractsController {
);
}
// On an agent-cleared booking the assigned agents close it in the desks'
// place: the forwarder after arrival (import), the Djibouti agent (export).
@Post('bookings/:bookingId/t1-close')
@BookingStaff([
@MixedAudience([
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
])
@ApiOperation({
summary:
'Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)',
'Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export); also the assigned agents on an agent-cleared booking',
})
closeT1(
async closeT1(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
if (
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) &&
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
) {
await this.assertStaffOrAgentOnAgentClearedBooking(bookingId, user, '');
}
return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user));
}
@@ -1575,17 +1619,25 @@ export class ContractsController {
return this.glOperationsService.uploadSecondDutySlip(bookingId, file);
}
// Also the clearing agent (forwarder) on an agent-cleared booking, for the
// documents GL Ethiopia would otherwise file (import release).
@Post('bookings/:bookingId/documents')
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@MixedAudience(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'GL uploads post-booking operational documents (DO/RO/T1/…)',
summary: 'GL — or the assigned clearing agent — uploads post-booking operational documents (DO/RO/T1/…)',
})
uploadGlDocuments(
async uploadGlDocuments(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrAgentOnAgentClearedBooking(
bookingId,
user,
FREIGHT_PERMS.bookings.uploadClearanceOutput,
);
return this.glOperationsService.uploadDocuments(bookingId, files ?? []);
}

View File

@@ -23,6 +23,7 @@ import {
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
AlertTriangle,
ArrowLeft,
ArrowRight,
Building2,
@@ -36,6 +37,7 @@ import {
History,
MessageSquare,
PackageCheck,
Share2,
ShipWheel,
UserCheck,
} from "lucide-react";
@@ -52,6 +54,12 @@ import {
} from "@/services/transit-assignments.service";
import type { Freight } from "@edr/types";
import {
ExchangeDocumentsPanel,
IncidentsPanel,
WorkflowDocumentsPanel,
} from "./ForwarderBookingTabs";
import { ForwarderClearancePanel } from "./ForwarderClearancePanel";
import { ForwarderDocumentReview } from "./ForwarderDocumentReview";
const LIST_PATH = "/forwarder/assigned-bookings";
@@ -190,6 +198,9 @@ export default function AssignedBookingDetailPage() {
void bookingQuery.refetch();
void clearanceQuery.refetch();
};
const workflowFileCount = (clearance?.workflowFiles ?? []).filter(
(f) => f.file,
).length;
const kpis = [
{ label: "Approved", value: stats.approved, icon: CheckCircle2, color: "edr-green" },
@@ -344,9 +355,28 @@ export default function AssignedBookingDetailPage() {
<Tabs.Tab value="clearance" leftSection={<ClipboardList size={14} />}>
Clearance
</Tabs.Tab>
<Tabs.Tab
value="documents"
leftSection={<FileText size={14} />}
rightSection={
workflowFileCount > 0 ? (
<Badge size="xs" variant="light" color="edr-green" circle>
{workflowFileCount}
</Badge>
) : undefined
}
>
Documents
</Tabs.Tab>
<Tabs.Tab value="exchange" leftSection={<Share2 size={14} />}>
Document exchange
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={14} />}>
History
</Tabs.Tab>
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
Incidents
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="clearance">
@@ -397,8 +427,15 @@ export default function AssignedBookingDetailPage() {
/>
) : null}
<DjiboutiAgentCard assignment={assignment} />
{assignedBookingsUnlocked && booking && clearance?.phase ? (
<ForwarderClearancePanel
booking={booking}
clearance={clearance}
onChanged={refresh}
/>
) : null}
<BookingFactsCard assignment={assignment} booking={booking ?? null} />
{assignedBookingsUnlocked && clearance ? (
{assignedBookingsUnlocked && clearance && !clearance.phase ? (
<Card withBorder shadow="sm" radius="lg" p="md">
<Group gap="sm" align="center" mb="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size={32}>
@@ -431,13 +468,38 @@ export default function AssignedBookingDetailPage() {
</Grid>
</Tabs.Panel>
<Tabs.Panel value="documents">
{assignedBookingsUnlocked ? (
<WorkflowDocumentsPanel
clearance={clearance}
loading={clearanceQuery.isPending}
/>
) : (
<LockedNote what="The documents" />
)}
</Tabs.Panel>
<Tabs.Panel value="exchange">
{assignedBookingsUnlocked && bookingId ? (
<ExchangeDocumentsPanel bookingId={bookingId} />
) : (
<LockedNote what="The document exchange" />
)}
</Tabs.Panel>
<Tabs.Panel value="history">
{assignedBookingsUnlocked && bookingId ? (
<HistoryPanel bookingId={bookingId} />
) : (
<Alert color="yellow" variant="light" radius="md">
The clearance history opens once your role is approved.
</Alert>
<LockedNote what="The clearance history" />
)}
</Tabs.Panel>
<Tabs.Panel value="incidents">
{assignedBookingsUnlocked && bookingId ? (
<IncidentsPanel bookingId={bookingId} />
) : (
<LockedNote what="Incident reports" />
)}
</Tabs.Panel>
</Tabs>
@@ -446,6 +508,14 @@ export default function AssignedBookingDetailPage() {
);
}
function LockedNote({ what }: { what: string }) {
return (
<Alert color="yellow" variant="light" radius="md">
{what} open once your transit agent role is approved.
</Alert>
);
}
function BackButton({ onClick }: { onClick: () => void }) {
return (
<div>

View File

@@ -0,0 +1,461 @@
import {
Alert,
Badge,
Box,
Button,
Card,
Checkbox,
Group,
Loader,
Modal,
Paper,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
Download,
Eye,
EyeOff,
FileText,
Share2,
Upload,
} from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useFileViewer } from "@/hooks/useFileViewer";
import { ClearanceWorkflowFilesPanel } from "@/pages/transit-agent/ClearanceWorkflowFilesPanel";
import { downloadStoredFile, fetchViewableFile } from "@/services/files.service";
import { transitAssignmentsService } from "@/services/transit-assignments.service";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
const formatDateTime = (value?: string | null): string =>
value ? new Date(value).toLocaleString() : "—";
function formatBytes(bytes: number): 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]}`;
}
const prettyStatus = (s?: string | null) =>
(s ?? "")
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
const SIDES: Record<Freight.GlExchangeDocument["side"], { label: string; color: string }> = {
ET: { label: "GL Ethiopia", color: "edr-green" },
DJ: { label: "GL Djibouti", color: "blue" },
TRANSIT: { label: "Agent", color: "grape" },
};
function EmptyPanel({ children }: { children: React.ReactNode }) {
return (
<Paper withBorder radius="md" p="lg" style={{ borderStyle: "dashed" }}>
<Group gap={10} wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" color="gray" radius="md" size={32}>
<FileText size={15} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{children}
</Text>
</Group>
</Paper>
);
}
/**
* Every customs workflow document on the booking, grouped by step — the
* customer's paperwork, the forwarder's declarations and permits, and the
* Djibouti agent's DO/RO/T1 — the GL page's "Documents" tab.
*/
export function WorkflowDocumentsPanel({
clearance,
loading,
}: {
clearance: Freight.ClearanceView | undefined;
loading: boolean;
}) {
const { view, viewer } = useFileViewer();
const files = (clearance?.workflowFiles ?? []).filter((f) => f.file);
if (loading) {
return (
<Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading documents</Text>
</Group>
);
}
return (
<>
{files.length > 0 ? (
<ClearanceWorkflowFilesPanel
files={clearance?.workflowFiles ?? []}
title="Customs documents (all steps)"
onView={view}
onDownload={(f) => void downloadStoredFile(f.id, f.name)}
/>
) : (
<EmptyPanel>
No customs workflow documents uploaded yet. Your declarations and
permits, and the Djibouti agent's DO/RO/T1 uploads, appear here.
</EmptyPanel>
)}
{viewer}
</>
);
}
/**
* The document exchange thread on the booking: what both Global Logistics
* desks and the assigned agents shared, plus sharing a document from here.
* Same thread the GL page and the Djibouti agent's page show.
*/
export function ExchangeDocumentsPanel({ bookingId }: { bookingId: string }) {
const { view, viewer } = useFileViewer();
const [shareOpen, setShareOpen] = useState(false);
const exchangeQuery = useQuery({
queryKey: ["forwarder-exchange", bookingId],
queryFn: () => transitAssignmentsService.glExchange(bookingId),
});
const docs = exchangeQuery.data ?? [];
const stats = {
et: docs.filter((d) => d.side === "ET").length,
dj: docs.filter((d) => d.side === "DJ").length,
transit: docs.filter((d) => d.side === "TRANSIT").length,
shared: docs.filter((d) => d.visibleToCustomer).length,
};
return (
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={44}>
<Share2 size={20} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fw={700} fz={16}>
Document exchange
</Text>
<Text size="xs" c="dimmed">
Documents shared between Global Logistics, you and the Djibouti
agent for this shipment. Anything you share here is visible to
them immediately.
</Text>
</Box>
</Group>
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={() => setShareOpen(true)}
>
Share document
</Button>
</Group>
{docs.length > 0 ? (
<Group gap={8} mt="md">
<Badge variant="light" color="edr-green" radius="sm" tt="none">
{stats.et} from GL Ethiopia
</Badge>
<Badge variant="light" color="blue" radius="sm" tt="none">
{stats.dj} from GL Djibouti
</Badge>
<Badge variant="light" color="grape" radius="sm" tt="none">
{stats.transit} from agents
</Badge>
<Badge variant="light" color="gray" radius="sm" tt="none">
{stats.shared} visible to customer
</Badge>
</Group>
) : null}
</Paper>
{exchangeQuery.isPending ? (
<Group justify="center" py={40} gap={10}>
<Loader size="sm" color="edr-green" />
<Text size="sm" c="dimmed">
Loading shared documents
</Text>
</Group>
) : exchangeQuery.isError ? (
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
Could not load the shared documents.
</Alert>
) : docs.length === 0 ? (
<EmptyPanel>
Nothing shared yet. Scans, correspondence and corrected forms posted
by any party appear here.
</EmptyPanel>
) : (
<Stack gap={8}>
{docs.map((doc) => {
const side = SIDES[doc.side];
const canPreview = isViewable({ name: doc.file.name, url: "" });
return (
<Paper key={doc.id} withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap" align="flex-start" gap="sm">
<Group gap={12} wrap="nowrap" align="flex-start" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color={side.color} radius="md" size={40}>
<FileText size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text size="sm" fw={700} truncate>
{doc.title}
</Text>
<Badge size="xs" variant="light" color={side.color} radius="sm" tt="none">
{side.label}
</Badge>
<Badge
size="xs"
variant="light"
color={doc.visibleToCustomer ? "teal" : "gray"}
radius="sm"
tt="none"
leftSection={doc.visibleToCustomer ? <Eye size={11} /> : <EyeOff size={11} />}
>
{doc.visibleToCustomer ? "Visible to customer" : "Agents & GL only"}
</Badge>
</Group>
<Text size="xs" c="dimmed" mt={4} truncate>
{doc.file.name} · {formatBytes(doc.file.size)} ·{" "}
{doc.uploadedByName ?? "Global Logistics"} · {formatDateTime(doc.uploadedAt)}
</Text>
</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(doc.file.id, doc.file.name).then(view)
}
>
View
</Button>
</Tooltip>
) : null}
<Tooltip label="Download">
<Button
size="compact-xs"
variant="light"
radius="md"
leftSection={<Download size={13} />}
onClick={() => void downloadStoredFile(doc.file.id, doc.file.name)}
>
Download
</Button>
</Tooltip>
</Group>
</Group>
</Paper>
);
})}
</Stack>
)}
<ShareExchangeModal
opened={shareOpen}
bookingId={bookingId}
onClose={() => setShareOpen(false)}
onShared={() => void exchangeQuery.refetch()}
/>
{viewer}
</Stack>
);
}
function ShareExchangeModal({
opened,
bookingId,
onClose,
onShared,
}: {
opened: boolean;
bookingId: string;
onClose: () => void;
onShared: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [visibleToCustomer, setVisibleToCustomer] = useState(false);
const close = () => {
setFile(null);
setTitle("");
setVisibleToCustomer(false);
onClose();
};
const submit = useMutation({
mutationFn: () =>
transitAssignmentsService.shareExchangeDocument(bookingId, {
file: file!,
title,
visibleToCustomer,
}),
onSuccess: () => {
toast.success("Document shared");
onShared();
close();
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Could not share document"),
});
return (
<Modal
opened={opened}
onClose={close}
radius="md"
size="md"
title={
<Group gap={8}>
<Share2 size={18} />
<Text fw={700}>Share a document</Text>
</Group>
}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Global Logistics and the Djibouti agent see this immediately. Only you
can edit or remove what you post.
</Text>
<TextInput
label="Title"
placeholder="What is this document?"
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
required
withAsterisk
/>
<Stack gap={6}>
<Text fz={13} fw={600}>
File
</Text>
<input
type="file"
onChange={(e) => setFile(e.currentTarget.files?.[0] ?? null)}
style={{
border: "1px dashed var(--mantine-color-gray-4)",
borderRadius: 8,
padding: 10,
fontSize: 12.5,
background: "var(--mantine-color-gray-0)",
}}
/>
{file ? (
<Text fz={11.5} c="dimmed">
{file.name} · {formatBytes(file.size)}
</Text>
) : null}
</Stack>
<Checkbox
label="Also make this visible to the customer"
description="Off by default — clearance paperwork usually stays between the agents and the desks."
checked={visibleToCustomer}
onChange={(e) => setVisibleToCustomer(e.currentTarget.checked)}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={close} disabled={submit.isPending}>
Cancel
</Button>
<Button
color="edr-green"
loading={submit.isPending}
disabled={!file || title.trim().length === 0}
leftSection={<Upload size={16} />}
onClick={() => submit.mutate()}
>
Share
</Button>
</Group>
</Stack>
</Modal>
);
}
/** Cargo exception reports logged against the shipment — read-only. */
export function IncidentsPanel({ bookingId }: { bookingId: string }) {
const incidentsQuery = useQuery({
queryKey: ["forwarder-incidents", bookingId],
queryFn: () => transitAssignmentsService.incidents(bookingId),
});
const incidents = incidentsQuery.data ?? [];
return (
<Card withBorder shadow="sm" radius="lg" p="md">
<Group gap="sm" align="center" mb="sm">
<ThemeIcon variant="light" color="red" radius="md" size={32}>
<AlertTriangle size={16} />
</ThemeIcon>
<Box>
<Text fw={700} fz={15}>
Incident reports
</Text>
<Text size="xs" c="dimmed">
Container or seal issues found during handling. Logged by the
operations desks read-only here.
</Text>
</Box>
</Group>
{incidentsQuery.isPending ? (
<Group py="lg" justify="center">
<Loader size="sm" color="edr-green" />
</Group>
) : incidentsQuery.isError ? (
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
Incident reports are not available for this shipment.
</Alert>
) : incidents.length > 0 ? (
<Stack gap="xs">
{incidents.map((inc: Freight.IClearanceIncident) => (
<Card key={inc.id} withBorder radius="md" p="sm">
<Group gap={10} wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" color="red" radius="md" size={32}>
<AlertTriangle size={15} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz={13} fw={600}>
{prettyStatus(inc.incidentType)}
</Text>
{inc.description ? (
<Text fz={12.5} c="edr-text" mt={2}>
{inc.description}
</Text>
) : null}
<Text fz={11} c="edr-muted" mt={3}>
{formatDateTime(inc.createdAt)}
</Text>
</Box>
</Group>
</Card>
))}
</Stack>
) : (
<Group gap={8}>
<CheckCircle2 size={15} className="text-edr-muted" />
<Text fz={13} c="dimmed">
No incidents reported for this shipment.
</Text>
</Group>
)}
</Card>
);
}

View File

@@ -0,0 +1,637 @@
import {
Alert,
Box,
Button,
Card,
Group,
NumberInput,
Progress,
Select,
Stack,
Stepper,
Text,
Textarea,
ThemeIcon,
} from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ArrowRight, CheckCircle2, Ship, Upload } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { PortalMultiFileDropzone } from "@/components/contracts/PortalMultiFileDropzone";
import { api } from "@/services/api";
import { transitAssignmentsService } from "@/services/transit-assignments.service";
import type { Freight } from "@edr/types";
import { isReleaseOrderFileCode } from "@edr/types";
/** Who does the step: the forwarder (this page), the Djibouti agent it named, the customer, or the system. */
type Owner = "forwarder" | "djibouti" | "customer" | "system";
interface Step {
key: string;
label: string;
description: string;
owner: Owner;
done: boolean;
}
type MilestoneRow = { milestoneCode?: string | null; status?: string | null };
function isMilestoneDone(ms: MilestoneRow[] | undefined, code: string): boolean {
const m = ms?.find((x) => x.milestoneCode === code);
return m?.status === "COMPLETED" || m?.status === "SKIPPED";
}
const formatStamp = (value: string): string => new Date(value).toLocaleString();
function trainLegDescription(train: Freight.ClearanceView["train"] | undefined): string {
if (train?.arrivedAt) return `Arrived ${formatStamp(train.arrivedAt)}`;
if (train?.departedAt) return `Departed ${formatStamp(train.departedAt)} · in transit`;
return "Departure and arrival";
}
const OWNER_LABEL: Record<Owner, string> = {
forwarder: "You",
djibouti: "Djibouti agent",
customer: "Customer",
system: "Operations",
};
function apiMessage(e: unknown, fallback: string): string {
const err = e as { response?: { data?: { message?: string | string[] } }; message?: string };
const m = err.response?.data?.message;
const text = Array.isArray(m) ? m.join(", ") : m;
return text || err.message || fallback;
}
/**
* The phased clearance wizard for a booking the customer handed to this
* forwarder — the GL desk's clearance action panel, with the forwarder in GL
* Ethiopia's place and the Djibouti agent it named in GL Djibouti's.
*
* Differences from the GL flow, by design: no "request transit assignee"
* step (the forwarder names the Djibouti agent from the card beside this),
* no duty & tax rounds (the forwarder settles duty with customs off the
* platform), and "Create booking" is the customer's — once pre-clearance is
* done the customer completes the booking itself.
*
* Every step's `done` reads off the clearance payload so the wizard can never
* claim a step the server does not consider complete. The action for the
* current step, when it is the forwarder's, renders under the stepper.
*/
export function ForwarderClearancePanel({
booking,
clearance,
onChanged,
}: {
booking: Freight.IBooking;
clearance: Freight.ClearanceView;
onChanged?: () => void;
}) {
const queryClient = useQueryClient();
const bookingId = booking.id;
const isImport = booking.tradeDirection === "IMPORT";
const ms = clearance.milestones;
const done = (code: string) => isMilestoneDone(ms, code);
const workflowFiles = clearance.workflowFiles ?? [];
const hasRo = workflowFiles.some((f) => isReleaseOrderFileCode(f.code) && f.file);
const hasT1Docs = workflowFiles.some(
(f) => /t1/i.test(f.code) && !/djibouti/i.test(f.code) && f.file,
);
const bookingCreated =
Number(booking.totalAmount ?? 0) > 0 ||
done("FREIGHT_PAYMENT_SETTLED") ||
Boolean(clearance.t1);
const draftUploaded = done("DRAFT_DECLARATION_UPLOADED");
const draftAccepted = done("DRAFT_DECLARATION_ACCEPTED");
const steps: Step[] = isImport
? [
{
key: "docs",
label: "Customer documents",
description: "Review and approve in the panel on the left",
owner: "forwarder",
done: done("DOCUMENTS_APPROVED") || Boolean(clearance.allApproved),
},
{
key: "draft",
label: "Draft declaration",
description: draftUploaded
? draftAccepted
? "Customer accepted the estimated price"
: "Sent — waiting for the customer to accept"
: "Send the customer a draft declaration with an estimated price",
owner: "forwarder",
done: draftAccepted || done("DECLARED"),
},
{
key: "declaration",
label: "Customs declaration",
description: "Upload the declaration documents",
owner: "forwarder",
done: done("DECLARED"),
},
{
key: "permit",
label: "Transit permit",
description: "Upload the transit permit documents",
owner: "forwarder",
done: done("TRANSIT_PERMIT_UPLOADED"),
},
{
key: "finalize",
label: "Finalize pre-clearance",
description: "Hand off to the Djibouti transit agent",
owner: "forwarder",
done: Boolean(clearance.preClearanceFinalized),
},
{
key: "do",
label: "Delivery Order",
description: "The Djibouti agent uploads the DO",
owner: "djibouti",
done: done("DO_COLLECTED"),
},
{
key: "create",
label: "Create booking",
description: "The customer completes the booking",
owner: "customer",
done: bookingCreated,
},
{
key: "payment",
label: "Freight payment",
description: "Customer pays the train and service charges",
owner: "customer",
done: done("FREIGHT_PAYMENT_SETTLED"),
},
{
key: "gatepass",
label: "Gate pass",
description: "Secured on the train schedule after payment and wagon allocation",
owner: "system",
done: Boolean(clearance.gatepassGranted),
},
{
key: "offload",
label: "Offload",
description: "Cargo comes off the train at its destination",
owner: "system",
done: Boolean(clearance.offloaded ?? clearance.offload?.offloaded),
},
{
key: "t1docs",
label: "T1 transport documents",
description: "The Djibouti agent uploads after the train departs",
owner: "djibouti",
done: hasT1Docs || Boolean(clearance.t1Closed),
},
{
key: "t1close",
label: "Close T1",
description: "Close once the train has arrived",
owner: "forwarder",
done: Boolean(clearance.t1Closed),
},
{
key: "risk",
label: "Customs risk",
description: clearance.riskLevel
? `Assigned: ${clearance.riskLevel}`
: "Assign Green / Yellow / Red",
owner: "forwarder",
done: Boolean(clearance.riskLevel),
},
{
key: "release",
label: "Import release",
description: "Upload the release document",
owner: "forwarder",
done: Boolean(clearance.importReleaseGranted),
},
]
: [
{
key: "docs",
label: "Customer documents",
description: "Review and approve in the panel on the left",
owner: "forwarder",
done: done("DOCUMENTS_APPROVED") || Boolean(clearance.allApproved),
},
{
key: "ro",
label: "Release Order",
description: "The Djibouti agent uploads the RO with the vessel date",
owner: "djibouti",
done: done("RELEASE_ORDER_SECURED") || hasRo,
},
{
key: "declaration",
label: "Customs declaration",
description: done("RELEASE_ORDER_SECURED")
? "Upload the declaration — releases the export"
: "Upload the declaration; the export is released once the Release Order is in too",
owner: "forwarder",
done: done("DECLARED"),
},
{
key: "create",
label: "Create booking",
description: "The customer completes the booking",
owner: "customer",
done: bookingCreated,
},
{
key: "payment",
label: "Payment & wagon allocation",
description: "Customer pays; operations allocates wagons",
owner: "customer",
done:
done("FREIGHT_PAYMENT_SETTLED") &&
(done("WAGON_ALLOCATED") || Boolean(clearance.train?.wagonAllocated)),
},
{
key: "transport",
label: "Transport document",
description: "Upload after wagon allocation",
owner: "forwarder",
done: done("EXPORT_TRANSPORT_ISSUED"),
},
{
key: "train",
label: "Train to Djibouti",
description: trainLegDescription(clearance.train),
owner: "system",
done: Boolean(clearance.train?.arrivedAt),
},
{
key: "t1close",
label: "Accept T1",
description: "The Djibouti agent closes once the train arrives",
owner: "djibouti",
done: Boolean(clearance.t1Closed),
},
{
key: "gatepass",
label: "Gate pass",
description: "Secured on the train schedule after arrival",
owner: "system",
done: Boolean(clearance.gatepassGranted),
},
{
key: "offload",
label: "Offload",
description: "Cargo comes off the train at its destination",
owner: "system",
done: Boolean(clearance.offloaded ?? clearance.offload?.offloaded),
},
];
const firstPending = steps.findIndex((s) => !s.done);
const activeStep = firstPending === -1 ? steps.length : firstPending;
const percent = Math.round((activeStep / steps.length) * 100);
const current = steps[activeStep] ?? null;
const refresh = () => {
void queryClient.invalidateQueries({
queryKey: api.bookings.getClearance.queryKey({ id: bookingId }),
});
void queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: bookingId }),
});
void queryClient.invalidateQueries({
queryKey: ["forwarder-clearance-history", bookingId],
});
onChanged?.();
};
return (
<Card withBorder shadow="sm" radius="lg" p={0} style={{ overflow: "hidden" }}>
<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 clearance" : "Export clearance"}
</Text>
<Text fz={11.5} c="dimmed">
Step {Math.min(activeStep + 1, steps.length)} of {steps.length}
{current ? ` · ${current.label}` : " · complete"}
</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>
{current ? (
<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 }}>
{OWNER_LABEL[current.owner].toUpperCase()}
</Text>
<Text fz={11.5} fw={600} c="edr-text" style={{ minWidth: 0 }}>
{current.description}
</Text>
</Group>
) : null}
<Box p="md">
{clearance.roHoldReason ? (
<Alert color="red" variant="light" mb="sm" title="RO amendment hold">
{clearance.roHoldReason}
</Alert>
) : null}
<Stepper
active={activeStep}
orientation="vertical"
size="sm"
iconSize={26}
allowNextStepsSelect={false}
mb="md"
>
{steps.map((s) => (
<Stepper.Step
key={s.key}
label={s.label}
description={
s.owner === "forwarder" ? s.description : `${OWNER_LABEL[s.owner]} · ${s.description}`
}
icon={s.done ? <CheckCircle2 size={14} /> : undefined}
/>
))}
</Stepper>
{current?.owner === "forwarder" ? (
<StepAction
step={current.key}
bookingId={bookingId}
isImport={isImport}
draftUploaded={draftUploaded}
onDone={refresh}
/>
) : current ? (
<Alert color="gray" variant="light" radius="md">
Waiting on {OWNER_LABEL[current.owner].toLowerCase()}: {current.description}.
</Alert>
) : (
<Alert color="teal" variant="light" radius="md" icon={<CheckCircle2 size={16} />}>
Every step is complete.
</Alert>
)}
</Box>
</Card>
);
}
/** The control for the forwarder's current step. */
function StepAction({
step,
bookingId,
isImport,
draftUploaded,
onDone,
}: {
step: string;
bookingId: string;
isImport: boolean;
draftUploaded: boolean;
onDone: () => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const [price, setPrice] = useState<number | string>("");
const [currency, setCurrency] = useState<string>("ETB");
const [risk, setRisk] = useState<"GREEN" | "YELLOW" | "RED" | null>(null);
const [note, setNote] = useState("");
const run = useMutation({
mutationFn: async () => {
switch (step) {
case "draft":
await transitAssignmentsService.uploadDraftDeclaration(
bookingId,
files,
Number(price),
currency,
);
return "Draft declaration sent to the customer";
case "declaration":
// On export the API releases the export itself once both the
// declaration and the Release Order are on file.
await transitAssignmentsService.uploadDeclaration(bookingId, files);
return isImport ? "Declaration uploaded" : "Declaration uploaded — the export is released once the Release Order is in too";
case "permit":
await transitAssignmentsService.uploadTransitPermit(bookingId, files);
return "Transit permit uploaded";
case "finalize":
await transitAssignmentsService.finalizePreClearance(bookingId);
return "Pre-clearance finalized — handed to the Djibouti agent";
case "transport":
await transitAssignmentsService.uploadTransportDocument(bookingId, files);
return "Transport document uploaded";
case "t1close":
await transitAssignmentsService.closeT1(bookingId);
return "T1 closed";
case "risk":
await transitAssignmentsService.assignRisk(bookingId, risk!, note);
return "Customs risk assigned";
case "release":
await transitAssignmentsService.uploadImportRelease(bookingId, files);
return "Import release uploaded";
default:
return "Done";
}
},
onSuccess: (message) => {
toast.success(message);
setFiles([]);
setNote("");
setRisk(null);
onDone();
},
onError: (e: unknown) => toast.error(apiMessage(e, "The step could not be completed")),
});
const skipDraft = useMutation({
mutationFn: () => transitAssignmentsService.skipDraftDeclaration(bookingId),
onSuccess: () => {
toast.success("Draft declaration skipped — file the declaration directly");
onDone();
},
onError: (e: unknown) => toast.error(apiMessage(e, "Could not skip the draft")),
});
if (step === "docs") {
return (
<Alert color="blue" variant="light" radius="md">
Approve every required customer document in the review panel to move on.
</Alert>
);
}
if (step === "draft") {
if (draftUploaded) {
return (
<Alert color="blue" variant="light" radius="md">
The draft declaration is with the customer. The next step unlocks when
they accept it, or when they send it back for changes.
</Alert>
);
}
return (
<Stack gap="sm">
<PortalMultiFileDropzone
label="Draft declaration documents"
description="The estimate the customer reviews before you file the real declaration."
files={files}
onChange={setFiles}
/>
<Group grow align="flex-end">
<NumberInput
label="Estimated price"
placeholder="0.00"
min={0}
decimalScale={2}
thousandSeparator=","
value={price}
onChange={setPrice}
radius="md"
/>
<Select
label="Currency"
data={["ETB", "USD", "DJF"]}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
allowDeselect={false}
radius="md"
/>
</Group>
<Group justify="space-between">
<Button
variant="subtle"
color="gray"
radius="md"
loading={skipDraft.isPending}
onClick={() => skipDraft.mutate()}
>
Skip the draft
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
disabled={files.length === 0 || !(Number(price) >= 0) || price === ""}
loading={run.isPending}
onClick={() => run.mutate()}
>
Send draft declaration
</Button>
</Group>
</Stack>
);
}
if (step === "finalize" || step === "t1close") {
return (
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={15} />}
loading={run.isPending}
onClick={() => run.mutate()}
>
{step === "finalize" ? "Finalize pre-clearance" : "Close T1"}
</Button>
</Group>
);
}
if (step === "risk") {
return (
<Stack gap="sm">
<Select
label="Customs risk level"
placeholder="Pick a level"
data={[
{ value: "GREEN", label: "Green" },
{ value: "YELLOW", label: "Yellow" },
{ value: "RED", label: "Red" },
]}
value={risk}
onChange={(v) => setRisk((v as "GREEN" | "YELLOW" | "RED" | null) ?? null)}
radius="md"
/>
<Textarea
label="Note (optional)"
autosize
minRows={2}
radius="md"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
disabled={!risk}
loading={run.isPending}
onClick={() => run.mutate()}
>
Assign risk
</Button>
</Group>
</Stack>
);
}
// Every remaining forwarder step is a document upload.
const uploadLabel: Record<string, string> = {
declaration: "Customs declaration documents",
permit: "Transit permit documents",
transport: "Transport documents",
release: "Import release document",
};
return (
<Stack gap="sm">
<PortalMultiFileDropzone
label={uploadLabel[step] ?? "Documents"}
files={files}
onChange={setFiles}
/>
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
disabled={files.length === 0}
loading={run.isPending}
onClick={() => run.mutate()}
>
Upload
</Button>
</Group>
</Stack>
);
}

View File

@@ -131,8 +131,12 @@ export function ForwarderDocumentReview({
const { clearance, customerDocs, canUpload, pending, adHoc, status } = flow;
const docNoun = bookingDocNoun(booking);
const reviewOpen = clearance.documentsOpen ?? canUpload;
// A phased (agent-cleared) booking advances through the clearance wizard —
// approving the last document moves it on by itself, and the server refuses
// the plain finalize. Only a non-phased booking finalizes from here.
const phased = Boolean(clearance.phase);
const canFinalize =
status === "DOCUMENTS_UNDER_REVIEW" && clearance.allApproved;
!phased && status === "DOCUMENTS_UNDER_REVIEW" && clearance.allApproved;
const finalized = [
"CLEARANCE_READY",
"OPERATION_REQUEST_PENDING",
@@ -147,6 +151,11 @@ export function ForwarderDocumentReview({
Clearance is finalized. The customer completes the booking from
here; documents stay open for additions until the shipment is paid.
</Alert>
) : phased && clearance.allApproved ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
Every required document is approved. Continue with the clearance
steps in the panel on the right.
</Alert>
) : status === "AWAITING_DOCUMENTS" ? (
<Alert color="yellow" radius="md" icon={<Upload size={18} />}>
Waiting for the {docNoun}. Upload them on the customer's behalf
@@ -440,7 +449,7 @@ export function ForwarderDocumentReview({
Submit documents
</Button>
) : null}
{!finalized ? (
{!finalized && !phased ? (
<Button
color="edr-green"
radius="md"

View File

@@ -81,6 +81,18 @@ export function TransitClearanceActionPanel({
const queryClient = useQueryClient();
const [amendOpen, setAmendOpen] = useState(false);
// Export, agent-cleared booking: the Djibouti agent accepts the T1 in GL
// Djibouti's place once the train has arrived. Elsewhere the desk does it.
const closeT1 = useMutation({
mutationFn: () => transitAssignmentsService.closeT1(bookingId),
onSuccess: () => {
toast.success("T1 accepted");
refresh();
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Could not accept the T1"),
});
const isImport = tradeDirection === "IMPORT";
const workflowFiles = clearance?.workflowFiles ?? [];
const hasRo = workflowFiles.some(
@@ -108,19 +120,26 @@ export function TransitClearanceActionPanel({
const done = (code: string) => isMilestoneDone(ms, code);
const bookingDone = (code: string) => isMilestoneDone(ms, code);
const dutyRequired = clearance?.dutyRequired ?? false;
// A booking a clearing agent (forwarder) handles: it reviews the documents
// and files the declaration in GL Ethiopia's place, the customer creates the
// booking itself, and the RO does not wait for the declaration.
const byAgent = Boolean(clearance?.clearedByAgent);
const etDesk = byAgent ? "the clearing agent" : "GL Ethiopia";
const steps: WizardStep[] = isImport
? [
{
label: "Customer documents",
description: "Reviewed and approved by GL Ethiopia",
description: `Reviewed and approved by ${etDesk}`,
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",
: byAgent
? "The clearing agent names the officer handling this shipment"
: "GL Djibouti names the officer handling this shipment",
done: Boolean(clearance?.transitAssignee?.name),
},
{
@@ -130,7 +149,7 @@ export function TransitClearanceActionPanel({
},
{
label: "Customs declaration",
description: "GL Ethiopia uploads declaration documents",
description: `${byAgent ? "The clearing agent" : "GL Ethiopia"} uploads declaration documents`,
done: done("DECLARED"),
},
{
@@ -151,7 +170,7 @@ export function TransitClearanceActionPanel({
},
{
label: "Finalize pre-clearance",
description: "GL Ethiopia hands off to GL Djibouti",
description: byAgent ? "The clearing agent hands off to you" : "GL Ethiopia hands off to GL Djibouti",
done: Boolean(clearance?.preClearanceFinalized),
},
{
@@ -161,7 +180,7 @@ export function TransitClearanceActionPanel({
},
{
label: "Create booking",
description: "GL Ethiopia books for the customer",
description: byAgent ? "The customer completes the booking" : "GL Ethiopia books for the customer",
done: Boolean(clearance?.t1) || bookingDone("FREIGHT_PAYMENT_SETTLED"),
},
{
@@ -178,19 +197,21 @@ export function TransitClearanceActionPanel({
: [
{
label: "Customer documents",
description: "Reviewed and approved by GL Ethiopia",
description: `Reviewed and approved by ${etDesk}`,
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",
: byAgent
? "The clearing agent names the officer handling this shipment"
: "GL Djibouti names the officer handling this shipment",
done: Boolean(clearance?.transitAssignee?.name),
},
{
label: "Customs declaration",
description: "GL Ethiopia uploads — releases the export",
description: `${byAgent ? "The clearing agent" : "GL Ethiopia"} uploads — releases the export`,
done: done("DECLARED"),
},
{
@@ -200,7 +221,7 @@ export function TransitClearanceActionPanel({
},
{
label: "Create booking",
description: "GL Ethiopia books for the customer",
description: byAgent ? "The customer completes the booking" : "GL Ethiopia books for the customer",
done: bookingDone("FREIGHT_PAYMENT_SETTLED") || Boolean(clearance?.t1),
},
{
@@ -213,7 +234,7 @@ export function TransitClearanceActionPanel({
},
{
label: "Transport document",
description: "GL Ethiopia uploads after wagon allocation",
description: `${byAgent ? "The clearing agent" : "GL Ethiopia"} uploads after wagon allocation`,
done: bookingDone("EXPORT_TRANSPORT_ISSUED"),
},
{
@@ -238,6 +259,17 @@ export function TransitClearanceActionPanel({
},
];
// On an agent-cleared export the RO goes in right after the customer
// documents, ahead of the declaration — the order the API enforces there.
if (!isImport && byAgent) {
const roIdx = steps.findIndex((s) => s.label === "Release Order");
const declIdx = steps.findIndex((s) => s.label === "Customs declaration");
if (roIdx > declIdx && declIdx >= 0) {
const [ro] = steps.splice(roIdx, 1);
steps.splice(declIdx, 0, ro!);
}
}
// 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);
@@ -350,6 +382,19 @@ export function TransitClearanceActionPanel({
transit documents panel above the grid, where its timings are
shown; only the RO amendment request stays here. */}
<Stack gap={8}>
{!isImport &&
clearance?.train?.arrivedAt &&
!clearance?.t1Closed ? (
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={15} />}
loading={closeT1.isPending}
onClick={() => closeT1.mutate()}
>
Accept T1
</Button>
) : null}
{!isImport ? (
<Button
variant="subtle"

View File

@@ -465,13 +465,25 @@ function ReleaseOrderCard({
);
const hasRo = roFiles.length > 0;
// The customs declaration is the gate: the API refuses an RO before it.
// The customs declaration is the gate: the API refuses an RO before it
// except on a booking a clearing agent handles, where the RO may go in as
// soon as the customer's documents are approved, declaration or not.
const declaredMilestone = clearance.milestones?.find(
(m) => m.milestoneCode === "DECLARED",
);
const declared =
const docsApprovedMilestone = clearance.milestones?.find(
(m) => m.milestoneCode === "DOCUMENTS_APPROVED",
);
const docsApproved =
docsApprovedMilestone?.status === "COMPLETED" ||
docsApprovedMilestone?.status === "SKIPPED" ||
Boolean(clearance.allApproved);
const declarationFiled =
declaredMilestone?.status === "COMPLETED" ||
declaredMilestone?.status === "SKIPPED";
const declared = clearance.clearedByAgent
? docsApproved
: declarationFiled;
const declaredAt =
declaredMilestone?.triggeredAt ??
latestEvent(history, "DECLARATION_UPLOADED")?.at ??
@@ -521,7 +533,7 @@ function ReleaseOrderCard({
tt="none"
leftSection={<Lock size={10} />}
>
Waiting for declaration
{clearance.clearedByAgent ? "Waiting for documents" : "Waiting for declaration"}
</Badge>
);
@@ -568,7 +580,15 @@ function ReleaseOrderCard({
icon={FileText}
label="Declaration uploaded"
value={declaredAt ? formatStamp(declaredAt) : declared ? "Done" : "Pending"}
hint={declared ? "By GL Ethiopia — RO unlocked" : "Unlocks the Release Order"}
hint={
clearance.clearedByAgent
? declarationFiled
? "Filed by the clearing agent"
: "The clearing agent files it — the RO does not wait for it"
: declared
? "By GL Ethiopia — RO unlocked"
: "Unlocks the Release Order"
}
tone={declared ? "green" : "muted"}
/>
<Stat
@@ -611,7 +631,9 @@ function ReleaseOrderCard({
<EmptyDocs icon={Ship}>
{declared
? "No Release Order on file yet. Upload the RO and confirm the vessel departure date."
: "The Release Order can be uploaded as soon as the customs declaration is on file."}
: clearance.clearedByAgent
? "The Release Order can be uploaded as soon as the customer documents are approved."
: "The Release Order can be uploaded as soon as the customs declaration is on file."}
</EmptyDocs>
)}
</Stack>

View File

@@ -425,6 +425,100 @@ export const transitAssignmentsService = {
return data.data ?? data;
},
// ── Phased workflow, GL Ethiopia's steps done by the clearing agent ──────
// On an agent-cleared booking the forwarder files what the GL Ethiopia desk
// would: draft and final declaration, transit permit, pre-clearance handoff,
// export release, transport document, T1 close, risk, import release. The
// API accepts these only from the agent assigned to that booking.
uploadDraftDeclaration: async (
bookingId: string,
files: File[],
price: number,
currency: string,
): Promise<void> => {
const form = new FormData();
for (const f of files) form.append("files", f);
form.append("price", String(price));
form.append("currency", currency);
await client.post(
`/api/bookings/${bookingId}/clearance/draft-declaration`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
},
skipDraftDeclaration: async (bookingId: string): Promise<void> => {
await client.post(
`/api/bookings/${bookingId}/clearance/draft-declaration/skip`,
);
},
uploadDeclaration: async (bookingId: string, files: File[]): Promise<void> => {
const form = new FormData();
for (const f of files) form.append("files", f);
await client.post(`/api/bookings/${bookingId}/clearance/declaration`, form, {
headers: { "Content-Type": "multipart/form-data" },
});
},
uploadTransitPermit: async (bookingId: string, files: File[]): Promise<void> => {
const form = new FormData();
for (const f of files) form.append("files", f);
await client.post(
`/api/bookings/${bookingId}/clearance/transit-permit`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
},
finalizePreClearance: async (bookingId: string): Promise<void> => {
await client.post(
`/api/bookings/${bookingId}/clearance/finalize-pre-clearance`,
);
},
confirmExportRelease: async (bookingId: string): Promise<void> => {
await client.post(`/api/bookings/${bookingId}/clearance/export-release`);
},
uploadTransportDocument: 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}/transport-document`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
},
closeT1: async (bookingId: string): Promise<void> => {
await client.post(`/api/contracts/bookings/${bookingId}/t1-close`);
},
assignRisk: async (
bookingId: string,
riskLevel: "GREEN" | "YELLOW" | "RED",
note?: string,
): Promise<void> => {
await client.post(`/api/contracts/bookings/${bookingId}/risk`, {
riskLevel,
...(note?.trim() ? { note: note.trim() } : {}),
});
},
/** Import release document; the field name is the milestone trigger. */
uploadImportRelease: async (bookingId: string, files: File[]): Promise<void> => {
const form = new FormData();
for (const f of files) form.append("import_release", f);
await client.post(`/api/contracts/bookings/${bookingId}/documents`, form, {
headers: { "Content-Type": "multipart/form-data" },
});
},
/** T1 transit documents (import); locked once GL Ethiopia closes the T1. */
uploadT1Documents: async (
bookingId: string,