mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 21:48:18 +00:00
feat(bookings): add agent-driven clearance flow with forwarder/transit-agent panels and BookingClearedByAgent migration
This commit is contained in:
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? []);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -898,6 +898,12 @@ export interface IBooking extends BaseEntity {
|
||||
handoverAwaitingSignature?: boolean;
|
||||
|
||||
customsClearingEnabled?: boolean;
|
||||
/**
|
||||
* Without-customs booking handed to a registered clearing agent (forwarder):
|
||||
* it runs the phased clearance workflow with the forwarder in GL Ethiopia's
|
||||
* place and the Djibouti agent it named in GL Djibouti's.
|
||||
*/
|
||||
clearedByAgent?: boolean;
|
||||
// (multi-truck self-haul lives in ICustomerTruck[], fetched via the
|
||||
// /customer-trucks endpoint; the fields above are the booking-level flag.)
|
||||
customsClearingAgent?: string | null;
|
||||
@@ -1213,6 +1219,12 @@ export interface ClearanceView {
|
||||
phase?: ContractDocPhase | null;
|
||||
milestones?: IClearanceMilestone[];
|
||||
nextAction?: ClearanceNextAction | null;
|
||||
/**
|
||||
* Handed to a registered clearing agent (forwarder): it does GL Ethiopia's
|
||||
* steps and the Djibouti agent it named does GL Djibouti's. The Release
|
||||
* Order does not wait for the declaration on such a booking.
|
||||
*/
|
||||
clearedByAgent?: boolean;
|
||||
dutyRequired?: boolean | null;
|
||||
roHold?: boolean;
|
||||
roHoldReason?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user