mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
|
||||
import { CreateTransitAgentDto } from './create-transit-agent.dto';
|
||||
|
||||
export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)),
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user