Merge pull request #1000 from Tria-plc/freight_feature/usermanagement

New Transit Agents admin table (name, valid-from/to, active/suspended…
This commit is contained in:
marshal
2026-07-29 08:46:14 +03:00
committed by GitHub
59 changed files with 2903 additions and 451 deletions

View File

@@ -32,6 +32,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
// import { TrainsModule } from "./modules/trains/trains.module";
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.module";
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
@@ -175,6 +176,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
ConsignmentsModule,
LocomotivesModule,
TruckTypesModule,
TransitAgentsModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* GL Ethiopia ↔ GL Djibouti document exchange. The documents are ordinary
* `freight.files` rows (resource `gl_exchange`), so they only need the metadata
* a free-form upload has and a catalog-driven one does not: the uploader's own
* title, who uploaded it (the only user allowed to change it afterwards) and
* whether the customer may see it in the portal.
*/
export class AddGlExchangeDocumentFields3030000000000
implements MigrationInterface
{
name = 'AddGlExchangeDocumentFields3030000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.files
ADD COLUMN IF NOT EXISTS title varchar(300),
ADD COLUMN IF NOT EXISTS visible_to_customer boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS uploaded_by_user_id uuid,
ADD COLUMN IF NOT EXISTS uploaded_by_name varchar(200);`,
);
// Every read of a thread is "all files of one resource" — index the pair.
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_files_resource_lookup
ON freight.files (resource, resource_id);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_files_resource_lookup;`,
);
await queryRunner.query(
`ALTER TABLE freight.files
DROP COLUMN IF EXISTS title,
DROP COLUMN IF EXISTS visible_to_customer,
DROP COLUMN IF EXISTS uploaded_by_user_id,
DROP COLUMN IF EXISTS uploaded_by_name;`,
);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddTransitAgents3040000000000 implements MigrationInterface {
name = "AddTransitAgents3040000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.transit_agents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name varchar(150) NOT NULL,
valid_from date NOT NULL,
valid_to date NOT NULL,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS ix_transit_agents_is_active
ON freight.transit_agents (is_active)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_agents`);
}
}

View File

@@ -317,11 +317,11 @@ export class BookingLifecycleNotifierService {
});
}
/** GL raised the final (post-offload) invoice — customer pays + uploads slip. */
/** GL raised the final (post-offload) invoice — customer approves, pays, uploads slip. */
finalInvoiceCreated(b: Booking, amount: number, currency: string): void {
const msg =
`A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` +
`Please pay and upload the payment slip from the portal.`;
`A final invoice of ${amount} ${currency} has been raised for booking ${b.reference}. ` +
`Please review and approve it in the portal, then pay and upload the payment slip.`;
void this.notifyContact(b, msg, 'FINAL INVOICE');
this.inApp(b, 'Final invoice issued', msg, {
type: NotificationType.INVOICE_ISSUED,
@@ -361,6 +361,15 @@ export class BookingLifecycleNotifierService {
);
}
/** Customer approved the GL Djibouti final invoice — payment slip can follow. */
finalInvoiceApprovedToStaff(b: Booking): void {
this.inAppStaff(
b,
'Final invoice approved',
`The customer approved the final invoice for booking ${this.ref(b)} — awaiting payment slip.`,
);
}
/** Customer signed the booking contract. */
customerSignedToStaff(b: Booking): void {
this.inAppStaff(
@@ -392,16 +401,27 @@ export class BookingLifecycleNotifierService {
);
}
/**
* The customer disputed the advised duty & tax. This goes to STAFF, not the
* customer: GL Ethiopia is the one who has to re-advise, and the clearance
* page is where they do it.
*/
dutyDisputed(b: Booking, note: string): void {
/** GL Ethiopia sent a draft customs declaration — the customer must accept or request a change. */
draftDeclarationReady(b: Booking, price: number, currency: string): void {
const msg =
`The customer disputed the duty & tax advised on booking ${this.ref(b)}: ` +
`"${note}". Review and re-advise the amount on the clearance page.`;
this.inAppStaff(b, `Duty disputed on ${this.ref(b)}`, msg, {
`A draft customs declaration for booking ${b.reference} is ready for your review — ` +
`estimated price ${price} ${currency}. Please accept it or request a change from the portal.`;
void this.notifyContact(b, msg, 'DRAFT DECLARATION READY');
this.inApp(b, 'Draft declaration ready for review', msg, {
type: NotificationType.DOCUMENT_ACTION,
});
}
/**
* The customer asked for a change on the draft declaration. This goes to
* STAFF, not the customer: GL Ethiopia is the one who has to send a
* corrected draft, and the clearance page is where they do it.
*/
draftDeclarationChangeRequested(b: Booking, note: string): void {
const msg =
`The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` +
`"${note}". Send a corrected draft from the clearance page.`;
this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`,
});

View File

@@ -841,13 +841,13 @@ export class BookingsController {
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary:
'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns',
'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns',
})
async assignBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('assignee') assignee: string,
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
) {
const booking = await this.bookingClearanceService.assignTransitAssignee(id, assignee);
const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -900,17 +900,52 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/duty/dispute')
@Post(':id/clearance/draft-declaration')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary:
'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)',
'GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review',
})
async disputeBookingDuty(
async uploadBookingDraftDeclaration(
@Param('id', ParseUUIDPipe) id: string,
@Body('price') priceRaw: string,
@Body('currency') currency: string | undefined,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadDraftDeclaration(
id,
files ?? [],
Number(priceRaw),
currency ?? 'ETB',
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/draft-declaration/accept')
@ApiOperation({
summary:
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
})
async acceptBookingDraftDeclaration(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.bookingClearanceService.acceptDraftDeclaration(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/draft-declaration/change')
@ApiOperation({
summary:
'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)',
})
async requestBookingDraftDeclarationChange(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.disputeDuty(
const booking = await this.bookingClearanceService.requestDraftDeclarationChange(
id,
note,
resolveAuthUserId(user),

View File

@@ -7,10 +7,10 @@ export const REVIEW_NOTE_TYPES = [
'REJECTION',
'STAFF_NOTE',
/**
* The customer disputed the advised duty & tax and asked GL Ethiopia to
* correct it. One row per round — the advice/dispute loop can repeat.
* The customer asked GL Ethiopia to correct the draft customs declaration
* (price/files). One row per round — the draft/change-request loop can repeat.
*/
'DUTY_DISPUTE',
'DRAFT_DECL_CHANGE_REQUEST',
] as const;
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];

View File

@@ -104,6 +104,12 @@ function makeService(overrides?: {
transitAssigneeRequested: jest.fn(),
transitAssigneeAssigned: jest.fn(),
} as never, // notifier
{ listVisibleToCustomer: jest.fn().mockResolvedValue([]) } as never, // GL exchange
{
getAssignable: jest
.fn()
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
} as never, // transit agents
);
return {

View File

@@ -1,10 +1,13 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import {
ContractDocPhase,
isDraftDeclarationFileCode,
type ClearanceFinalInvoiceSummary,
type ClearanceOffloadState,
type ClearanceSecondDuty,
type ClearanceT1State,
type ClearanceTrainState,
type GlExchangeDocument,
} from '@edr/types';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
@@ -23,8 +26,10 @@ import { assertDoCollectionDates } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { GlExchangeService } from './gl-exchange.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDraftDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
@@ -89,10 +94,22 @@ export interface BookingClearanceView {
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
/**
* The customer's open objection to the advised duty. Present only until GL
* re-advises; `rounds` counts how many times it has been sent back.
* Import only: the draft customs declaration GL Ethiopia sends before filing
* the real one. Present once a draft has been uploaded, regardless of
* accept state — `accepted` tells the caller which.
*/
dutyDispute?: {
draftDeclaration?: {
price: number;
currency: string;
files: Array<{ id: string; name: string; url: string }>;
accepted: boolean;
} | null;
/**
* The customer's open change request on the current draft declaration.
* Present only until GL sends a corrected draft; `rounds` counts how many
* times it has been sent back.
*/
draftDeclarationChangeRequest?: {
note: string;
raisedAt: string;
rounds: number;
@@ -107,6 +124,8 @@ export interface BookingClearanceView {
t1Closed?: boolean;
t1ClosedAt?: string | null;
offloaded?: boolean;
/** Offload stats for this booking (what came off the train, and where). */
offload?: ClearanceOffloadState | null;
/** GL Djibouti post-offload final invoice (export). */
finalInvoice?: ClearanceFinalInvoiceSummary | null;
/** Customs risk level assigned by GL ET (import; visible to the customer). */
@@ -117,6 +136,8 @@ export interface BookingClearanceView {
/** Post-arrival additional duty/tax round (import). */
secondDuty?: ClearanceSecondDuty | null;
importReleaseGranted?: boolean;
/** GL-shared documents this booking's uploader marked visible to the customer. */
exchangeDocuments?: GlExchangeDocument[];
}
@Injectable()
@@ -131,6 +152,8 @@ export class BookingClearanceService {
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly glExchangeService: GlExchangeService,
private readonly transitAgentsService: TransitAgentsService,
) {}
private async assertPhasedCustoms(booking: Booking): Promise<void> {
@@ -231,7 +254,11 @@ export class BookingClearanceService {
booking.tradeDirection ?? 'IMPORT',
);
const dutyAdvice = this.buildDutyAdvice(files, milestones);
const dutyDispute = await this.buildDutyDispute(bookingId, milestones);
const draftDeclaration = this.buildDraftDeclaration(files, milestones);
const draftDeclarationChangeRequest = await this.buildDraftDeclarationChangeRequest(
bookingId,
milestones,
);
const workflowFiles = buildWorkflowFiles(
files,
booking.tradeDirection ?? 'IMPORT',
@@ -259,6 +286,12 @@ export class BookingClearanceService {
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
// GL↔GL exchange documents shared with the customer. The two desks may work
// the thread on the booking (per-booking customs) or on its contract
// (pre-booking clearance), so the customer's view spans both.
const exchangeDocuments = await this.glExchangeService.listVisibleToCustomer(
[bookingId, booking.contractId ?? ''],
);
return {
bookingId,
@@ -301,8 +334,10 @@ export class BookingClearanceService {
: null,
},
dutyAdvice,
dutyDispute,
draftDeclaration,
draftDeclarationChangeRequest,
workflowFiles,
exchangeDocuments,
t1,
train,
gatepassGranted: gatepass.granted,
@@ -313,6 +348,7 @@ export class BookingClearanceService {
? t1ClosedMilestone.triggeredAt.toISOString()
: null,
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
offload: await this.glOperationsService.offloadState(bookingId, milestones),
finalInvoice,
riskLevel:
riskMilestone?.status === 'COMPLETED'
@@ -359,13 +395,37 @@ export class BookingClearanceService {
};
}
private async buildDutyDispute(
private buildDraftDeclaration(
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
milestones: ClearanceMilestone[],
): BookingClearanceView['draftDeclaration'] {
const uploaded = milestones.find(
(m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED' && m.status === 'COMPLETED',
);
if (!uploaded?.metadata) return null;
const price = uploaded.metadata.draftDeclarationPrice;
const currency = uploaded.metadata.draftDeclarationCurrency;
if (typeof price !== 'number' || typeof currency !== 'string') return null;
const draftFiles = files
.filter((f) => f.code && isDraftDeclarationFileCode(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''))
.map((f) => ({ id: f.id, name: f.name, url: f.url }));
const accepted =
milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_ACCEPTED')?.status ===
'COMPLETED';
return { price, currency, files: draftFiles, accepted };
}
private async buildDraftDeclarationChangeRequest(
bookingId: string,
milestones: ClearanceMilestone[],
): Promise<BookingClearanceView['dutyDispute']> {
const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED');
if (!advised || advised.status === 'COMPLETED') return null;
const notes = await this.bookingsRepository.findReviewNotes(bookingId, 'DUTY_DISPUTE');
): Promise<BookingClearanceView['draftDeclarationChangeRequest']> {
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
if (!uploaded || uploaded.status === 'COMPLETED') return null;
const notes = await this.bookingsRepository.findReviewNotes(
bookingId,
'DRAFT_DECL_CHANGE_REQUEST',
);
const latest = notes[0];
if (!latest) return null;
return {
@@ -426,28 +486,27 @@ export class BookingClearanceService {
}
/**
* GL Djibouti names the transit officer — free text, because the person is not
* a platform user. Answering unblocks the declaration for Ethiopia. A later
* call overwrites the name (reassignment) and re-notifies.
* GL Djibouti picks the transit officer from the admin-managed roster —
* rejected unless the agent is active and inside its validity window.
* Answering unblocks the declaration for Ethiopia. A later call overwrites
* the name (reassignment) and re-notifies.
*/
async assignTransitAssignee(bookingId: string, assignee: string): Promise<Booking> {
async assignTransitAssignee(bookingId: string, transitAgentId: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (!assignee?.trim()) {
throw new BadRequestException('Name the officer who will handle the transit.');
}
if (!booking.transitAssigneeRequestedAt) {
throw new BadRequestException(
'GL Ethiopia has not requested a transit assignee for this shipment yet.',
);
}
const agent = await this.transitAgentsService.getAssignable(transitAgentId);
const previous = booking.transitAssigneeName ?? null;
await this.bookingsRepository.update(bookingId, {
transitAssigneeName: assignee.trim(),
transitAssigneeName: agent.name,
transitAssigneeAssignedAt: new Date(),
} as never);
this.notifier.transitAssigneeAssigned(booking, assignee.trim(), previous);
this.notifier.transitAssigneeAssigned(booking, agent.name, previous);
return this.bookingsService.findById(bookingId);
}
@@ -499,12 +558,6 @@ export class BookingClearanceService {
: ContractDocPhase.CustomerDuty,
} as never);
// Export: the declaration is the last GL ET pre-operation action — release
// immediately so the customer can proceed without a separate confirm click.
if (tradeDirection === 'EXPORT') {
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
}
return this.bookingsService.findById(bookingId);
}
@@ -562,61 +615,120 @@ export class BookingClearanceService {
}
/**
* The customer disagrees with the advised duty & tax on this booking and asks
* GL Ethiopia to correct it. Nothing is paid; the advice milestone reopens so
* the Duty & tax step becomes actionable again on the GL clearance page, with
* the customer's message shown beside it. GL re-advises (same endpoint as the
* first time), which closes the dispute — the loop may run as many rounds as
* it takes.
* GL Ethiopia sends a draft customs declaration (estimated price + files) for
* the customer to review before the real declaration is filed. Repeatable —
* each call replaces the previous draft's files/price and re-arms the step,
* which is what a re-send after a change request needs.
*/
async disputeDuty(
async uploadDraftDeclaration(
bookingId: string,
files: Express.Multer.File[],
price: number,
currency: string,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Draft declaration applies only to import bookings.');
}
if (files.length === 0) {
throw new BadRequestException('No draft declaration documents uploaded');
}
if (!Number.isFinite(price) || price < 0) {
throw new BadRequestException('A valid estimated price is required.');
}
// Backfills the two new milestone rows for bookings seeded before this step
// existed — a blind complete() 404s on a booking with no such row yet.
await this.milestoneService.ensureBookingMilestones(bookingId, 'IMPORT');
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'IMPORT',
'DRAFT_DECLARATION_UPLOADED',
);
await persistDraftDeclarationUploads(this.filesService, bookingId, 'bookings', files);
await this.milestoneService.completeWithMetadataForBooking(
bookingId,
'DRAFT_DECLARATION_UPLOADED',
{ draftDeclarationPrice: price, draftDeclarationCurrency: currency },
userId,
);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
const updated = await this.bookingsService.findById(bookingId);
this.notifier.draftDeclarationReady(updated, price, currency);
return updated;
}
/**
* The customer accepts the draft declaration — GL Ethiopia may now file the
* real customs declaration.
*/
async acceptDraftDeclaration(bookingId: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Draft declaration applies only to import bookings.');
}
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
if (uploaded?.status !== 'COMPLETED') {
throw new BadRequestException('There is no draft declaration to accept yet.');
}
await this.workflowService.completeMilestoneForBooking(bookingId, 'DRAFT_DECLARATION_ACCEPTED');
return this.bookingsService.findById(bookingId);
}
/**
* The customer sends the draft declaration back with a reason. Nothing is
* filed; the upload milestone reopens so the step becomes actionable again
* for GL Ethiopia, with the customer's message shown beside it. GL re-sends
* (same endpoint as the first time), which closes the request — the loop may
* run as many rounds as it takes.
*/
async requestDraftDeclarationChange(
bookingId: string,
note: string,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Duty applies only to import bookings.');
throw new BadRequestException('Draft declaration applies only to import bookings.');
}
if (!note?.trim()) {
throw new BadRequestException(
'Say what is wrong with the advised amount so GL can correct it.',
'Say what needs to change so GL can correct the draft.',
);
}
if (!booking.dutyRequired) {
throw new BadRequestException('Duty/tax is not required for this clearance.');
}
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
if (byCode.get('DUTY_TAXES_ADVISED')?.status !== 'COMPLETED') {
throw new BadRequestException(
'There is no advised duty amount to dispute yet.',
);
if (byCode.get('DRAFT_DECLARATION_UPLOADED')?.status !== 'COMPLETED') {
throw new BadRequestException('There is no draft declaration to request a change on yet.');
}
// Once the slip is in, the money is paid — a dispute then is a refund
// conversation, not a re-advice.
if (byCode.get('DUTY_TAX_PAID')?.status === 'COMPLETED') {
if (byCode.get('DRAFT_DECLARATION_ACCEPTED')?.status === 'COMPLETED') {
throw new BadRequestException(
'The duty payment slip has already been submitted — contact GL Ethiopia directly.',
'The draft declaration has already been accepted — contact GL Ethiopia directly.',
);
}
await this.bookingsRepository.createReviewNote(
bookingId,
note.trim(),
'DUTY_DISPUTE',
'DRAFT_DECL_CHANGE_REQUEST',
userId,
);
// Back to GL: reopening the milestone is what re-arms the Duty & tax step
// (the stepper picks its active step from milestone completion).
await this.milestoneService.reopenForBooking(bookingId, 'DUTY_TAXES_ADVISED');
// Back to GL: reopening the milestone is what re-arms the step (the
// stepper picks its active step from milestone completion).
await this.milestoneService.reopenForBooking(bookingId, 'DRAFT_DECLARATION_UPLOADED');
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
const updated = await this.bookingsService.findById(bookingId);
this.notifier.dutyDisputed(updated, note.trim());
this.notifier.draftDeclarationChangeRequested(updated, note.trim());
return updated;
}
@@ -824,6 +936,10 @@ export class BookingClearanceService {
'RELEASE_ORDER_SECURED',
userId,
);
// 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);
return { booking: await this.bookingsService.findById(bookingId), hold: false };
}

View File

@@ -1,162 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import { BookingClearanceService } from './booking-clearance.service';
import type { Booking } from '../bookings/entities/booking.entity';
/**
* The duty advice → dispute → re-advice loop, at the booking level. GL
* Ethiopia advises an amount; the customer either pays it or sends it back
* with a reason. Sending it back reopens the advice milestone — that is what
* puts the Duty & tax step back in GL's hands — and the round can repeat
* until the amount is agreed.
*/
describe('BookingClearanceService — duty dispute', () => {
const booking = (over: Partial<Booking> = {}): Booking =>
({
id: 'bk-1',
reference: 'BKG-2026-00042',
tradeDirection: 'IMPORT',
customsClearingEnabled: true,
contractId: 'ctr-1',
dutyRequired: true,
...over,
}) as Booking;
const milestone = (code: string, status: string) =>
({ milestoneCode: code, status }) as never;
let repo: {
createReviewNote: jest.Mock;
findReviewNotes: jest.Mock;
update: jest.Mock;
};
let bookingsService: { findById: jest.Mock };
let workflowService: { listMilestonesForBooking: jest.Mock };
let milestoneService: { reopenForBooking: jest.Mock };
let notifier: { dutyDisputed: jest.Mock };
let service: BookingClearanceService;
const build = (milestones: unknown[]) => {
workflowService.listMilestonesForBooking.mockResolvedValue(milestones);
};
beforeEach(() => {
repo = {
createReviewNote: jest.fn().mockResolvedValue(undefined),
findReviewNotes: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(undefined),
};
bookingsService = { findById: jest.fn().mockResolvedValue(booking()) };
workflowService = { listMilestonesForBooking: jest.fn().mockResolvedValue([]) };
milestoneService = { reopenForBooking: jest.fn().mockResolvedValue(undefined) };
notifier = { dutyDisputed: jest.fn() };
service = new BookingClearanceService(
repo as never,
bookingsService as never,
{} as never, // filesService
{} as never, // fileUploadSettingsService
workflowService as never,
milestoneService as never,
{} as never, // dropdownSettingsService
{} as never, // glOperationsService
notifier as never,
);
build([
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
milestone('DUTY_TAX_PAID', 'PENDING'),
]);
});
it('records the objection and hands the step back to GL', async () => {
await service.disputeDuty('bk-1', ' Declared value is wrong ', 'user-1');
expect(repo.createReviewNote).toHaveBeenCalledWith(
'bk-1',
'Declared value is wrong',
'DUTY_DISPUTE',
'user-1',
);
// Reopening the advice milestone is what re-arms the Duty & tax step.
expect(milestoneService.reopenForBooking).toHaveBeenCalledWith(
'bk-1',
'DUTY_TAXES_ADVISED',
);
expect(repo.update).toHaveBeenCalledWith('bk-1', {
clearanceCurrentPhase: 'GL_ET_OUTPUT',
});
});
it('tells GL Ethiopia, not the customer', async () => {
await service.disputeDuty('bk-1', 'Too high', 'user-1');
expect(notifier.dutyDisputed).toHaveBeenCalledWith(
expect.objectContaining({ id: 'bk-1' }),
'Too high',
);
});
it('requires a reason — GL cannot correct an unexplained objection', async () => {
await expect(service.disputeDuty('bk-1', ' ')).rejects.toBeInstanceOf(
BadRequestException,
);
expect(milestoneService.reopenForBooking).not.toHaveBeenCalled();
});
it('refuses when nothing has been advised yet', async () => {
build([milestone('DUTY_TAXES_ADVISED', 'PENDING')]);
await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow(
/no advised duty amount/i,
);
});
it('refuses once the payment slip is in — that is a refund, not a re-advice', async () => {
build([
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
milestone('DUTY_TAX_PAID', 'COMPLETED'),
]);
await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow(
/already been submitted/i,
);
});
it('refuses when duty was never required for this clearance', async () => {
bookingsService.findById.mockResolvedValue(booking({ dutyRequired: false }));
await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow(
/not required/i,
);
});
describe('the view', () => {
const buildDispute = (milestones: unknown[]) =>
(
service as unknown as {
buildDutyDispute: (id: string, m: unknown[]) => Promise<unknown>;
}
).buildDutyDispute('bk-1', milestones);
it('shows the objection while GL still owes a corrected advice', async () => {
repo.findReviewNotes.mockResolvedValue([
{ note: 'Second look please', createdAt: new Date('2026-07-20T09:00:00Z') },
{ note: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
]);
const dispute = await buildDispute([
milestone('DUTY_TAXES_ADVISED', 'PENDING'),
]);
expect(dispute).toMatchObject({ note: 'Second look please', rounds: 2 });
});
it('clears itself once GL re-advises', async () => {
repo.findReviewNotes.mockResolvedValue([
{ note: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
]);
const dispute = await buildDispute([
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
]);
expect(dispute).toBeNull();
});
});
});

View File

@@ -18,6 +18,8 @@ const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
IMPORT_DOCS_UPLOADED: { label: 'Import Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false },
PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true },
DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false },
DRAFT_DECLARATION_UPLOADED: { label: 'Draft Declaration Sent', ownerRegion: 'ET', triggeredByDoc: true },
DRAFT_DECLARATION_ACCEPTED: { label: 'Draft Declaration Accepted', ownerRegion: 'CUST', triggeredByDoc: false },
UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false },
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false },

View File

@@ -353,10 +353,10 @@ export class ClearanceWorkflowService {
if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview;
if (tradeDirection === 'EXPORT') {
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
if (!isDone('RELEASE_ORDER_SECURED')) {
return ContractDocPhase.GlDjCollection;
}
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance;
return ContractDocPhase.GlEtPostClearance;
}
@@ -450,13 +450,6 @@ export class ClearanceWorkflowService {
: 'Proceed to request operation';
if (tradeDirection === 'EXPORT') {
if (!isDone('RELEASE_ORDER_SECURED')) {
return {
actor: 'GL_DJ',
action: 'Upload Release Order and vessel departure date',
milestoneCode: 'RELEASE_ORDER_SECURED',
};
}
if (!isDone('DECLARED')) {
return {
actor: 'GL_ET',
@@ -464,6 +457,13 @@ export class ClearanceWorkflowService {
milestoneCode: 'DECLARED',
};
}
if (!isDone('RELEASE_ORDER_SECURED')) {
return {
actor: 'GL_DJ',
action: 'Upload Release Order and vessel departure date',
milestoneCode: 'RELEASE_ORDER_SECURED',
};
}
if (!isDone(EXPORT_BOUNDARY)) {
return {
actor: 'GL_ET',

View File

@@ -7,6 +7,7 @@ import {
import {
ContractDocPhase,
type ClearanceFinalInvoiceSummary,
type ClearanceOffloadState,
type ClearanceSecondDuty,
type ClearanceT1State,
type ClearanceTrainState,
@@ -26,6 +27,7 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractNotifierService } from './contract-notifier.service';
import { GlOperationsService } from './gl-operations.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import {
ClearanceMilestone,
type RiskAssignmentRecord,
@@ -140,6 +142,8 @@ export interface ContractClearanceView {
t1Closed?: boolean;
t1ClosedAt?: string | null;
offloaded?: boolean;
/** Offload stats for the linked booking (null until one exists). */
offload?: ClearanceOffloadState | null;
/** GL Djibouti post-offload final invoice (export). */
finalInvoice?: ClearanceFinalInvoiceSummary | null;
/** Customs risk level assigned by GL ET (import; visible to the customer). */
@@ -165,6 +169,7 @@ export class ContractClearanceService {
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
private readonly notifier: ContractNotifierService,
private readonly transitAgentsService: TransitAgentsService,
) {}
private isPhasedCustoms(contract: Contract): boolean {
@@ -433,6 +438,9 @@ export class ContractClearanceService {
? t1ClosedMilestone.triggeredAt.toISOString()
: null,
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
offload: cycle?.bookingId
? await this.glOperationsService.offloadState(cycle.bookingId, bookingMilestones)
: null,
finalInvoice,
riskLevel:
riskMilestone?.status === 'COMPLETED'
@@ -1117,20 +1125,18 @@ export class ContractClearanceService {
}
/**
* GL Djibouti names the transit officer — free text, because the person is
* not a platform user. Answering unblocks the declaration for Ethiopia. A
* later call overwrites the name (reassignment) and re-notifies.
* GL Djibouti picks the transit officer from the admin-managed roster —
* rejected unless the agent is active and inside its validity window.
* Answering unblocks the declaration for Ethiopia. A later call overwrites
* the name (reassignment) and re-notifies.
*/
async assignTransitAssignee(
contractId: string,
assignee: string,
transitAgentId: string,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (!assignee?.trim()) {
throw new BadRequestException('Name the officer who will handle the transit.');
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle) throw new BadRequestException('No clearance cycle found');
if (!cycle.transitAssigneeRequestedAt) {
@@ -1138,16 +1144,17 @@ export class ContractClearanceService {
'GL Ethiopia has not requested a transit assignee for this clearance yet.',
);
}
const agent = await this.transitAgentsService.getAssignable(transitAgentId);
const previous = cycle.transitAssigneeName ?? null;
await this.contractsRepository.updateCycle(cycle.id, {
transitAssigneeName: assignee.trim(),
transitAssigneeName: agent.name,
transitAssigneeAssignedAt: new Date(),
transitAssigneeAssignedByUserId: userId ?? null,
});
const updated = await this.contractsService.findById(contractId);
this.notifier.transitAssigneeAssigned(updated, assignee.trim(), previous);
this.notifier.transitAssigneeAssigned(updated, agent.name, previous);
return updated;
}
@@ -1186,6 +1193,15 @@ export class ContractClearanceService {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
await this.ensureDeclarationPrerequisites(contractId, contract);
// The draft-declaration accept/change-request loop only exists on the
// booking-scoped clearance page (portal customers never see contract-scoped
// clearance) — skip it here so it can never block the ONE_TIME pre-booking
// flow, which has no UI to complete it. Contracts seeded before this step
// existed have no such row to skip — ignore, `assertPriorComplete` below
// already tolerates a missing milestone as "not required".
await this.workflowService
.skipMilestones(contractId, ['DRAFT_DECLARATION_UPLOADED', 'DRAFT_DECLARATION_ACCEPTED'])
.catch(() => undefined);
await this.workflowService.assertPriorComplete(
contractId,
contract.tradeDirection,
@@ -1215,12 +1231,6 @@ export class ContractClearanceService {
});
}
// Export: the declaration is the last GL ET pre-booking action — release
// immediately so booking creation unlocks without a separate confirm click.
if (contract.tradeDirection === 'EXPORT') {
await this.workflowService.onExportReleased(contractId, userId);
}
return this.contractsService.findById(contractId);
}
@@ -1561,6 +1571,10 @@ export class ContractClearanceService {
currentPhase: ContractDocPhase.GlEtOutput,
});
await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId);
// Release Order is now the last GL DJ pre-booking action (it follows the
// declaration) — release immediately so booking creation unlocks without a
// separate confirm click.
await this.workflowService.onExportReleased(contractId, userId);
return { contract: await this.contractsService.findById(contractId), hold: false };
}

View File

@@ -62,6 +62,7 @@ describe('ContractClearanceService — duty dispute', () => {
{} as never, // dropdownSettingsService
{} as never, // glOperationsService
notifier as never,
{} as never, // transitAgentsService
);
build([
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),

View File

@@ -837,16 +837,16 @@ export class ContractsController {
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary:
'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns',
'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns',
})
assignTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('assignee') assignee: string,
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.assignTransitAssignee(
id,
assignee,
transitAgentId,
resolveAuthUserId(user),
);
}
@@ -1294,6 +1294,20 @@ export class ContractsController {
);
}
@Post('bookings/:bookingId/final-invoice/approve')
@ApiOperation({
summary: 'Customer approves the drafted final invoice — unlocks the payment slip',
})
approveFinalInvoice(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.approveFinalInvoice(
bookingId,
resolveAuthUserId(user),
);
}
@Post('bookings/:bookingId/final-invoice-slip')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')

View File

@@ -17,6 +17,7 @@ import { NotificationInboxModule } from '../notification-inbox/notification-inbo
import { BookingsModule } from '../bookings/bookings.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { ContractTemplatesModule } from '../contract-templates/contract-templates.module';
import { TransitAgentsModule } from '../transit-agents/transit-agents.module';
import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
@@ -31,6 +32,8 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ContractBookingService } from './contract-booking.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { GlExchangeController } from './gl-exchange.controller';
import { GlExchangeService } from './gl-exchange.service';
import { BookingRequestService } from './booking-request.service';
import { BookingRequestRepository } from './booking-request.repository';
@@ -89,6 +92,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
// Provides the admin-editable contract document templates consumed by
// ContractDocumentViewModelBuilder when rendering contract PDFs.
ContractTemplatesModule,
TransitAgentsModule,
// BookingsModule provides BookingsRepository/BookingPricingService used by the
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
forwardRef(() => BookingsModule),
@@ -102,7 +106,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
}),
],
controllers: [ContractsController],
controllers: [ContractsController, GlExchangeController],
providers: [
ContractsService,
ContractsRepository,
@@ -117,6 +121,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractBookingService,
ClearanceMilestoneService,
GlOperationsService,
GlExchangeService,
BookingRequestService,
BookingRequestRepository,
// Contract PDF providers (template resolution + render + PDF) — stateless
@@ -136,6 +141,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
BookingClearanceService,
ContractBookingService,
ClearanceMilestoneService,
GlExchangeService,
],
})
export class ContractsModule {}

View File

@@ -46,6 +46,9 @@ export interface MilestoneMetadata {
declarationSerial?: string;
/** When the gate pass was physically granted (GL DJ captures the time). */
gatepassAt?: string;
/** DRAFT_DECLARATION_UPLOADED → the estimated price GL sent the customer. */
draftDeclarationPrice?: number;
draftDeclarationCurrency?: string;
}
/**

View File

@@ -0,0 +1,102 @@
import { BadRequestException } from '@nestjs/common';
import { Freight } from '@edr/types';
import { GlOperationsService } from './gl-operations.service';
import { Booking } from '../bookings/entities/booking.entity';
/**
* The GL Djibouti final invoice lands as a DRAFT: the customer must approve it
* (which issues it) before a payment slip is accepted.
*/
describe('GlOperationsService — final invoice approval', () => {
const invoice = (status: Freight.InvoiceStatus, issuedAt: Date | null = null) => ({
id: 'inv-1',
invoiceNumber: 'INV-1',
status,
totalAmount: 1500,
currency: 'ETB',
issuedAt,
paidAt: null,
});
let billingService: { findInvoice: jest.Mock; updateStatus: jest.Mock };
let filesService: { findByResource: jest.Mock; upsertByCode: jest.Mock };
let notifier: { finalInvoiceApprovedToStaff: jest.Mock; dutySlipUploadedToStaff: jest.Mock };
let service: GlOperationsService;
beforeEach(() => {
billingService = {
findInvoice: jest.fn(),
updateStatus: jest.fn().mockResolvedValue(undefined),
};
filesService = {
findByResource: jest.fn().mockResolvedValue([]),
upsertByCode: jest.fn().mockResolvedValue(undefined),
};
notifier = {
finalInvoiceApprovedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
};
const dataSource = {
getRepository: (entity: unknown) =>
entity === Booking
? { findOne: jest.fn().mockResolvedValue({ id: 'bk-1', reference: 'BKG-1' }) }
: { findOne: jest.fn().mockResolvedValue({ description: 'Post-offload charges' }) },
};
service = new GlOperationsService(
dataSource as never,
filesService as never,
{} as never, // milestoneService
billingService as never,
notifier as never,
);
});
it('issues the draft on customer approval and reports approvedAt', async () => {
const issued = new Date('2026-07-28T09:00:00.000Z');
billingService.findInvoice
.mockResolvedValueOnce(invoice(Freight.InvoiceStatus.Draft))
.mockResolvedValueOnce(invoice(Freight.InvoiceStatus.Issued, issued));
const summary = await service.approveFinalInvoice('bk-1', 'user-1');
expect(billingService.updateStatus).toHaveBeenCalledWith(
'inv-1',
Freight.InvoiceStatus.Issued,
);
expect(notifier.finalInvoiceApprovedToStaff).toHaveBeenCalled();
expect(summary.approvedAt).toBe(issued.toISOString());
});
it('is a no-op when the invoice was already approved', async () => {
billingService.findInvoice.mockResolvedValue(
invoice(Freight.InvoiceStatus.Issued, new Date()),
);
await service.approveFinalInvoice('bk-1');
expect(billingService.updateStatus).not.toHaveBeenCalled();
});
it('refuses a payment slip while the invoice is still a draft', async () => {
billingService.findInvoice.mockResolvedValue(invoice(Freight.InvoiceStatus.Draft));
await expect(
service.uploadFinalInvoiceSlip('bk-1', { originalname: 'slip.pdf' } as never),
).rejects.toThrow(BadRequestException);
expect(filesService.upsertByCode).not.toHaveBeenCalled();
});
it('accepts the payment slip once approved', async () => {
billingService.findInvoice.mockResolvedValue(
invoice(Freight.InvoiceStatus.Issued, new Date()),
);
await service.uploadFinalInvoiceSlip('bk-1', { originalname: 'slip.pdf' } as never);
expect(filesService.upsertByCode).toHaveBeenCalledWith(
expect.objectContaining({ code: 'final_invoice_slip' }),
);
});
});

View File

@@ -0,0 +1,133 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { actorLabel } from '../warehouses/current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { hasFreightPermission } from '../../common/freight-permission.util';
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
import {
GlExchangeService,
type GlExchangeActor,
type GlExchangeSide,
} from './gl-exchange.service';
/** Either GL desk may read and post; ownership decides who may edit. */
const GL_EXCHANGE_PERMS = [
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
];
/** Multipart bodies arrive as strings — "true"/"1" mean checked. */
const asBool = (raw: string | boolean | undefined): boolean =>
raw === true || raw === 'true' || raw === '1';
@ApiTags('gl-exchange')
@ApiBearerAuth()
@Controller('gl-exchange')
export class GlExchangeController {
constructor(private readonly exchangeService: GlExchangeService) {}
@Get(':entityId')
@BookingStaff(GL_EXCHANGE_PERMS)
@ApiOperation({
summary: 'GL ET ↔ GL DJ shared documents for a booking or contract',
})
list(
@Param('entityId', ParseUUIDPipe) entityId: string,
@CurrentUser() user: TCurrentUser,
) {
return this.exchangeService.list(entityId, resolveAuthUserId(user));
}
@Post(':entityId')
@BookingStaff(GL_EXCHANGE_PERMS)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Share a document with the other GL desk' })
upload(
@Param('entityId', ParseUUIDPipe) entityId: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Body('title') title: string,
@Body('visibleToCustomer') visibleToCustomer: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.exchangeService.upload(
entityId,
file,
{ title, visibleToCustomer: asBool(visibleToCustomer) },
this.actor(user),
);
}
@Patch('documents/:documentId')
@BookingStaff(GL_EXCHANGE_PERMS)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'Uploader edits a shared document (title, visibility, file)',
})
update(
@Param('documentId', ParseUUIDPipe) documentId: string,
@UploadedFile() file: Express.Multer.File | undefined,
@Body('title') title: string | undefined,
@Body('visibleToCustomer') visibleToCustomer: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.exchangeService.update(
documentId,
{
title,
visibleToCustomer:
visibleToCustomer == null ? undefined : asBool(visibleToCustomer),
},
file,
resolveAuthUserId(user),
);
}
@Delete('documents/:documentId')
@BookingStaff(GL_EXCHANGE_PERMS)
@HttpCode(204)
@ApiOperation({ summary: 'Uploader removes a shared document' })
async remove(
@Param('documentId', ParseUUIDPipe) documentId: string,
@CurrentUser() user: TCurrentUser,
) {
await this.exchangeService.remove(documentId, resolveAuthUserId(user));
}
/**
* Which desk is posting. A user holding only the Djibouti actions permission
* is Djibouti; everyone else (GL Ethiopia, and super admins who hold both)
* posts as Ethiopia.
*/
private actor(user: TCurrentUser): GlExchangeActor {
const side: GlExchangeSide =
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) &&
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
? 'DJ'
: 'ET';
return {
userId: resolveAuthUserId(user),
name: actorLabel(user) ?? null,
side,
};
}
}

View File

@@ -0,0 +1,198 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { Freight } from '@edr/types';
import { FilesService } from '../files/files.service';
import type { FileRecord } from '../files/entities/file.entity';
/**
* `files.resource` of the GL Ethiopia ↔ GL Djibouti document exchange. The
* thread is keyed by the entity the two desks are working on — a booking id on
* the per-booking clearance pages, a contract id on the pre-booking ones — so
* both desks opening the same record see the same documents.
*/
export const GL_EXCHANGE_RESOURCE = 'gl_exchange';
export type GlExchangeSide = 'ET' | 'DJ';
export interface GlExchangeActor {
userId: string;
name?: string | null;
side: GlExchangeSide;
}
export interface GlExchangeUploadInput {
title: string;
visibleToCustomer: boolean;
}
/**
* Free-form document exchange between the two Global Logistics desks. Anything
* either side needs the other to have (scans, correspondence, corrected forms)
* lands here under a title they choose, instead of a fixed clearance slot.
*
* Rules, all enforced here rather than in the UI:
* - both desks read every document in a thread, whoever uploaded it;
* - only the uploader may retitle, replace or remove one;
* - the customer sees only what its uploader marked visible.
*/
@Injectable()
export class GlExchangeService {
constructor(private readonly filesService: FilesService) {}
/** Every document on one thread, newest first, from a GL desk's view. */
async list(
entityId: string,
viewerId: string,
): Promise<Freight.GlExchangeDocument[]> {
const records = await this.filesService.findByResource(
entityId,
GL_EXCHANGE_RESOURCE,
);
return this.sort(records.map((r) => this.toDto(r, viewerId)));
}
/**
* The customer-facing slice across several threads (a booking and the
* contract it belongs to). Never exposes internal documents, and never marks
* anything editable — the customer is not a GL desk.
*/
async listVisibleToCustomer(
entityIds: string[],
): Promise<Freight.GlExchangeDocument[]> {
const ids = [...new Set(entityIds.filter(Boolean))];
if (ids.length === 0) return [];
const grouped = await this.filesService.findByResourceIdsGrouped(
ids,
GL_EXCHANGE_RESOURCE,
);
const visible = [...grouped.values()]
.flat()
.filter((r) => r.visibleToCustomer);
return this.sort(visible.map((r) => this.toDto(r, null)));
}
async upload(
entityId: string,
file: Express.Multer.File | undefined,
input: GlExchangeUploadInput,
actor: GlExchangeActor,
): Promise<Freight.GlExchangeDocument> {
const title = input.title?.trim();
if (!title) throw new BadRequestException('A document title is required.');
if (!file) throw new BadRequestException('A file is required.');
const record = await this.filesService.upload({
resourceId: entityId,
resource: GL_EXCHANGE_RESOURCE,
// No fixed slot exists for these — `code` carries the uploading desk, so
// a document's origin survives even if the uploader leaves the org.
code: actor.side,
file,
title,
visibleToCustomer: input.visibleToCustomer,
uploadedByUserId: actor.userId,
uploadedByName: actor.name ?? null,
});
return this.toDto(record, actor.userId);
}
/**
* Retitle, re-share or replace a document. Uploader only — the other desk
* reads it but never edits it. A replacement file supersedes the old record
* (soft-deleted, bytes kept) and carries its metadata forward.
*/
async update(
documentId: string,
patch: { title?: string; visibleToCustomer?: boolean },
file: Express.Multer.File | undefined,
actorId: string,
): Promise<Freight.GlExchangeDocument> {
const record = await this.assertUploader(documentId, actorId);
const title = patch.title?.trim();
if (patch.title != null && !title) {
throw new BadRequestException('A document title is required.');
}
if (file) {
const replacement = await this.filesService.upload({
resourceId: record.resourceId,
resource: GL_EXCHANGE_RESOURCE,
code: record.code,
file,
title: title ?? record.title,
visibleToCustomer: patch.visibleToCustomer ?? record.visibleToCustomer,
uploadedByUserId: record.uploadedByUserId,
uploadedByName: record.uploadedByName,
});
await this.filesService.remove(record.id);
return this.toDto(replacement, actorId);
}
const updated = await this.filesService.updateMeta(record.id, {
...(title ? { title } : {}),
...(patch.visibleToCustomer != null
? { visibleToCustomer: patch.visibleToCustomer }
: {}),
});
return this.toDto(updated, actorId);
}
/** Uploader-only removal (soft delete — the stored bytes are kept). */
async remove(documentId: string, actorId: string): Promise<void> {
const record = await this.assertUploader(documentId, actorId);
await this.filesService.remove(record.id);
}
private async assertUploader(
documentId: string,
actorId: string,
): Promise<FileRecord> {
const record = await this.filesService.findById(documentId);
if (record.resource !== GL_EXCHANGE_RESOURCE) {
throw new NotFoundException(`Exchange document ${documentId} not found`);
}
if (record.uploadedByUserId !== actorId) {
throw new ForbiddenException(
'Only the person who uploaded this document can change it.',
);
}
return record;
}
private sort(
docs: Freight.GlExchangeDocument[],
): Freight.GlExchangeDocument[] {
return docs.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt));
}
private toDto(
record: FileRecord,
viewerId: string | null,
): Freight.GlExchangeDocument {
return {
id: record.id,
entityId: record.resourceId,
// Pre-title rows (none in practice) fall back to the filename so a list
// never renders a blank row.
title: record.title ?? record.name,
side: record.code === 'DJ' ? 'DJ' : 'ET',
visibleToCustomer: record.visibleToCustomer,
uploadedById: record.uploadedByUserId,
uploadedByName: record.uploadedByName,
uploadedAt: record.createdAt.toISOString(),
file: {
id: record.id,
name: record.name,
url: record.url,
size: record.size,
mimeType: record.mimeType,
},
canEdit: viewerId != null && record.uploadedByUserId === viewerId,
};
}
}

View File

@@ -282,6 +282,85 @@ export class GlOperationsService {
};
}
/**
* Offload facts for a booking, read-only: what came off the train at its
* destination (containers, wagons, tonnes) and where the goods went. Sourced
* from the booking's warehouse-inventory row — written by the auto-unload
* that runs on train arrival for both directions.
*/
async offloadState(
bookingId: string,
milestones: Array<{ milestoneCode: string; status: string; triggeredAt?: Date | null }>,
): Promise<Freight.ClearanceOffloadState> {
const [row]: Array<{
destination: string | null;
containers: number;
wagons: number;
bookedWeight: string | null;
inventoryStatus: string | null;
unloadedAt: Date | null;
grnNumber: string | null;
offloadedWeight: string | null;
warehouse: string | null;
warehouseYard: string | null;
zone: string | null;
}> = await this.dataSource.query(
`SELECT COALESCE(dy.label, dy.code) AS "destination",
(SELECT COUNT(*)::int
FROM freight.booking_container bc
JOIN freight.booking_container_units bcu
ON bcu.booking_container_id = bc.id AND bcu.deleted_at IS NULL
WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL) AS "containers",
(SELECT COUNT(*)::int
FROM freight.wagon_booking_allocations wba
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL) AS "wagons",
b.cargo_total_weight_vgm AS "bookedWeight",
inv.status AS "inventoryStatus",
inv.unloaded_at AS "unloadedAt",
inv.grn_number AS "grnNumber",
inv.weight AS "offloadedWeight",
wh.name AS "warehouse",
wy.name AS "warehouseYard",
wz.name AS "zone"
FROM freight.bookings b
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN LATERAL (
SELECT i.*
FROM freight.warehouse_inventory i
WHERE i.booking_id = b.id AND i.deleted_at IS NULL
ORDER BY i.unloaded_at DESC NULLS LAST, i.created_at DESC
LIMIT 1
) inv ON TRUE
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards wy ON wy.id = inv.yard_id
LEFT JOIN freight.warehouse_zones wz ON wz.id = inv.zone_id
WHERE b.id = $1 AND b.deleted_at IS NULL`,
[bookingId],
);
const milestone = milestones.find((m) => m.milestoneCode === 'OFFLOADED');
const offloadedAt =
milestone?.status === 'COMPLETED' && milestone.triggeredAt
? new Date(milestone.triggeredAt).toISOString()
: (row?.unloadedAt ? new Date(row.unloadedAt).toISOString() : null);
// The warehouse records the real offloaded tonnage; before it does, the
// booked VGM is the best number we have.
const weight = Number(row?.offloadedWeight ?? 0) || Number(row?.bookedWeight ?? 0);
const location = [row?.warehouse, row?.warehouseYard, row?.zone].filter(Boolean).join(' ');
return {
offloaded: milestone?.status === 'COMPLETED' || Boolean(row?.unloadedAt),
offloadedAt,
destination: row?.destination ?? null,
containers: row?.containers ?? 0,
wagons: row?.wagons ?? 0,
weightTons: weight || null,
grnNumber: row?.grnNumber ?? null,
location: location || null,
inventoryStatus: row?.inventoryStatus ?? null,
};
}
/**
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
* is secured on the train schedule (which itself follows wagon allocation).
@@ -381,8 +460,9 @@ export class GlOperationsService {
/**
* GL Djibouti raises the post-offload final invoice (export): manual amount +
* attached invoice document. The customer pays offline and attaches a slip;
* GL (ET or DJ) then confirms to settle it.
* attached invoice document. It is issued as a DRAFT the customer must approve
* first; only then do they pay offline and attach a slip, and GL (ET or DJ)
* confirms to settle it.
*/
async createFinalInvoice(
bookingId: string,
@@ -445,7 +525,8 @@ export class GlOperationsService {
amount: input.amount,
},
],
status: Freight.InvoiceStatus.Issued,
// DRAFT until the customer approves it — approveFinalInvoice issues it.
status: Freight.InvoiceStatus.Draft,
});
await this.filesService.upsertByCode({
@@ -467,6 +548,40 @@ export class GlOperationsService {
return summary;
}
/**
* Customer approves the drafted final invoice — issues it, which is what
* unlocks the payment slip upload. Idempotent: approving twice is a no-op.
*/
async approveFinalInvoice(
bookingId: string,
userId?: string,
): Promise<Freight.ClearanceFinalInvoiceSummary> {
const booking = await this.getBooking(bookingId);
const invoice = await this.billingService.findInvoice(
Freight.InvoiceSource.Booking,
bookingId,
GL_FINAL_INVOICE_TYPE,
);
if (!invoice) {
throw new BadRequestException('No final invoice has been raised for this shipment.');
}
if (
invoice.status === Freight.InvoiceStatus.Cancelled ||
invoice.status === Freight.InvoiceStatus.Expired
) {
throw new BadRequestException('The final invoice is no longer payable.');
}
if (invoice.status === Freight.InvoiceStatus.Draft) {
await this.billingService.updateStatus(invoice.id, Freight.InvoiceStatus.Issued);
this.notifier.finalInvoiceApprovedToStaff(booking);
}
void userId;
const summary = await this.finalInvoiceSummary(bookingId);
if (!summary) throw new NotFoundException('Final invoice not found.');
return summary;
}
/** Customer attaches the payment slip for the final invoice. */
async uploadFinalInvoiceSlip(
bookingId: string,
@@ -483,6 +598,11 @@ export class GlOperationsService {
if (!invoice) {
throw new BadRequestException('No final invoice has been issued for this shipment.');
}
if (invoice.status === Freight.InvoiceStatus.Draft) {
throw new BadRequestException(
'Approve the final invoice before attaching a payment slip.',
);
}
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException('The final invoice is already paid.');
}
@@ -688,6 +808,8 @@ export class GlOperationsService {
description: line?.description ?? null,
invoiceFile: toRef('final_invoice'),
slipFile: toRef('final_invoice_slip'),
// Issuing IS the customer approval (createFinalInvoice leaves it DRAFT).
approvedAt: invoice.issuedAt ? new Date(invoice.issuedAt).toISOString() : null,
confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null,
};
}

View File

@@ -2,7 +2,9 @@ import { BadRequestException } from '@nestjs/common';
import {
catalogEntriesForTradeDirection,
declarationFileLabel,
draftDeclarationFileLabel,
isDeclarationFileCode,
isDraftDeclarationFileCode,
isImportTransitPermitFileCode,
isExportTransportFileCode,
isT1TransportFileCode,
@@ -72,6 +74,52 @@ export async function persistDeclarationUploads(
);
}
/** Require at least one draft declaration file in the upload batch. */
export function assertDraftDeclarationFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
throw new BadRequestException('No draft declaration documents uploaded');
}
}
/** Assign stable `draft_declaration_*` codes so multi-file uploads always pass validation. */
export function normalizeDraftDeclarationFieldNames(
files: Express.Multer.File[],
): Express.Multer.File[] {
return files.map((file, index) => ({
...file,
fieldname: `draft_declaration_${index}`,
}));
}
/** Replace all draft declaration files on a resource with a new multi-file upload batch. */
export async function persistDraftDeclarationUploads(
store: DeclarationFileStore,
resourceId: string,
resource: string,
files: Express.Multer.File[],
): Promise<void> {
const normalized = normalizeDraftDeclarationFieldNames(files);
assertDraftDeclarationFiles(normalized);
const existing = await store.findByResource(resourceId, resource);
await Promise.all(
existing
.filter((f) => f.code && isDraftDeclarationFileCode(f.code))
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
);
await Promise.all(
normalized.map((file, index) =>
store.upload({
resourceId,
resource,
code: `draft_declaration_${index}`,
file,
}),
),
);
}
/** Require at least one transit permit file in the upload batch. */
export function assertTransitPermitFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
@@ -341,6 +389,22 @@ export function buildWorkflowFiles(
});
});
const extraDraftDeclarations = files
.filter((f) => f.code && isDraftDeclarationFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
extraDraftDeclarations.forEach((file, index) => {
if (!file.code) return;
included.add(file.code);
out.push({
code: file.code,
label: draftDeclarationFileLabel(index),
uploadedBy: 'gl_et',
category: 'draft_declaration',
file: { id: file.id, name: file.name, url: file.url },
});
});
if (tradeDirection === 'IMPORT') {
const extraTransit = files
.filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code))

View File

@@ -26,6 +26,7 @@ describe('ContractClearanceService — transit assignee', () => {
transitAssigneeRequested: jest.Mock;
transitAssigneeAssigned: jest.Mock;
};
let transitAgentsService: { getAssignable: jest.Mock };
let service: ContractClearanceService;
const cycle = (over: Record<string, unknown> = {}) => ({
@@ -45,6 +46,9 @@ describe('ContractClearanceService — transit assignee', () => {
transitAssigneeRequested: jest.fn(),
transitAssigneeAssigned: jest.fn(),
};
transitAgentsService = {
getAssignable: jest.fn().mockResolvedValue({ id: 'agent-1', name: 'Ahmed Bourhan' }),
};
service = new ContractClearanceService(
repo as never,
contractsService as never,
@@ -56,6 +60,7 @@ describe('ContractClearanceService — transit assignee', () => {
{} as never,
{} as never,
notifier as never,
transitAgentsService as never,
);
});
@@ -79,8 +84,9 @@ describe('ContractClearanceService — transit assignee', () => {
cycle({ transitAssigneeRequestedAt: new Date() }),
);
await service.assignTransitAssignee('ctr-1', ' Ahmed Bourhan ', 'dj-1');
await service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1');
expect(transitAgentsService.getAssignable).toHaveBeenCalledWith('agent-1');
const patch = repo.updateCycle.mock.calls[0][1];
expect(patch.transitAssigneeName).toBe('Ahmed Bourhan');
expect(patch.transitAssigneeAssignedByUserId).toBe('dj-1');
@@ -98,8 +104,12 @@ describe('ContractClearanceService — transit assignee', () => {
transitAssigneeName: 'Ahmed Bourhan',
}),
);
transitAgentsService.getAssignable.mockResolvedValue({
id: 'agent-2',
name: 'Fatouma Ali',
});
await service.assignTransitAssignee('ctr-1', 'Fatouma Ali', 'dj-1');
await service.assignTransitAssignee('ctr-1', 'agent-2', 'dj-1');
expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith(
expect.anything(),
@@ -108,19 +118,23 @@ describe('ContractClearanceService — transit assignee', () => {
);
});
it('refuses an empty name', async () => {
it('refuses a suspended or out-of-window agent', async () => {
repo.currentCycle.mockResolvedValue(
cycle({ transitAssigneeRequestedAt: new Date() }),
);
transitAgentsService.getAssignable.mockRejectedValue(
new BadRequestException('suspended'),
);
await expect(
service.assignTransitAssignee('ctr-1', ' ', 'dj-1'),
service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('refuses before Ethiopia has asked', async () => {
await expect(
service.assignTransitAssignee('ctr-1', 'Ahmed Bourhan', 'dj-1'),
service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'),
).rejects.toThrow(/not requested/i);
expect(transitAgentsService.getAssignable).not.toHaveBeenCalled();
});
});

View File

@@ -65,4 +65,29 @@ export class FileRecord extends BaseEntity {
/** Why the file was replaced — shown on the document's version history. */
@Column({ name: "replace_reason", type: "text", nullable: true })
replaceReason!: string | null;
/**
* Free-text label chosen by the uploader, when the document has no fixed slot
* (`code`) to name it — the GL Ethiopia ↔ GL Djibouti exchange. Null for every
* catalog-driven upload, whose label comes from its code.
*/
@Column({ name: "title", type: "varchar", length: 300, nullable: true })
title!: string | null;
/** Uploader's choice to share the document with the customer's portal. */
@Column({ name: "visible_to_customer", type: "boolean", default: false })
visibleToCustomer!: boolean;
/** Who uploaded it — the only user allowed to edit or remove it afterwards. */
@Column({ name: "uploaded_by_user_id", type: "uuid", nullable: true })
uploadedByUserId!: string | null;
/** Uploader's display name, resolved once so lists need no IAM lookup. */
@Column({
name: "uploaded_by_name",
type: "varchar",
length: 200,
nullable: true,
})
uploadedByName!: string | null;
}

View File

@@ -15,6 +15,11 @@ export interface CreateFileInput {
resource: string;
code: string;
file: Express.Multer.File;
/** Optional metadata for free-form uploads (GL exchange) — see FileRecord. */
title?: string | null;
visibleToCustomer?: boolean;
uploadedByUserId?: string | null;
uploadedByName?: string | null;
}
/**
@@ -101,9 +106,27 @@ export class FilesService {
url,
size: file.size,
mimeType: file.mimetype,
title: input.title ?? null,
visibleToCustomer: input.visibleToCustomer ?? false,
uploadedByUserId: input.uploadedByUserId ?? null,
uploadedByName: input.uploadedByName ?? null,
});
}
/**
* Edit the uploader-authored metadata of a stored file (title, customer
* visibility). Bytes are untouched — callers replacing content upload a new
* record instead.
*/
async updateMeta(
id: string,
patch: { title?: string; visibleToCustomer?: boolean },
): Promise<FileRecord> {
const updated = await this.filesRepository.update(id, patch);
if (!updated) throw new NotFoundException(`File ${id} not found`);
return updated;
}
/**
* Replace the file stored under a resource + code (e.g. contract PDF). The
* previous version is retired, not destroyed — pass `replacedBy` to record who

View File

@@ -0,0 +1,31 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsDateString, IsOptional, IsString, MaxLength } from 'class-validator';
const toBoolean = ({ value }: { value: unknown }) => {
if (typeof value === 'boolean') return value;
if (value === 'true') return true;
if (value === 'false') return false;
return value;
};
export class CreateTransitAgentDto {
@ApiProperty({ maxLength: 150, example: 'Ahmed Bourhan' })
@IsString()
@MaxLength(150)
name!: string;
@ApiProperty({ example: '2026-01-01' })
@IsDateString()
validFrom!: string;
@ApiProperty({ example: '2026-12-31' })
@IsDateString()
validTo!: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTransitAgentDto } from './create-transit-agent.dto';
export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {}

View File

@@ -0,0 +1,24 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
/**
* Djibouti transit officer GL Djibouti may assign against a shipment's
* transit-assignee handshake. Admin-managed so the roster and each officer's
* validity window arrive without a code change; `isActive` is the manual
* suspend/reactivate switch, independent of the validity window.
*/
@Entity({ schema: 'freight', name: 'transit_agents' })
@Index(['isActive'])
export class TransitAgent extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 150 })
name!: string;
@Column({ name: 'valid_from', type: 'date' })
validFrom!: string;
@Column({ name: 'valid_to', type: 'date' })
validTo!: string;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,87 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
RuleEngineCreate,
RuleEngineDelete,
RuleEngineUpdate,
RuleEngineView,
} from '../../common/rule-engine-guards';
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
import { TransitAgentsService } from './transit-agents.service';
@ApiTags('transit-agents')
@Controller('transit-agents')
@ApiBearerAuth()
export class TransitAgentsController {
constructor(private readonly transitAgentsService: TransitAgentsService) {}
@Get()
@RuleEngineView('transit-agents')
@ApiOperation({ summary: 'List transit agents' })
findAll(@Query() query: Record<string, string | undefined>) {
return this.transitAgentsService.findAll({
isActive:
query.isActive === 'all'
? undefined
: query.isActive !== undefined
? query.isActive === 'true'
: undefined,
page: query.page ? parseInt(query.page, 10) : undefined,
pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
sortBy: query.sortBy,
sortOrder: query.sortOrder,
});
}
/** Active + currently valid officers — the transit-assignee assignment dropdown. */
@Get('assignable')
@RuleEngineView('transit-agents')
@ApiOperation({ summary: 'List transit agents assignable right now (active and in-window)' })
findAssignable() {
return this.transitAgentsService.findAssignable();
}
@Get(':id')
@RuleEngineView('transit-agents')
@ApiOperation({ summary: 'Get a transit agent by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.transitAgentsService.findById(id);
}
@Post()
@RuleEngineCreate('transit-agents')
@ApiOperation({ summary: 'Create a transit agent' })
create(@Body() dto: CreateTransitAgentDto) {
return this.transitAgentsService.create(dto);
}
@Patch(':id')
@RuleEngineUpdate('transit-agents')
@ApiOperation({ summary: 'Update a transit agent' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAgentDto) {
return this.transitAgentsService.update(id, dto);
}
@Delete(':id')
@RuleEngineDelete('transit-agents')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a transit agent' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.transitAgentsService.remove(id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TransitAgent } from './entities/transit-agent.entity';
import { TransitAgentsController } from './transit-agents.controller';
import { TransitAgentsRepository } from './transit-agents.repository';
import { TransitAgentsService } from './transit-agents.service';
@Module({
imports: [TypeOrmModule.forFeature([TransitAgent])],
controllers: [TransitAgentsController],
providers: [TransitAgentsRepository, TransitAgentsService],
exports: [TransitAgentsRepository, TransitAgentsService],
})
export class TransitAgentsModule {}

View File

@@ -0,0 +1,28 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import { TransitAgent } from './entities/transit-agent.entity';
@Injectable()
export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
constructor(
@InjectRepository(TransitAgent)
repository: Repository<TransitAgent>,
) {
super(repository);
}
/** Active AND currently inside its validity window (today's date, server-side). */
findAssignable(today: string): Promise<TransitAgent[]> {
return this.repository.find({
where: {
isActive: true,
validFrom: LessThanOrEqual(today),
validTo: MoreThanOrEqual(today),
},
order: { name: 'ASC' },
});
}
}

View File

@@ -0,0 +1,138 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsOrder } from 'typeorm';
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
import { TransitAgent } from './entities/transit-agent.entity';
import { TransitAgentsRepository } from './transit-agents.repository';
export type TransitAgentValidityStatus = 'VALID' | 'NOT_STARTED' | 'EXPIRED';
export type TransitAgentView = TransitAgent & {
validityStatus: TransitAgentValidityStatus;
};
type TransitAgentListFilter = {
isActive?: boolean;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: string;
};
/** Today as `yyyy-MM-dd`, matching the `date`-typed validity columns. */
function todayISODate(): string {
return new Date().toISOString().slice(0, 10);
}
function validityStatus(agent: Pick<TransitAgent, 'validFrom' | 'validTo'>): TransitAgentValidityStatus {
const today = todayISODate();
if (today < agent.validFrom) return 'NOT_STARTED';
if (today > agent.validTo) return 'EXPIRED';
return 'VALID';
}
function withValidityStatus(agent: TransitAgent): TransitAgentView {
return { ...agent, validityStatus: validityStatus(agent) };
}
@Injectable()
export class TransitAgentsService {
constructor(private readonly transitAgentsRepository: TransitAgentsRepository) {}
async findAll(filter: TransitAgentListFilter = {}): Promise<{
data: TransitAgentView[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 500;
const sortBy = ['name', 'validFrom', 'validTo', 'isActive'].includes(filter.sortBy ?? '')
? (filter.sortBy as keyof TransitAgent)
: 'name';
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
const [data, total] = await this.transitAgentsRepository.findAndCount({
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
order: { [sortBy]: sortOrder } as FindOptionsOrder<TransitAgent>,
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
data: data.map(withValidityStatus),
meta: {
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
};
}
/** Active and currently inside its validity window — the DJ assignment dropdown. */
async findAssignable(): Promise<TransitAgent[]> {
return this.transitAgentsRepository.findAssignable(todayISODate());
}
async findById(id: string): Promise<TransitAgentView> {
const agent = await this.transitAgentsRepository.findById(id);
if (!agent) {
throw new NotFoundException(`Transit agent ${id} not found`);
}
return withValidityStatus(agent);
}
/** Used by the assignment flow — rejects a suspended or out-of-window officer. */
async getAssignable(id: string): Promise<TransitAgent> {
const agent = await this.transitAgentsRepository.findById(id);
if (!agent) {
throw new BadRequestException('Selected transit officer was not found.');
}
if (!agent.isActive) {
throw new BadRequestException(`${agent.name} is suspended — pick another transit officer.`);
}
if (validityStatus(agent) !== 'VALID') {
throw new BadRequestException(
`${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`,
);
}
return agent;
}
async create(dto: CreateTransitAgentDto): Promise<TransitAgentView> {
if (dto.validTo < dto.validFrom) {
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
}
const agent = await this.transitAgentsRepository.create({
name: dto.name.trim(),
validFrom: dto.validFrom,
validTo: dto.validTo,
isActive: dto.isActive ?? true,
});
return withValidityStatus(agent);
}
async update(id: string, dto: UpdateTransitAgentDto): Promise<TransitAgentView> {
const current = await this.findById(id);
const nextValidFrom = dto.validFrom ?? current.validFrom;
const nextValidTo = dto.validTo ?? current.validTo;
if (nextValidTo < nextValidFrom) {
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
}
const updated = await this.transitAgentsRepository.update(id, {
...dto,
...(dto.name ? { name: dto.name.trim() } : {}),
});
if (!updated) {
throw new NotFoundException(`Transit agent ${id} not found`);
}
return withValidityStatus(updated);
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.transitAgentsRepository.softDelete(id);
}
}

View File

@@ -22,6 +22,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
// Keep new slugs at the END: ruleEngineCrudId derives ids from list index,
// so a mid-list insert would shift ids already seeded for later slugs.
'truck-types',
'transit-agents',
] as const;
export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number];
@@ -123,6 +124,7 @@ const RULE_ENGINE_VIEW_IDS: Record<RuleEngineResourceSlug, string> = {
rates: 'b2000001-0001-4000-8000-000000000011',
'approval-rules': 'b2000001-0001-4000-8000-000000000013',
'yard-distances': 'b2000001-0001-4000-8000-000000000018',
'transit-agents': 'b2000001-0001-4000-8000-00000000001b',
};
// CRUD replaces the retired coarse `:manage`. New ids live in a fresh block

View File

@@ -1,12 +1,15 @@
import type { ReactNode } from "react";
import { Badge, Stack, Tabs, Text } from "@mantine/core";
import { AlertTriangle, FileText, ShieldAlert } from "lucide-react";
import { AlertTriangle, FileText, Share2, ShieldAlert } from "lucide-react";
import type { Freight } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard";
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
export interface ClearanceOpsTabsProps {
bookingId: string | undefined;
@@ -17,6 +20,12 @@ export interface ClearanceOpsTabsProps {
/** Phased customs workflow files — enables the Uploaded documents tab. */
workflowFiles?: Freight.ClearanceWorkflowFile[];
showWorkflowFilesTab?: boolean;
/**
* Booking or contract id whose GL Ethiopia ↔ GL Djibouti document exchange
* belongs on this page. Undefined hides the tab; it is also hidden from staff
* who hold neither desk's clearance-actions permission.
*/
exchangeEntityId?: string;
tradeDirection?: string;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
@@ -40,6 +49,7 @@ export function ClearanceOpsTabs({
clearanceTab,
workflowFiles = [],
showWorkflowFilesTab = false,
exchangeEntityId,
tradeDirection = "IMPORT",
onViewFile,
onDownloadFile,
@@ -53,7 +63,12 @@ export function ClearanceOpsTabs({
return true;
}).length;
const showDocuments = showWorkflowFilesTab && Boolean(onViewFile);
const hasTabs = (showOpsTabs && hasOps) || showDocuments;
const { user } = useAuth();
const showExchange =
Boolean(exchangeEntityId) &&
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions));
const hasTabs = (showOpsTabs && hasOps) || showDocuments || showExchange;
if (!hasTabs) {
return <>{clearanceTab}</>;
@@ -78,6 +93,11 @@ export function ClearanceOpsTabs({
Uploaded documents
</Tabs.Tab>
) : null}
{showExchange ? (
<Tabs.Tab value="exchange" leftSection={<Share2 size={14} />}>
Document exchange
</Tabs.Tab>
) : null}
{showOpsTabs && riskMs ? (
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
Risk assignment
@@ -103,6 +123,12 @@ export function ClearanceOpsTabs({
</Tabs.Panel>
) : null}
{showExchange ? (
<Tabs.Panel value="exchange">
<GlExchangePanel entityId={exchangeEntityId!} />
</Tabs.Panel>
) : null}
{showOpsTabs && riskMs && bookingId ? (
<Tabs.Panel value="risk">
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">

View File

@@ -21,12 +21,14 @@ const CATEGORY_LABELS: Record<
string
> = {
declaration: "Declaration",
draft_declaration: "Draft declaration",
duty: "Duty & taxes",
transit: "Transit",
djibouti: "Djibouti",
};
const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [
"draft_declaration",
"declaration",
"duty",
"transit",

View File

@@ -20,6 +20,7 @@ import {
CheckCircle2,
FileText,
PackageCheck,
PackageOpen,
Receipt,
Ship,
Train,
@@ -31,6 +32,7 @@ import toast from "react-hot-toast";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
@@ -41,9 +43,11 @@ import {
} from "@/components/contracts/PhasedUploadedFileRow";
import {
DeclarationStep,
OffloadStep,
StepStatus,
isBookingMilestoneDone,
isMilestoneDone,
offloadSummary,
type ClearanceViewLike,
type MilestoneRow,
} from "@/components/contracts/PhasedClearanceActionPanel";
@@ -59,7 +63,8 @@ function todayISODate(): string {
/**
* Export customs flow, ordered per the stakeholder process:
* customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET)
* customer docs → transit assignee (DJ names officer) → declaration (ET,
* releases the export) → RO (DJ, auto-releases) → create booking (ET)
* → payment + wagons → transport document / T1 (ET) → train to Djibouti
* → accept T1 (DJ, one button after arrival) → gate pass (DJ)
* → final invoice (DJ) + customer slip + GL confirm.
@@ -71,9 +76,10 @@ export function computeExportActiveStep(
): number {
const released = Boolean(clearance.bookingReady || clearance.operationReady);
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
if (!isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED")) return 1;
if (!isMilestoneDone(clearance.milestones, "DECLARED") || !released) return 2;
if (!bookingCreated) return 3;
if (!clearance.transitAssignee?.name) return 1;
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 2;
if (!isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED") || !released) return 3;
if (!bookingCreated) return 4;
if (
!isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") ||
!(
@@ -81,14 +87,17 @@ export function computeExportActiveStep(
clearance.train?.wagonAllocated
)
) {
return 4;
return 5;
}
if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 5;
if (!clearance.train?.arrivedAt) return 6;
if (!clearance.t1Closed) return 7;
if (!clearance.gatepassGranted) return 8;
if (clearance.finalInvoice?.status !== "PAID") return 9;
return 10;
if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 6;
if (!clearance.train?.arrivedAt) return 7;
if (!clearance.t1Closed) return 8;
if (!clearance.gatepassGranted) return 9;
// Step 10 is the read-only Offload step. It never gates the flow: the final
// invoice may be raised on a secured gate pass alone, so parking the stepper
// there would hide the invoice actions whenever operations lag on the offload.
if (clearance.finalInvoice?.status !== "PAID") return 11;
return 12;
}
export function exportTransitFilesFromWorkflow(
@@ -168,6 +177,7 @@ export function ExportClearanceStepper({
isBookingMilestoneDone(bookingMilestones, "WAGON_ALLOCATED") ||
Boolean(clearance.train?.wagonAllocated);
const transportIssued = isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED");
const offloadDone = clearance.offload?.offloaded ?? Boolean(clearance.offloaded);
return (
<Stack gap="md">
@@ -213,6 +223,58 @@ export function ExportClearanceStepper({
/>
</Stepper.Step>
<Stepper.Step
label="Request transit assignee"
description="Ask GL Djibouti to name the officer handling this shipment"
icon={
clearance.transitAssignee?.name ? (
<CheckCircle2 size={14} />
) : undefined
}
>
{showEt && canEt ? (
<TransitAssigneePanel
entityId={entityId}
isBooking={isBooking}
transitAssignee={clearance.transitAssignee}
side="ET"
onChanged={onChanged}
/>
) : (
<StepStatus
done={Boolean(clearance.transitAssignee?.name)}
pendingLabel="Waiting for GL Ethiopia to request a transit assignee from GL Djibouti."
doneLabel={`Transit assignee: ${clearance.transitAssignee?.name ?? ""}`}
/>
)}
</Stepper.Step>
<Stepper.Step
label="Customs declaration"
description="GL Ethiopia uploads — releases the export"
icon={declared ? <CheckCircle2 size={14} /> : <FileText size={14} />}
>
{showEt && canEt && !effectiveBookingCreated && (activeStep >= 2 || declared) ? (
<Stack gap="sm">
<DeclarationStep
entityId={entityId}
isBooking={isBooking}
replaceMode={declared}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stack>
) : (
<StepStatus
done={declared}
pendingLabel="Waiting for GL Ethiopia to upload the customs declaration."
doneLabel="Declaration uploaded."
/>
)}
</Stepper.Step>
<Stepper.Step
label="Release Order"
description="GL Djibouti uploads RO + vessel date"
@@ -255,31 +317,13 @@ export function ExportClearanceStepper({
</Text>
) : null}
<StepStatus
done={isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED")}
done={isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED") && released}
pendingLabel="Waiting for GL Djibouti to upload the Release Order."
doneLabel="Release Order secured."
doneLabel="Release Order secured — export released."
/>
</Stack>
)}
</Stepper.Step>
<Stepper.Step
label="Customs declaration"
description="GL Ethiopia uploads — releases the export"
icon={declared ? <CheckCircle2 size={14} /> : <FileText size={14} />}
>
{showEt && canEt && !effectiveBookingCreated && (activeStep >= 2 || declared) ? (
<Stack gap="sm">
<DeclarationStep
entityId={entityId}
isBooking={isBooking}
replaceMode={declared}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
{declared && !released ? (
{/* RO is secured but the auto-release never fired (legacy in-flight
contracts from before the RO step auto-released). */}
{isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED") && !released ? (
<ConfirmExportReleaseFallback
entityId={entityId}
isBooking={isBooking}
@@ -287,12 +331,6 @@ export function ExportClearanceStepper({
/>
) : null}
</Stack>
) : (
<StepStatus
done={declared && released}
pendingLabel="Waiting for GL Ethiopia to upload the customs declaration."
doneLabel="Declaration uploaded — export released."
/>
)}
</Stepper.Step>
@@ -432,6 +470,21 @@ export function ExportClearanceStepper({
<GatepassStep clearance={clearance} />
</Stepper.Step>
{/* Read-only: operations record the offload when the train is unloaded
at the Djibouti port. Stats ride in the description so they stay
visible after the flow moves on to the final invoice. */}
<Stepper.Step
label="Offload"
description={offloadSummary(clearance.offload, Boolean(clearance.offloaded))}
color={offloadDone ? undefined : "gray"}
icon={<PackageOpen size={14} />}
completedIcon={
offloadDone ? <CheckCircle2 size={14} /> : <PackageOpen size={14} />
}
>
<OffloadStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
label="Final invoice & payment"
description="GL Djibouti invoices after offload; customer pays"
@@ -656,6 +709,8 @@ function FinalInvoiceStep({
const invoice = clearance.finalInvoice ?? null;
const paid = invoice?.status === "PAID";
// Raised as a draft — the customer approves it before paying.
const approved = Boolean(invoice?.approvedAt);
// Export: OFFLOADED is a DJ doc milestone that may never be recorded, so the
// secured gate pass is enough to open invoicing. Sending an invoice is optional.
@@ -684,7 +739,7 @@ function FinalInvoiceStep({
</Text>
</div>
<Badge color={paid ? "edr-green" : "yellow"} variant="light">
{invoice.status}
{approved ? invoice.status : "AWAITING CUSTOMER APPROVAL"}
</Badge>
</Group>
</Paper>
@@ -722,9 +777,11 @@ function FinalInvoiceStep({
<StepStatus
done={false}
pendingLabel={
invoice.slipFile
? "Payment slip attached — confirm to settle the invoice."
: "Waiting for the customer to pay and attach the payment slip."
!approved
? "Waiting for the customer to review and approve the invoice."
: invoice.slipFile
? "Payment slip attached — confirm to settle the invoice."
: "Waiting for the customer to pay and attach the payment slip."
}
doneLabel=""
/>
@@ -754,6 +811,7 @@ function FinalInvoiceStep({
<>
<Text size="sm" c="dimmed">
Send the final invoice to the customer if post-arrival charges apply (optional).
The customer approves it before paying.
</Text>
<Button
color="edr-green"

View File

@@ -0,0 +1,479 @@
import { useMemo, useState } from "react";
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Loader,
Menu,
Modal,
Paper,
Stack,
Switch,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Download,
Eye,
EyeOff,
FileText,
MoreVertical,
Pencil,
Share2,
Trash2,
Upload,
UserCheck,
} from "lucide-react";
import dayjs from "dayjs";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { useFileViewer } from "@/hooks/useFileViewer";
import { downloadBookingFile, fetchViewableFile } from "@/services/files.service";
import { glExchangeService } from "@/services/glExchange.service";
const SIDES: Record<Freight.GlExchangeDocument["side"], { label: string; color: string }> =
{
ET: { label: "GL Ethiopia", color: "edr-green" },
DJ: { label: "GL Djibouti", color: "blue" },
};
function formatBytes(bytes: number): string {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / 1024 ** i).toFixed(1))} ${units[i]}`;
}
export interface GlExchangePanelProps {
/** Booking or contract id both desks are working on — the thread key. */
entityId: string;
}
/**
* GL Ethiopia ↔ GL Djibouti document exchange. Either desk attaches any file
* under a title of its own choosing; both desks see the whole thread, only the
* uploader can change or remove what they posted, and each document is shared
* with the customer's portal or kept between the desks.
*/
export function GlExchangePanel({ entityId }: GlExchangePanelProps) {
const queryClient = useQueryClient();
const { view, viewer } = useFileViewer();
const [formDoc, setFormDoc] = useState<
Freight.GlExchangeDocument | "new" | null
>(null);
const [pendingDelete, setPendingDelete] =
useState<Freight.GlExchangeDocument | null>(null);
const {
data: documents = [],
isLoading,
isError,
} = useQuery({
queryKey: ["gl-exchange", entityId],
queryFn: () => glExchangeService.list(entityId),
enabled: Boolean(entityId),
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["gl-exchange", entityId] });
const removeMutation = useMutation({
mutationFn: (id: string) => glExchangeService.remove(id),
onSuccess: async () => {
setPendingDelete(null);
await invalidate();
toast.success("Document removed");
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Could not remove document"),
});
const stats = useMemo(
() => ({
et: documents.filter((d) => d.side === "ET").length,
dj: documents.filter((d) => d.side === "DJ").length,
shared: documents.filter((d) => d.visibleToCustomer).length,
}),
[documents],
);
return (
<Stack gap="md">
<Paper withBorder radius="md" p="md">
<Group justify="space-between" align="flex-start" wrap="wrap" 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">
Share any document with the other Global Logistics desk. Both
desks see everything here; only the uploader can edit or remove
a document, and only documents marked visible reach the customer.
</Text>
</Box>
</Group>
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={() => setFormDoc("new")}
>
Share document
</Button>
</Group>
{documents.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="gray" radius="sm" tt="none">
{stats.shared} visible to customer
</Badge>
</Group>
) : null}
</Paper>
{isLoading ? (
<Group justify="center" py={40} gap={10}>
<Loader size="sm" color="edr-green" />
<Text size="sm" c="dimmed">
Loading shared documents
</Text>
</Group>
) : isError ? (
<Text size="sm" c="red">
Could not load the shared documents.
</Text>
) : documents.length === 0 ? (
<EmptyState onShare={() => setFormDoc("new")} />
) : (
<Stack gap={8}>
{documents.map((doc) => (
<DocumentRow
key={doc.id}
doc={doc}
onView={view}
onEdit={() => setFormDoc(doc)}
onDelete={() => setPendingDelete(doc)}
/>
))}
</Stack>
)}
<DocumentFormModal
entityId={entityId}
doc={formDoc === "new" ? null : formDoc}
opened={formDoc != null}
onClose={() => setFormDoc(null)}
onSaved={() => {
setFormDoc(null);
void invalidate();
}}
/>
<Modal
opened={pendingDelete != null}
onClose={() => setPendingDelete(null)}
title={<Text fw={700}>Remove shared document</Text>}
radius="md"
size="sm"
>
<Stack gap="md">
<Text size="sm">
Remove <b>{pendingDelete?.title}</b> from the exchange? The other
desk and the customer, if it was shared will no longer see it.
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setPendingDelete(null)}>
Cancel
</Button>
<Button
color="red"
loading={removeMutation.isPending}
leftSection={<Trash2 size={15} />}
onClick={() => removeMutation.mutate(pendingDelete!.id)}
>
Remove
</Button>
</Group>
</Stack>
</Modal>
{viewer}
</Stack>
);
}
function EmptyState({ onShare }: { onShare: () => void }) {
return (
<Box
py={44}
style={{
borderRadius: 12,
border: "1px dashed var(--mantine-color-gray-4)",
background: "var(--mantine-color-gray-0)",
textAlign: "center",
}}
>
<Stack gap={10} align="center">
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<FileText size={22} />
</ThemeIcon>
<Text size="sm" c="dimmed" maw={380}>
Nothing shared yet. Anything either desk uploads here scans,
correspondence, corrected forms is visible to the other side
immediately.
</Text>
<Button
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
onClick={onShare}
>
Share the first document
</Button>
</Stack>
</Box>
);
}
function DocumentRow({
doc,
onView,
onEdit,
onDelete,
}: {
doc: Freight.GlExchangeDocument;
onView: (file: { name: string; url: string }) => void;
onEdit: () => void;
onDelete: () => void;
}) {
const side = SIDES[doc.side];
const canPreview = isViewable({ name: doc.file.name, url: "" });
return (
<Paper 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" : "GL only"}
</Badge>
</Group>
<Text size="xs" c="dimmed" mt={4} truncate>
{doc.file.name} · {formatBytes(doc.file.size)} ·{" "}
{doc.uploadedByName ?? "Global Logistics"} ·{" "}
{dayjs(doc.uploadedAt).format("D MMM YYYY, HH:mm")}
</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(onView)
}
>
View
</Button>
</Tooltip>
) : null}
<Tooltip label="Download">
<Button
size="compact-xs"
variant="light"
radius="md"
leftSection={<Download size={13} />}
onClick={() =>
void downloadBookingFile(doc.file.id, doc.file.name)
}
>
Download
</Button>
</Tooltip>
{doc.canEdit ? (
<Menu position="bottom-end" radius="md" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Document actions">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<Pencil size={14} />} onClick={onEdit}>
Edit title, visibility or file
</Menu.Item>
<Menu.Item
color="red"
leftSection={<Trash2 size={14} />}
onClick={onDelete}
>
Remove
</Menu.Item>
</Menu.Dropdown>
</Menu>
) : (
<Tooltip label={`Only ${doc.uploadedByName ?? "the uploader"} can edit this`}>
<ThemeIcon variant="subtle" color="gray" size={28}>
<UserCheck size={15} />
</ThemeIcon>
</Tooltip>
)}
</Group>
</Group>
</Paper>
);
}
function DocumentFormModal({
entityId,
doc,
opened,
onClose,
onSaved,
}: {
entityId: string;
doc: Freight.GlExchangeDocument | null;
opened: boolean;
onClose: () => void;
onSaved: () => void;
}) {
const editing = doc != null;
const [title, setTitle] = useState("");
const [visible, setVisible] = useState(false);
const [file, setFile] = useState<File | null>(null);
// Re-seed the form whenever a different document (or "new") opens it.
const [seededFor, setSeededFor] = useState<string | null>(null);
const seedKey = opened ? (doc?.id ?? "new") : null;
if (seedKey !== seededFor) {
setSeededFor(seedKey);
setTitle(doc?.title ?? "");
setVisible(doc?.visibleToCustomer ?? false);
setFile(null);
}
const save = useMutation({
mutationFn: () =>
editing
? glExchangeService.update(doc.id, {
title: title.trim(),
visibleToCustomer: visible,
file,
})
: glExchangeService.upload(entityId, {
title: title.trim(),
visibleToCustomer: visible,
file: file!,
}),
onSuccess: () => {
toast.success(editing ? "Document updated" : "Document shared");
onSaved();
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : "Could not save document"),
});
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Group gap={8}>
<Share2 size={18} />
<Text fw={700}>{editing ? "Edit shared document" : "Share a document"}</Text>
</Group>
}
radius="md"
size="md"
>
<Stack gap="md">
<TextInput
label="Document title"
placeholder="e.g. Corrected packing list for container TCLU1234567"
description="What the other desk (and the customer, if shared) will see."
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
maxLength={300}
required
/>
<PhasedFileDropzone
label={editing ? "Replacement file (optional)" : "File"}
description={
editing
? "Leave empty to keep the current file."
: "Any document type — PDF, image, spreadsheet."
}
accept="*/*"
value={file}
onChange={setFile}
replaceMode={editing}
/>
<Switch
checked={visible}
onChange={(e) => setVisible(e.currentTarget.checked)}
color="edr-green"
label="Visible to the customer"
description="Shows in the customer's booking documents. Off keeps it between the two GL desks."
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={save.isPending}>
Cancel
</Button>
<Button
color="edr-green"
loading={save.isPending}
disabled={!title.trim() || (!editing && !file)}
leftSection={<Upload size={16} />}
onClick={() => save.mutate()}
>
{editing ? "Save changes" : "Share document"}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -27,6 +27,7 @@ import {
FileText,
MessageSquareWarning,
PackageCheck,
PackageOpen,
Receipt,
ShieldAlert,
Ship,
@@ -61,7 +62,8 @@ export type ClearanceViewLike = Pick<
| "nextAction"
| "dutyRequired"
| "dutyAdvice"
| "dutyDispute"
| "draftDeclaration"
| "draftDeclarationChangeRequest"
| "transitAssignee"
| "roHold"
| "roHoldReason"
@@ -77,6 +79,7 @@ export type ClearanceViewLike = Pick<
| "t1Closed"
| "t1ClosedAt"
| "offloaded"
| "offload"
| "finalInvoice"
| "vesselDepartureDate"
| "vesselArrivalDate"
@@ -113,32 +116,49 @@ function computeImportActiveStep(
bookingMilestones: MilestoneRow[],
t1Uploaded: boolean,
freightPaid: boolean,
isBooking: boolean,
): number {
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
if (!clearance.transitAssignee?.name) return 1;
// Draft declaration is a booking-only step — the customer only ever reviews
// it on the booking-scoped portal page, so it never applies (and never
// gates) on the contract-scoped pre-booking page. Also a backward-compat
// guard: a booking that already has a real declaration filed got there
// before this step existed — never send it backward for a draft it was
// never asked to send.
if (
isBooking &&
!isMilestoneDone(clearance.milestones, "DRAFT_DECLARATION_ACCEPTED") &&
!isMilestoneDone(clearance.milestones, "DECLARED")
) {
return 2;
}
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 3;
if (
clearance.dutyRequired === null ||
clearance.dutyRequired === undefined ||
(clearance.dutyRequired && !isMilestoneDone(clearance.milestones, "DUTY_TAXES_ADVISED"))
) {
return 2;
return 4;
}
if (
clearance.dutyRequired &&
!isMilestoneDone(clearance.milestones, "DUTY_TAX_PAID")
) {
return 3;
return 5;
}
if (!isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) return 4;
if (!clearance.preClearanceFinalized) return 5;
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
if (!bookingCreated) return 7;
if (!isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) return 6;
if (!clearance.preClearanceFinalized) return 7;
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 8;
if (!bookingCreated) return 9;
// The customer pays the train/freight charges on the booking. Until that
// settles the gate pass is not granted for this booking, so the flow stops here.
if (!freightPaid) return 8;
if (!clearance.gatepassGranted) return 9;
if (!t1Uploaded && !clearance.t1?.closed) return 10;
if (!clearance.t1?.closed) return 11;
if (!freightPaid) return 10;
if (!clearance.gatepassGranted) return 11;
// Step 12 is the read-only Offload step — cargo comes off the train at
// arrival, i.e. AFTER the T1 steps below, so it never gates the flow.
if (!t1Uploaded && !clearance.t1?.closed) return 13;
if (!clearance.t1?.closed) return 14;
// Risk is "assigned" when the booking milestone says so OR the clearance view
// already carries a riskLevel. The ET page derives its bookingMilestones from a
// separately-fetched booking id that can lag or mismatch the booking carrying
@@ -146,15 +166,15 @@ function computeImportActiveStep(
const riskAssigned =
Boolean(clearance.riskLevel) ||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
if (!riskAssigned) return 12;
if (!riskAssigned) return 15;
// Additional duty round is optional — resolved once skipped or paid.
const secondDutyResolved =
clearance.secondDuty?.skipped ||
clearance.secondDuty?.paid ||
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
if (!secondDutyResolved) return 13;
if (!clearance.importReleaseGranted) return 14;
return 15;
if (!secondDutyResolved) return 16;
if (!clearance.importReleaseGranted) return 17;
return 18;
}
function t1FilesFromWorkflow(
@@ -267,6 +287,7 @@ export function PhasedClearanceActionPanel({
const freightPaid =
isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") ||
Boolean(clearance.gatepassGranted);
const offloadDone = clearance.offload?.offloaded ?? Boolean(clearance.offloaded);
const activeStep = useMemo(
() =>
isImport
@@ -276,6 +297,7 @@ export function PhasedClearanceActionPanel({
bookingMilestones,
t1Uploaded,
freightPaid,
isBooking,
)
: 0,
[
@@ -333,6 +355,76 @@ export function PhasedClearanceActionPanel({
/>
</Stepper.Step>
<Stepper.Step
label="Request transit assignee"
description="Ask GL Djibouti to name the officer handling this shipment"
icon={
clearance.transitAssignee?.name ? (
<CheckCircle2 size={14} />
) : undefined
}
>
{showEt && canEt ? (
<TransitAssigneePanel
entityId={entityId}
isBooking={isBooking}
transitAssignee={clearance.transitAssignee}
side="ET"
onChanged={onChanged}
/>
) : (
<StepStatus
done={Boolean(clearance.transitAssignee?.name)}
pendingLabel="Waiting for GL Ethiopia to request a transit assignee from GL Djibouti."
doneLabel={`Transit assignee: ${clearance.transitAssignee?.name ?? ""}`}
/>
)}
</Stepper.Step>
<Stepper.Step
label="Draft declaration"
description="Send the customer a draft declaration with an estimated price"
icon={
isMilestoneDone(clearance.milestones, "DRAFT_DECLARATION_ACCEPTED") ? (
<CheckCircle2 size={14} />
) : (
<FileText size={14} />
)
}
>
{showEt && canEt && activeStep === 2 ? (
<DraftDeclarationStep
bookingId={entityId}
clearance={clearance}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
) : (
<Stack gap="sm">
{(clearance.draftDeclaration?.files ?? []).map((file, index) => (
<PhasedUploadedFileRow
key={file.id}
label={`Draft declaration document ${index + 1}`}
file={file}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
))}
<StepStatus
done={isMilestoneDone(clearance.milestones, "DRAFT_DECLARATION_ACCEPTED")}
pendingLabel={
clearance.draftDeclaration
? "Waiting for the customer to accept the draft declaration."
: "Send the customer a draft declaration to review."
}
doneLabel="Draft declaration accepted by the customer."
/>
</Stack>
)}
</Stepper.Step>
<Stepper.Step
label="Customs declaration"
description="Upload declaration documents"
@@ -344,24 +436,10 @@ export function PhasedClearanceActionPanel({
)
}
>
{/* Djibouti must name the transit officer first — the declaration
is filed against whoever handles the shipment there, and the
API refuses the upload until the name is in. */}
{showEt &&
canEt &&
!clearance.transitAssignee?.name &&
!isMilestoneDone(clearance.milestones, "DECLARED") ? (
<TransitAssigneePanel
entityId={entityId}
isBooking={isBooking}
transitAssignee={clearance.transitAssignee}
side="ET"
onChanged={onChanged}
/>
) : showEt &&
canEt &&
!clearance.bookingReady &&
(activeStep >= 1 ||
(activeStep >= 3 ||
isMilestoneDone(clearance.milestones, "DECLARED")) ? (
<DeclarationStep
entityId={entityId}
@@ -398,7 +476,7 @@ export function PhasedClearanceActionPanel({
description="Advise amount and attach notice"
icon={<Receipt size={14} />}
>
{showEt && canEt && activeStep === 2 ? (
{showEt && canEt && activeStep === 3 ? (
<DutyStep
entityId={entityId}
isBooking={isBooking}
@@ -460,7 +538,7 @@ export function PhasedClearanceActionPanel({
{showEt &&
canEt &&
!bookingCreated &&
(activeStep >= 4 ||
(activeStep >= 5 ||
isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) ? (
<TransitPermitStep
entityId={entityId}
@@ -504,7 +582,7 @@ export function PhasedClearanceActionPanel({
description="Hand off to GL Djibouti"
icon={<PackageCheck size={14} />}
>
{showEt && canEt && activeStep === 5 ? (
{showEt && canEt && activeStep === 6 ? (
<FinalizePreClearanceStep
entityId={entityId}
isBooking={isBooking}
@@ -624,6 +702,22 @@ export function PhasedClearanceActionPanel({
<ImportGatepassStep clearance={clearance} freightPaid={freightPaid} />
</Stepper.Step>
{/* Read-only: offload is recorded by operations when the train
reaches the destination, which happens after the T1 steps — so
it never holds the active pointer, and its icon stays neutral
until it actually happens. */}
<Stepper.Step
label="Offload"
description={offloadSummary(clearance.offload, Boolean(clearance.offloaded))}
color={offloadDone ? undefined : "gray"}
icon={<PackageOpen size={14} />}
completedIcon={
offloadDone ? <CheckCircle2 size={14} /> : <PackageOpen size={14} />
}
>
<OffloadStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
label="T1 transport documents"
description="GL Djibouti uploads after the gate pass is secured"
@@ -1049,6 +1143,91 @@ function ImportGatepassStep({
);
}
/**
* Compact offload line for the step's description row — the only part of a
* Mantine step that stays visible once the flow has moved past it.
*/
export function offloadSummary(
offload: ClearanceViewLike["offload"],
offloaded: boolean,
): string {
if (!offload?.offloaded && !offloaded) {
return "Cargo comes off the train at its destination";
}
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
const bits = [
offload?.containers ? plural(offload.containers, "container") : null,
offload?.wagons ? plural(offload.wagons, "wagon") : null,
offload?.weightTons ? `${offload.weightTons.toLocaleString()} t` : null,
offload?.destination ?? null,
].filter(Boolean);
return bits.length ? bits.join(" · ") : "Offloaded";
}
/**
* Offload stats for the booking, read-only. Recorded by the warehouse
* auto-unload that runs when the train reaches the booking's destination —
* nothing here is actioned from clearance.
*/
export function OffloadStep({
clearance,
}: {
clearance: ClearanceViewLike;
}) {
const offload = clearance.offload ?? null;
const done = offload?.offloaded ?? Boolean(clearance.offloaded);
if (!done) {
return (
<StepStatus
done={false}
pendingLabel="Waiting for the cargo to be offloaded at its destination (recorded by operations on arrival)."
doneLabel=""
/>
);
}
const stats: Array<[string, string]> = [
["Containers", offload?.containers ? String(offload.containers) : "—"],
["Wagons", offload?.wagons ? String(offload.wagons) : "—"],
[
"Weight",
offload?.weightTons ? `${offload.weightTons.toLocaleString()} t` : "—",
],
["Destination", offload?.destination ?? "—"],
["GRN", offload?.grnNumber ?? "—"],
["Location", offload?.location ?? "—"],
];
return (
<Paper withBorder radius="md" p="sm" bg="var(--mantine-color-edr-green-0)">
<Group gap="xs" wrap="nowrap" mb="xs">
<Badge color="edr-green" variant="light" leftSection={<CheckCircle2 size={12} />}>
Offloaded
</Badge>
<Text size="sm" c="dimmed">
{offload?.offloadedAt
? new Date(offload.offloadedAt).toLocaleString()
: "Recorded on arrival"}
{offload?.inventoryStatus ? ` · ${offload.inventoryStatus}` : ""}
</Text>
</Group>
<Group gap="lg" wrap="wrap">
{stats.map(([label, value]) => (
<Stack key={label} gap={0}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600}>
{value}
</Text>
</Stack>
))}
</Group>
</Paper>
);
}
const RISK_LEVEL_COLOR: Record<string, string> = {
GREEN: "green",
YELLOW: "yellow",
@@ -1569,6 +1748,145 @@ export function DeclarationStep({
);
}
/**
* GL Ethiopia sends a draft customs declaration (estimated price + files) for
* the customer to review in the portal before the real declaration is filed.
* Booking-only — the customer only ever sees this on the booking-scoped page.
*/
function DraftDeclarationStep({
bookingId,
clearance,
onChanged,
onViewFile,
onDownloadFile,
}: {
bookingId: string;
clearance: ClearanceViewLike;
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const [price, setPrice] = useState<number | string>(
clearance.draftDeclaration?.price ?? "",
);
const [currency, setCurrency] = useState(clearance.draftDeclaration?.currency ?? "ETB");
const [loading, setLoading] = useState(false);
const changeRequest = clearance.draftDeclarationChangeRequest;
const existingFiles = clearance.draftDeclaration?.files ?? [];
const replaceMode = existingFiles.length > 0;
return (
<Stack gap="md">
{/* The customer sent this draft back — their words drive the
correction, so they lead the step. */}
{changeRequest ? (
<Alert
color="orange"
radius="md"
icon={<MessageSquareWarning size={16} />}
title={
changeRequest.rounds > 1
? `Customer requested a change (round ${changeRequest.rounds})`
: "Customer requested a change"
}
>
<Stack gap={4}>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{changeRequest.note}
</Text>
<Text size="xs" c="dimmed">
Raised {new Date(changeRequest.raisedAt).toLocaleString()} send a
corrected draft below.
</Text>
</Stack>
</Alert>
) : null}
{existingFiles.length > 0 ? (
<Stack gap={8}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
Current draft
</Text>
{existingFiles.map((file, index) => (
<PhasedUploadedFileRow
key={file.id}
label={`Draft declaration document ${index + 1}`}
file={file}
onView={onViewFile}
onDownload={onDownloadFile}
/>
))}
</Stack>
) : null}
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
<Stack gap="md">
<Group grow align="flex-start">
<NumberInput
label="Estimated price"
placeholder="0.00"
value={price}
onChange={setPrice}
min={0}
size="sm"
thousandSeparator=","
/>
<Select
label="Currency"
data={["ETB", "USD"]}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
size="sm"
/>
</Group>
<PhasedMultiFileDropzone
label="Draft declaration documents"
description={
replaceMode
? "Replace the draft — upload one or more corrected documents."
: "Upload one or more draft declaration documents (PDF or image)."
}
value={files}
onChange={setFiles}
replaceMode={replaceMode}
disabled={loading}
/>
</Stack>
</Paper>
<Button
color="edr-green"
loading={loading}
disabled={files.length === 0 || price === ""}
leftSection={<Upload size={16} />}
fullWidth
onClick={async () => {
setLoading(true);
try {
await bookingsService.uploadDraftDeclaration(
bookingId,
files,
Number(price),
currency,
);
setFiles([]);
toast.success(replaceMode ? "Corrected draft sent" : "Draft sent to customer");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
{replaceMode ? "Send corrected draft" : "Send draft to customer"}
</Button>
</Stack>
);
}
function DutyStep({
entityId,
isBooking,
@@ -1597,35 +1915,9 @@ function DutyStep({
const noticeFile = findWorkflowFile(workflowFiles, "duty_tax_notice");
const hasExistingNotice = Boolean(noticeFile);
const dispute = clearance.dutyDispute;
return (
<Stack gap="md">
{/* The customer rejected the last advice — their words drive the
correction, so they lead the step. */}
{dispute ? (
<Alert
color="orange"
radius="md"
icon={<MessageSquareWarning size={16} />}
title={
dispute.rounds > 1
? `Customer asked for a correction (round ${dispute.rounds})`
: "Customer asked for a correction"
}
>
<Stack gap={4}>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{dispute.note}
</Text>
<Text size="xs" c="dimmed">
Raised {new Date(dispute.raisedAt).toLocaleString()} re-advise
below to send a corrected notice.
</Text>
</Stack>
</Alert>
) : null}
{noticeFile ? (
<Stack gap={8}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase">

View File

@@ -5,18 +5,19 @@ import {
Button,
Group,
Paper,
Select,
Stack,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CheckCircle2, Clock, Send, UserCheck } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { bookingsService } from "@/services/bookings.service";
import { contractsService } from "@/services/contracts.service";
import { transitAgentsService } from "@/services/transit-agents.service";
export interface TransitAssigneePanelProps {
/** Booking id when `isBooking`, contract id otherwise. */
@@ -62,10 +63,20 @@ export function TransitAssigneePanel({
onChanged,
}: TransitAssigneePanelProps) {
const [note, setNote] = useState("");
const [assignee, setAssignee] = useState(transitAssignee?.name ?? "");
const [transitAgentId, setTransitAgentId] = useState<string | null>(null);
const [changing, setChanging] = useState(false);
const service = isBooking ? bookingsService : contractsService;
const { data: assignableAgents, isLoading: loadingAgents } = useQuery({
queryKey: ["transit-agents", "assignable"],
queryFn: () => transitAgentsService.listAssignable(),
enabled: side === "DJ",
});
const agentOptions = (assignableAgents ?? []).map((a) => ({
value: a.id,
label: a.name,
}));
const request = useMutation({
mutationFn: async () => {
await service.requestTransitAssignee(entityId, note.trim());
@@ -79,7 +90,8 @@ export function TransitAssigneePanel({
const assign = useMutation({
mutationFn: async () => {
await service.assignTransitAssignee(entityId, assignee.trim());
if (!transitAgentId) return;
await service.assignTransitAssignee(entityId, transitAgentId);
},
onSuccess: () => {
toast.success("Transit assignee sent to GL Ethiopia");
@@ -114,7 +126,7 @@ export function TransitAssigneePanel({
variant="light"
radius="md"
onClick={() => {
setAssignee(transitAssignee!.name ?? "");
setTransitAgentId(null);
setChanging(true);
}}
>
@@ -152,13 +164,16 @@ export function TransitAssigneePanel({
GL Ethiopia: {transitAssignee.requestNote}
</Text>
) : null}
<TextInput
<Select
label="Transit officer"
description="Name of the person handling this shipment in Djibouti"
placeholder="e.g. Ahmed Bourhan"
value={assignee}
onChange={(e) => setAssignee(e.currentTarget.value)}
disabled={readOnly}
description="Active, currently-valid transit agents only — configure the roster in Transit Agents settings"
placeholder={loadingAgents ? "Loading…" : "Select transit officer"}
data={agentOptions}
value={transitAgentId}
onChange={setTransitAgentId}
searchable
disabled={readOnly || loadingAgents}
nothingFoundMessage="No active, valid transit agents — add one in Transit Agents settings"
/>
<Group justify="flex-end" gap="sm">
{changing ? (
@@ -171,7 +186,7 @@ export function TransitAssigneePanel({
radius="md"
leftSection={<Send size={15} />}
loading={assign.isPending}
disabled={readOnly || !assignee.trim()}
disabled={readOnly || !transitAgentId}
onClick={() => assign.mutate()}
>
Send assignment

View File

@@ -66,6 +66,19 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
);
}
if (format === "validityBadge") {
const status = String(value);
const label =
status === "VALID" ? "Valid" : status === "EXPIRED" ? "Expired" : "Not started";
const color =
status === "VALID" ? "edr-green" : status === "EXPIRED" ? "red" : "yellow";
return (
<Badge color={color} variant="filled" size="sm" radius="md">
{label}
</Badge>
);
}
if (format === "code") {
return (
<Badge

View File

@@ -142,7 +142,7 @@ export const TrainConsistView = ({
weightMax={trainSet?.locomotive?.maxPullWeightTons ?? null}
lengthUsed={lengthUsed}
lengthMax={trainSet?.locomotive?.maxTrainLengthMeters ?? null}
wagonCount={wagons.length}
wagonCount={loadedCount}
wagonMax={maxWagons}
/>

View File

@@ -144,6 +144,8 @@ export const URL_CONSTANTS = {
CLEARANCE_DECLARATION: (id: string) =>
`/bookings/${id}/clearance/declaration`,
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
CLEARANCE_DRAFT_DECLARATION: (id: string) =>
`/bookings/${id}/clearance/draft-declaration`,
CLEARANCE_TRANSIT_ASSIGNEE_REQUEST: (id: string) =>
`/bookings/${id}/clearance/transit-assignee/request`,
CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN: (id: string) =>
@@ -166,6 +168,13 @@ export const URL_CONSTANTS = {
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
},
// GL Ethiopia ↔ GL Djibouti document exchange, keyed by the booking or
// contract both desks are working on.
GL_EXCHANGE: {
FOR_ENTITY: (entityId: string) => `/gl-exchange/${entityId}`,
DOCUMENT: (documentId: string) => `/gl-exchange/documents/${documentId}`,
},
CONTRACTS: {
BASE: "/contracts",
LIST_SUMMARY: "/contracts/list-summary",
@@ -467,6 +476,10 @@ export const URL_CONSTANTS = {
APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`,
APPROVAL_RULES_CHAIN: "/approval-rules/chain",
APPROVAL_RULES_POSITION_TYPES: "/approval-rules/position-types",
TRANSIT_AGENTS: "/transit-agents",
TRANSIT_AGENT_BY_ID: (id: string) => `/transit-agents/${id}`,
TRANSIT_AGENTS_ASSIGNABLE: "/transit-agents/assignable",
},
RATE_MATRIX: {
BASE: "/api/rate-matrices",

View File

@@ -254,6 +254,7 @@ export default function DocumentClearanceDetailPage() {
milestones={bookingMilestones}
showOpsTabs={Boolean(id)}
showWorkflowFilesTab={isPhasedGeneral}
exchangeEntityId={id}
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
onViewFile={view}

View File

@@ -352,6 +352,7 @@ export default function ContractClearanceDetailPage() {
milestones={bookingMilestones}
showOpsTabs={Boolean(linkedBookingId)}
showWorkflowFilesTab={phasedCustoms}
exchangeEntityId={id}
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
onViewFile={view}

View File

@@ -20,6 +20,7 @@ import {
AlertTriangle,
ClipboardList,
FileText,
Share2,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -33,6 +34,7 @@ import { PageHeader } from "@/components/page/PageHeader";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
import {
GlClearanceUploadModal,
type GlClearanceUploadKind,
@@ -228,6 +230,9 @@ export default function GlClearanceDetailPage() {
>
Customs documents (all steps)
</Tabs.Tab>
<Tabs.Tab value="exchange" leftSection={<Share2 size={14} />}>
Document exchange
</Tabs.Tab>
{incidentBookingId ? (
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
Incidents
@@ -342,6 +347,10 @@ export default function GlClearanceDetailPage() {
)}
</Tabs.Panel>
<Tabs.Panel value="exchange">
<GlExchangePanel entityId={id!} />
</Tabs.Panel>
{incidentBookingId ? (
<Tabs.Panel value="incidents">
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">

View File

@@ -10,6 +10,7 @@ export type ColumnFormat =
| "boolean"
| "activeBadge"
| "rateStatus"
| "validityBadge"
| "date"
| "number"
| "currency"
@@ -483,6 +484,39 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "isActive", label: "Active", type: "boolean" },
],
},
{
slug: "transit-agents",
label: "Transit Agents",
category: "configuration",
subtitle:
"Djibouti transit officers GL Djibouti may assign to a shipment — each carries a validity window",
searchPlaceholder: "Search transit agents by name...",
cardTitleKey: "name",
columns: [
{ id: "name", header: "Name", accessorKey: "name" },
{ id: "validFrom", header: "Valid from", accessorKey: "validFrom", format: "date" },
{ id: "validTo", header: "Valid to", accessorKey: "validTo", format: "date" },
{
id: "validityStatus",
header: "Validity",
accessorKey: "validityStatus",
format: "validityBadge",
},
activeColumn,
],
formFields: [
{ name: "name", label: "Name", type: "text", required: true },
{ name: "validFrom", label: "Valid from", type: "date", required: true },
{
name: "validTo",
label: "Valid to",
type: "date",
required: true,
description: "Expired or not-yet-started agents can't be assigned — extend the dates or add a new one",
},
{ name: "isActive", label: "Active", type: "boolean", description: "Off suspends the officer regardless of the validity window" },
],
},
{
slug: "yard-distances",
label: "Yard Distances",

View File

@@ -338,10 +338,10 @@ export const bookingsService = {
note,
}),
/** GL Djibouti names (or changes) that officer — unblocks the declaration. */
assignTransitAssignee: (id: string, assignee: string) =>
/** GL Djibouti picks (or changes) that officer — unblocks the declaration. */
assignTransitAssignee: (id: string, transitAgentId: string) =>
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), {
assignee,
transitAgentId,
}),
uploadDeclaration: async (
@@ -382,6 +382,22 @@ export const bookingsService = {
return unwrap(response.data) as BookingDetail;
},
uploadDraftDeclaration: async (
id: string,
files: File[],
price: number,
currency: string,
): Promise<BookingDetail> => {
const form = new FormData();
files.forEach((file, index) => form.append(`draft_declaration_${index}`, file));
form.append("price", String(price));
form.append("currency", currency);
const response = await client.post(B.CLEARANCE_DRAFT_DECLARATION(id), form, {
headers: { "Content-Type": "multipart/form-data" },
});
return unwrap(response.data) as BookingDetail;
},
finalizePreClearance: (id: string) =>
postBooking<BookingDetail>(B.CLEARANCE_FINALIZE_PRE(id)),

View File

@@ -331,10 +331,10 @@ export const contractsService = {
{ note },
),
/** GL Djibouti names (or changes) that officer — unblocks the declaration. */
assignTransitAssignee: (id: string, assignee: string) =>
/** GL Djibouti picks (or changes) that officer — unblocks the declaration. */
assignTransitAssignee: (id: string, transitAgentId: string) =>
postContract<Freight.IContract>(C.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), {
assignee,
transitAgentId,
}),
/**

View File

@@ -0,0 +1,60 @@
import type { Freight } from "@edr/types";
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
const X = URL_CONSTANTS.GL_EXCHANGE;
export interface GlExchangeUpload {
title: string;
visibleToCustomer: boolean;
file: File;
}
export interface GlExchangeEdit {
title?: string;
visibleToCustomer?: boolean;
/** Optional replacement bytes — omit to keep the stored file. */
file?: File | null;
}
const multipart = { headers: { "Content-Type": "multipart/form-data" } };
/** GL Ethiopia ↔ GL Djibouti shared documents for one booking or contract. */
export const glExchangeService = {
list: async (entityId: string): Promise<Freight.GlExchangeDocument[]> => {
const response = await client.get(X.FOR_ENTITY(entityId));
return (unwrap(response.data) ?? []) as Freight.GlExchangeDocument[];
},
upload: async (
entityId: string,
input: GlExchangeUpload,
): Promise<Freight.GlExchangeDocument> => {
const form = new FormData();
form.append("file", input.file);
form.append("title", input.title);
form.append("visibleToCustomer", String(input.visibleToCustomer));
const response = await client.post(X.FOR_ENTITY(entityId), form, multipart);
return unwrap(response.data) as Freight.GlExchangeDocument;
},
update: async (
documentId: string,
input: GlExchangeEdit,
): Promise<Freight.GlExchangeDocument> => {
const form = new FormData();
if (input.file) form.append("file", input.file);
if (input.title != null) form.append("title", input.title);
if (input.visibleToCustomer != null) {
form.append("visibleToCustomer", String(input.visibleToCustomer));
}
const response = await client.patch(X.DOCUMENT(documentId), form, multipart);
return unwrap(response.data) as Freight.GlExchangeDocument;
},
remove: async (documentId: string): Promise<void> => {
await client.delete(X.DOCUMENT(documentId));
},
};

View File

@@ -94,6 +94,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"shipping-lines": URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINES,
rates: URL_CONSTANTS.RULE_ENGINE.RATES,
"approval-rules": URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES,
"transit-agents": URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENTS,
};
const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {

View File

@@ -0,0 +1,20 @@
import { api } from "../auth/http";
import { URL_CONSTANTS } from "../constants/URLS";
export interface TransitAgent {
id: string;
name: string;
validFrom: string;
validTo: string;
isActive: boolean;
}
export const transitAgentsService = {
/** Active + currently inside its validity window — the assignment dropdown. */
async listAssignable() {
const response = await api.get<TransitAgent[]>(
URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENTS_ASSIGNABLE,
);
return response.data;
},
};

View File

@@ -10,7 +10,8 @@ export type RuleEngineResourceSlug =
| "yard-distances"
| "shipping-lines"
| "rates"
| "approval-rules";
| "approval-rules"
| "transit-agents";
/**
* Mirrors the API's shared `PaginationMeta` (@edr/types). The `has*` flags are

View File

@@ -168,6 +168,8 @@ export const URL_CONSTANTS = {
`/api/contracts/bookings/${bookingId}/milestones`,
BOOKING_DUTY_SLIP: (bookingId: string) =>
`/api/contracts/bookings/${bookingId}/duty-slip`,
BOOKING_FINAL_INVOICE_APPROVE: (bookingId: string) =>
`/api/contracts/bookings/${bookingId}/final-invoice/approve`,
BOOKING_FINAL_INVOICE_SLIP: (bookingId: string) =>
`/api/contracts/bookings/${bookingId}/final-invoice-slip`,
BOOKING_SECOND_DUTY_SLIP: (bookingId: string) =>

View File

@@ -14,6 +14,7 @@ import {
} from "@mantine/core";
import {
AlertTriangle,
Check,
Download,
Eye,
FileBadge,
@@ -86,9 +87,11 @@ export function BookingClearanceWorkflowBanner({
clearance.dutyRequired &&
clearance.dutyAdvice &&
!dutyPaid;
// A dispute clears the advice while it's open — show the "waiting on GL"
// state instead of the pay panel until GL re-advises.
const dutyDisputePending = Boolean(clearance.dutyDispute);
// A change request clears the draft while it's open — show the "waiting on
// GL" state instead of the review panel until GL sends a corrected draft.
const draftDeclarationChangeRequestPending = Boolean(
clearance.draftDeclarationChangeRequest,
);
return (
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
@@ -114,9 +117,20 @@ export function BookingClearanceWorkflowBanner({
</Alert>
) : null}
{dutyDisputePending && clearance.dutyDispute ? (
<DutyDisputePendingCard dispute={clearance.dutyDispute} />
) : dutyPending && clearance.dutyAdvice ? (
{draftDeclarationChangeRequestPending && clearance.draftDeclarationChangeRequest ? (
<DraftDeclarationChangeRequestedCard
changeRequest={clearance.draftDeclarationChangeRequest}
/>
) : clearance.draftDeclaration && !clearance.draftDeclaration.accepted ? (
<DraftDeclarationPanel
draftDeclaration={clearance.draftDeclaration}
bookingId={booking.id}
onView={(f) => view(f)}
onChanged={() => void refetch()}
/>
) : null}
{dutyPending && clearance.dutyAdvice ? (
<DutyAdvicePanel
dutyAdvice={clearance.dutyAdvice}
bookingId={booking.id}
@@ -193,9 +207,6 @@ function DutyAdvicePanel({
}) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [disputing, setDisputing] = useState(false);
const [note, setNote] = useState("");
const [submittingDispute, setSubmittingDispute] = useState(false);
const noticeFile = dutyAdvice.noticeFile;
return (
@@ -250,11 +261,68 @@ function DutyAdvicePanel({
Submit payment slip
</Button>
{disputing ? (
</Stack>
</Paper>
);
}
/**
* GL Ethiopia sent a draft customs declaration — an estimated price + files
* the customer must accept before the real declaration is filed, or send back
* with a note asking for a change.
*/
function DraftDeclarationPanel({
draftDeclaration,
bookingId,
onView,
onChanged,
}: {
draftDeclaration: NonNullable<Freight.ClearanceView["draftDeclaration"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [accepting, setAccepting] = useState(false);
const [requestingChange, setRequestingChange] = useState(false);
const [note, setNote] = useState("");
const [submitting, setSubmitting] = useState(false);
return (
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
<Stack gap="sm">
<GroupLabel icon={FileBadge} text="Draft customs declaration" />
<Text size="sm">
Estimated price:{" "}
<strong>
{draftDeclaration.price.toLocaleString()} {draftDeclaration.currency}
</strong>
</Text>
<Stack gap={4}>
{draftDeclaration.files.map((file, index) => (
<Anchor
key={file.id}
component="button"
type="button"
onClick={() => onView({ name: file.name, url: file.url })}
size="sm"
>
<Group gap={6} wrap="nowrap">
<Eye size={14} />
Draft declaration document {index + 1} ({file.name})
</Group>
</Anchor>
))}
</Stack>
<Text size="sm" c="dimmed">
Review the draft above. Accept it to let GL Ethiopia proceed with the
real customs declaration, or request a change if something is wrong.
</Text>
{requestingChange ? (
<Stack gap={6}>
<Textarea
label="What's wrong with this amount?"
placeholder="Explain why you're disputing the advised duty/tax…"
label="What needs to change?"
placeholder="Explain what should be corrected on the draft…"
minRows={2}
autosize
value={note}
@@ -265,7 +333,7 @@ function DutyAdvicePanel({
variant="default"
size="xs"
onClick={() => {
setDisputing(false);
setRequestingChange(false);
setNote("");
}}
>
@@ -274,23 +342,23 @@ function DutyAdvicePanel({
<Button
color="red"
size="xs"
loading={submittingDispute}
loading={submitting}
disabled={!note.trim()}
onClick={async () => {
setSubmittingDispute(true);
setSubmitting(true);
try {
await bookingsService.disputeBookingClearanceDuty(
await bookingsService.requestDraftDeclarationChange(
bookingId,
note.trim(),
);
toast.success("Sent to GL Ethiopia for review");
setDisputing(false);
setRequestingChange(false);
setNote("");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to send");
} finally {
setSubmittingDispute(false);
setSubmitting(false);
}
}}
>
@@ -299,26 +367,48 @@ function DutyAdvicePanel({
</Group>
</Stack>
) : (
<Anchor
component="button"
type="button"
size="sm"
c="dimmed"
onClick={() => setDisputing(true)}
>
Not right? Request a change
</Anchor>
<Group gap="xs">
<Button
color="edr-green"
size="sm"
loading={accepting}
leftSection={<Check size={15} />}
onClick={async () => {
setAccepting(true);
try {
await bookingsService.acceptDraftDeclaration(bookingId);
toast.success("Draft accepted");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to accept");
} finally {
setAccepting(false);
}
}}
>
Accept draft
</Button>
<Anchor
component="button"
type="button"
size="sm"
c="dimmed"
onClick={() => setRequestingChange(true)}
>
Not right? Request a change
</Anchor>
</Group>
)}
</Stack>
</Paper>
);
}
/** The customer's dispute is open — GL Ethiopia owes a corrected advice. */
function DutyDisputePendingCard({
dispute,
/** The customer's change request is open — GL Ethiopia owes a corrected draft. */
function DraftDeclarationChangeRequestedCard({
changeRequest,
}: {
dispute: NonNullable<Freight.ClearanceView["dutyDispute"]>;
changeRequest: NonNullable<Freight.ClearanceView["draftDeclarationChangeRequest"]>;
}) {
return (
<Alert
@@ -326,18 +416,18 @@ function DutyDisputePendingCard({
radius="md"
icon={<MessageSquareWarning size={16} />}
title={
dispute.rounds > 1
? `Waiting on GL Ethiopia (round ${dispute.rounds})`
changeRequest.rounds > 1
? `Waiting on GL Ethiopia (round ${changeRequest.rounds})`
: "Waiting on GL Ethiopia"
}
>
<Stack gap={4}>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{dispute.note}
{changeRequest.note}
</Text>
<Text size="xs" c="dimmed">
Sent {new Date(dispute.raisedAt).toLocaleString()} you'll see the
corrected amount here once GL Ethiopia re-advises.
Sent {new Date(changeRequest.raisedAt).toLocaleString()} you'll see
the corrected draft here once GL Ethiopia sends it.
</Text>
</Stack>
</Alert>
@@ -373,7 +463,11 @@ function FinalInvoiceDueCard({
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [approving, setApproving] = useState(false);
const paid = invoice.status === "PAID";
// GL Djibouti raises it as a draft: nothing is payable until the customer
// reviews the attached invoice and approves it.
const approved = Boolean(invoice.approvedAt);
return (
<Paper
@@ -402,11 +496,17 @@ function FinalInvoiceDueCard({
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid ? "Final invoice paid" : "Final invoice due"} {" "}
{invoice.invoiceNumber}
{paid
? "Final invoice paid"
: approved
? "Final invoice due"
: "Final invoice — your approval needed"}{" "}
{invoice.invoiceNumber}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{invoiceStatusLabel(invoice.status)}
{approved
? invoiceStatusLabel(invoice.status)
: "Awaiting your approval"}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
@@ -419,8 +519,9 @@ function FinalInvoiceDueCard({
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
Pay the amount above and attach your payment slip Global
Logistics will confirm the payment.
{approved
? "Pay the amount above and attach your payment slip — Global Logistics will confirm the payment."
: "Review the invoice document from Global Logistics Djibouti and approve it to proceed with payment."}
</Text>
) : null}
</div>
@@ -458,7 +559,30 @@ function FinalInvoiceDueCard({
View payment slip
</Button>
) : null}
{!paid ? (
{!paid && !approved ? (
<Button
color="edr-green"
radius="md"
size="sm"
loading={approving}
leftSection={<Check size={15} />}
onClick={async () => {
setApproving(true);
try {
await contractsService.approveFinalInvoice(bookingId);
toast.success("Invoice approved — you can now pay");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Approval failed");
} finally {
setApproving(false);
}
}}
>
Approve invoice
</Button>
) : null}
{!paid && approved ? (
<>
<FileInput
placeholder={

View File

@@ -393,6 +393,31 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
</SectionCard>
)}
{/* ── 1b. Documents Global Logistics shared with you ──────────────── */}
{(clearance?.exchangeDocuments?.length ?? 0) > 0 && (
<SectionCard>
<CardTitle>Shared by Global Logistics</CardTitle>
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
Documents the clearance team shared with you for this shipment.
</Text>
<Stack gap={0}>
{clearance!.exchangeDocuments!.map((doc, i, arr) => (
<FileRow
key={doc.id}
title={doc.title}
meta={`${doc.file.name} · shared ${new Date(
doc.uploadedAt,
).toLocaleDateString()}`}
file={doc.file}
pill={<Pill tone="green" label="Shared with you" />}
last={i === arr.length - 1}
onView={view}
/>
))}
</Stack>
</SectionCard>
)}
{/* ── 2. Contract & company documents ─────────────────────────────── */}
{hasContract && contract && (
<SectionCard>

View File

@@ -398,13 +398,21 @@ export const bookingsService = {
return data.data ?? data;
},
disputeBookingClearanceDuty: async (
acceptDraftDeclaration: async (id: string): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${id}/clearance/draft-declaration/accept`,
);
return data.data ?? data;
},
requestDraftDeclarationChange: async (
id: string,
note: string,
): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/clearance/duty/dispute`, {
note,
});
const { data } = await client.post(
`/api/bookings/${id}/clearance/draft-declaration/change`,
{ note },
);
return data.data ?? data;
},

View File

@@ -439,6 +439,14 @@ export const contractsService = {
return data.data ?? data;
},
/** Customer approves the drafted GL final invoice — unlocks the slip upload. */
approveFinalInvoice: async (bookingId: string): Promise<unknown> => {
const { data } = await client.post(
C.BOOKING_FINAL_INVOICE_APPROVE(bookingId),
);
return data.data ?? data;
},
/** Customer attaches the payment slip for the GL final invoice (export). */
uploadFinalInvoiceSlip: async (
bookingId: string,

View File

@@ -3,6 +3,7 @@ export type ClearanceWorkflowFileOwner = "customer" | "gl_et" | "gl_dj";
export type ClearanceWorkflowFileCategory =
| "declaration"
| "draft_declaration"
| "duty"
| "transit"
| "djibouti";
@@ -120,6 +121,18 @@ export function declarationFileLabel(code: string, index?: number): string {
return code;
}
/** Multi-file draft declaration uploads use `draft_declaration_0`, `draft_declaration_1`, … */
export const DRAFT_DECLARATION_FILE_PREFIX = "draft_declaration_";
export function isDraftDeclarationFileCode(code: string | null | undefined): boolean {
if (!code) return false;
return code.toLowerCase().startsWith(DRAFT_DECLARATION_FILE_PREFIX);
}
export function draftDeclarationFileLabel(index?: number): string {
return index != null ? `Draft declaration document ${index + 1}` : "Draft declaration";
}
/** Legacy single import transit permit code. */
export const LEGACY_IMPORT_TRANSIT_PERMIT_CODE = "transit_permitted";

View File

@@ -409,12 +409,34 @@ export interface ClearanceTrainState {
arrivedAt: string | null;
}
/**
* Offload facts for the clearance booking, read-only: what came off the train
* at its destination and where it went. Populated for both directions once the
* booking has ridden a train; every field stays null/0 before that.
*/
export interface ClearanceOffloadState {
offloaded: boolean;
/** When the OFFLOADED milestone completed (falls back to the unload stamp). */
offloadedAt: string | null;
/** Yard the cargo alighted at. */
destination: string | null;
containers: number;
wagons: number;
/** Tonnes recorded on unload (booked VGM until the warehouse records it). */
weightTons: number | null;
grnNumber: string | null;
/** Warehouse yard zone the goods went into. */
location: string | null;
inventoryStatus: string | null;
}
/** Billing invoice `type` for the GL Djibouti post-offload final invoice. */
export const GL_FINAL_INVOICE_TYPE = "GL_FINAL";
/**
* Post-offload final invoice raised by GL Djibouti: customer pays offline and
* attaches a slip; GL (ET or DJ) confirms to mark it paid.
* Post-offload final invoice raised by GL Djibouti: it lands as a DRAFT the
* customer must approve, after which they pay offline and attach a slip; GL
* (ET or DJ) confirms to mark it paid.
*/
export interface ClearanceFinalInvoiceSummary {
id: string;
@@ -425,6 +447,8 @@ export interface ClearanceFinalInvoiceSummary {
description?: string | null;
invoiceFile: { id: string; name: string; url: string } | null;
slipFile: { id: string; name: string; url: string } | null;
/** When the customer approved the invoice (null while it is still a draft). */
approvedAt: string | null;
confirmedAt: string | null;
}
@@ -505,10 +529,23 @@ export interface ContractClearanceView {
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
/**
* The customer's open objection to the advised duty. Present only until GL
* re-advises; `rounds` counts how many times it has been sent back.
* Import only: the draft customs declaration GL Ethiopia sends before filing
* the real one — an estimated price + files the customer must accept (or send
* back with a change request) before the "Customs declaration" step unlocks.
* Present once a draft has been uploaded, regardless of accept state.
*/
dutyDispute?: {
draftDeclaration?: {
price: number;
currency: string;
files: Array<{ id: string; name: string; url: string }>;
accepted: boolean;
} | null;
/**
* The customer's open change request on the current draft declaration.
* Present only until GL sends a corrected draft; `rounds` counts how many
* times it has been sent back.
*/
draftDeclarationChangeRequest?: {
note: string;
raisedAt: string;
rounds: number;
@@ -524,6 +561,8 @@ export interface ContractClearanceView {
t1Closed?: boolean;
t1ClosedAt?: string | null;
offloaded?: boolean;
/** Offload stats for the linked booking (null until one exists). */
offload?: ClearanceOffloadState | null;
/** GL Djibouti post-offload final invoice (export). */
finalInvoice?: ClearanceFinalInvoiceSummary | null;
/** Customs risk level assigned by GL ET (import; visible to the customer). */
@@ -588,6 +627,8 @@ export interface MilestoneMetadata {
dutyAmount?: number;
dutyCurrency?: string;
declarationSerial?: string;
draftDeclarationPrice?: number;
draftDeclarationCurrency?: string;
}
export interface IClearanceMilestone {
@@ -633,6 +674,8 @@ export const IMPORT_MILESTONES = [
"IMPORT_DOCS_UPLOADED",
"PENDING_DOCUMENT_REVIEW",
"DOCUMENTS_APPROVED",
"DRAFT_DECLARATION_UPLOADED",
"DRAFT_DECLARATION_ACCEPTED",
"UNDER_CUSTOMS_CLEARANCE",
"DECLARED",
"DUTY_TAXES_ADVISED",
@@ -664,9 +707,9 @@ export const EXPORT_MILESTONES = [
"EXPORT_DOCS_UPLOADED",
"PENDING_DOCUMENT_REVIEW",
"DOCUMENTS_APPROVED",
"RELEASE_ORDER_SECURED",
"UNDER_CUSTOMS_CLEARANCE",
"DECLARED",
"RELEASE_ORDER_SECURED",
"EXPORT_RELEASED",
"WAGON_REQUESTED",
"FREIGHT_PAYMENT_PENDING",

View File

@@ -759,6 +759,34 @@ export interface ClearanceDocument {
note: string | null;
}
/**
* One document in the GL Ethiopia ↔ GL Djibouti exchange: a free-titled file
* either desk attaches to a shipment (booking) or a contract clearance. Both
* desks see every document; only the uploader may change or remove one, and
* only documents the uploader marked visible reach the customer's portal.
*/
export interface GlExchangeDocument {
id: string;
/** Booking or contract id the document is attached to. */
entityId: string;
title: string;
/** Desk that uploaded it. */
side: "ET" | "DJ";
visibleToCustomer: boolean;
uploadedById: string | null;
uploadedByName: string | null;
uploadedAt: string;
file: {
id: string;
name: string;
url: string;
size: number;
mimeType: string;
};
/** True when the requesting user uploaded it (edit/replace/remove allowed). */
canEdit: boolean;
}
/** The clearance view for a booking, driving both portals' clearance UI. */
export interface ClearanceView {
status: string;
@@ -801,16 +829,35 @@ export interface ClearanceView {
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
/**
* The customer's open objection to the advised duty. Present only until GL
* re-advises; `rounds` counts how many times it has been sent back.
* Import only: the draft customs declaration GL Ethiopia sends before filing
* the real one — an estimated price + files the customer must accept (or send
* back with a change request) before the "Customs declaration" step unlocks.
* Present once a draft has been uploaded, regardless of accept state.
*/
dutyDispute?: {
draftDeclaration?: {
price: number;
currency: string;
files: Array<{ id: string; name: string; url: string }>;
accepted: boolean;
} | null;
/**
* The customer's open change request on the current draft declaration.
* Present only until GL sends a corrected draft; `rounds` counts how many
* times it has been sent back.
*/
draftDeclarationChangeRequest?: {
note: string;
raisedAt: string;
rounds: number;
} | null;
/** Phased customs uploads (IM4, DO, transit permit, etc.) with friendly labels. */
workflowFiles?: import("./clearance-files.catalog").ClearanceWorkflowFile[];
/**
* GL-shared documents the uploader marked visible to the customer — the
* customer-facing slice of the GL Ethiopia ↔ GL Djibouti exchange, covering
* both this booking's thread and its contract's.
*/
exchangeDocuments?: GlExchangeDocument[];
/** Import post-allocation T1 transit document state (null until wagon allocation). */
t1?: import("./contracts").ClearanceT1State | null;
/** Train link state for the booking (both directions). */
@@ -820,6 +867,8 @@ export interface ClearanceView {
t1Closed?: boolean;
t1ClosedAt?: string | null;
offloaded?: boolean;
/** Offload stats for this booking (what came off the train, and where). */
offload?: import("./contracts").ClearanceOffloadState | null;
/** GL Djibouti post-offload final invoice (export). */
finalInvoice?: import("./contracts").ClearanceFinalInvoiceSummary | null;
/** Customs risk level assigned by GL ET (import; visible to the customer). */