mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 22:25:42 +00:00
Enhance clearance milestone management and introduce new contract actions
- Added new milestones for 'Transit Permit Uploaded' and 'Export Transport Document Issued' in the clearance milestone catalog. - Implemented methods in ClearanceMilestoneService to skip milestones and complete them with metadata. - Updated ContractBookingService to check boundary conditions before booking creation. - Introduced new endpoints in ContractsController for uploading customs declarations, advising duty, and handling various document uploads. - Enhanced the UI to support new clearance actions and display relevant components based on milestone statuses.
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class PhasedClearanceCycleMeta1829000000000 implements MigrationInterface {
|
||||||
|
name = 'PhasedClearanceCycleMeta1829000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS current_phase VARCHAR(40);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS duty_required;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS vessel_departure_date;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_amendment_requested_at;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_hold_reason;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS current_phase;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/** Admin-configurable minimum days between today and export RO vessel departure. */
|
||||||
|
export class SeedRoVesselMinDays1829000000001 implements MigrationInterface {
|
||||||
|
name = 'SeedRoVesselMinDays1829000000001';
|
||||||
|
private readonly code = 'ro_vessel_min_days';
|
||||||
|
private readonly options: Array<{ value: string; label: string }> = [
|
||||||
|
{ value: '2', label: '2 days' },
|
||||||
|
{ value: '3', label: '3 days' },
|
||||||
|
];
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const existing = await queryRunner.query(
|
||||||
|
`SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`,
|
||||||
|
[this.code],
|
||||||
|
);
|
||||||
|
if (existing.length > 0) return;
|
||||||
|
|
||||||
|
const inserted = await queryRunner.query(
|
||||||
|
`INSERT INTO freight.dropdown_settings (code, label, description, multiple)
|
||||||
|
VALUES ($1, $2, $3, false)
|
||||||
|
RETURNING id;`,
|
||||||
|
[
|
||||||
|
this.code,
|
||||||
|
'RO vessel minimum lead time (days)',
|
||||||
|
'Minimum days between today and the vessel departure date on an export Release Order.',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const settingId = inserted[0].id;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.options.length; i++) {
|
||||||
|
const opt = this.options[i];
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
|
||||||
|
VALUES ($1, $2, $3, $4);`,
|
||||||
|
[settingId, opt.value, opt.label, i],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [
|
||||||
|
this.code,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,11 @@ const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
|||||||
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
|
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
|
||||||
DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false },
|
DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false },
|
||||||
DUTY_TAX_PAID: { label: 'Duty and Tax Paid', ownerRegion: 'CUST', triggeredByDoc: true },
|
DUTY_TAX_PAID: { label: 'Duty and Tax Paid', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||||
|
TRANSIT_PERMIT_UPLOADED: {
|
||||||
|
label: 'Transit Permit Uploaded',
|
||||||
|
ownerRegion: 'ET',
|
||||||
|
triggeredByDoc: true,
|
||||||
|
},
|
||||||
DO_COLLECTED: { label: 'DO Collected', ownerRegion: 'DJ', triggeredByDoc: true },
|
DO_COLLECTED: { label: 'DO Collected', ownerRegion: 'DJ', triggeredByDoc: true },
|
||||||
WAGON_REQUESTED: { label: 'Wagon Allocation Requested', ownerRegion: 'ET', triggeredByDoc: false },
|
WAGON_REQUESTED: { label: 'Wagon Allocation Requested', ownerRegion: 'ET', triggeredByDoc: false },
|
||||||
FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled (freight)', ownerRegion: 'CUST', triggeredByDoc: true },
|
FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled (freight)', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||||
@@ -52,6 +57,11 @@ const EXPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
|||||||
FREIGHT_PAYMENT_PENDING: { label: 'Pending Payment', ownerRegion: 'CUST', triggeredByDoc: false },
|
FREIGHT_PAYMENT_PENDING: { label: 'Pending Payment', ownerRegion: 'CUST', triggeredByDoc: false },
|
||||||
FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled', ownerRegion: 'CUST', triggeredByDoc: true },
|
FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||||
WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false },
|
WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||||
|
EXPORT_TRANSPORT_ISSUED: {
|
||||||
|
label: 'Export Transport Document Issued',
|
||||||
|
ownerRegion: 'ET',
|
||||||
|
triggeredByDoc: true,
|
||||||
|
},
|
||||||
CARGO_ARRIVED: { label: 'Cargo Arrived', ownerRegion: 'OPS', triggeredByDoc: false },
|
CARGO_ARRIVED: { label: 'Cargo Arrived', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||||
READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'OPS', triggeredByDoc: false },
|
READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||||
LOADED: { label: 'Loaded', ownerRegion: 'OPS', triggeredByDoc: false },
|
LOADED: { label: 'Loaded', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||||
|
|||||||
@@ -187,6 +187,58 @@ export class ClearanceMilestoneService {
|
|||||||
return this.repo.save(milestone);
|
return this.repo.save(milestone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Skip optional milestones (e.g. duty when not required). */
|
||||||
|
async skipForContract(contractId: string, code: string): Promise<ClearanceMilestone> {
|
||||||
|
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
|
||||||
|
if (!milestone) {
|
||||||
|
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
|
||||||
|
}
|
||||||
|
if (milestone.status === 'COMPLETED') return milestone;
|
||||||
|
milestone.status = 'SKIPPED';
|
||||||
|
milestone.triggeredAt = new Date();
|
||||||
|
return this.repo.save(milestone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complete a contract milestone with structured metadata (duty advice, etc.). */
|
||||||
|
async completeWithMetadataForContract(
|
||||||
|
contractId: string,
|
||||||
|
code: string,
|
||||||
|
metadata: MilestoneMetadata,
|
||||||
|
userId?: string,
|
||||||
|
note?: string,
|
||||||
|
): Promise<ClearanceMilestone> {
|
||||||
|
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
|
||||||
|
if (!milestone) {
|
||||||
|
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
|
||||||
|
}
|
||||||
|
if (milestone.status === 'COMPLETED') {
|
||||||
|
throw new BadRequestException(`Milestone ${code} is already completed.`);
|
||||||
|
}
|
||||||
|
milestone.status = 'COMPLETED';
|
||||||
|
milestone.triggeredAt = new Date();
|
||||||
|
milestone.triggeredByUserId = userId ?? null;
|
||||||
|
milestone.metadata = { ...(milestone.metadata ?? {}), ...metadata };
|
||||||
|
if (note) milestone.note = note;
|
||||||
|
return this.repo.save(milestone);
|
||||||
|
}
|
||||||
|
|
||||||
|
async adviseDutyForContract(
|
||||||
|
contractId: string,
|
||||||
|
input: { amount: number; currency: string; declarationSerial?: string },
|
||||||
|
userId?: string,
|
||||||
|
): Promise<ClearanceMilestone> {
|
||||||
|
return this.completeWithMetadataForContract(
|
||||||
|
contractId,
|
||||||
|
'DUTY_TAXES_ADVISED',
|
||||||
|
{
|
||||||
|
dutyAmount: input.amount,
|
||||||
|
dutyCurrency: input.currency,
|
||||||
|
declarationSerial: input.declarationSerial,
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Complete a doc-triggered milestone when its document is uploaded/approved. */
|
/** Complete a doc-triggered milestone when its document is uploaded/approved. */
|
||||||
async completeByDocTrigger(
|
async completeByDocTrigger(
|
||||||
scope: { bookingId?: string; contractId?: string },
|
scope: { bookingId?: string; contractId?: string },
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { ContractDocPhase } from '@edr/types';
|
||||||
|
|
||||||
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
|
import type { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||||
|
import type { Contract } from './entities/contract.entity';
|
||||||
|
import type { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||||
|
|
||||||
|
function ms(
|
||||||
|
code: string,
|
||||||
|
status: 'PENDING' | 'COMPLETED' | 'SKIPPED',
|
||||||
|
ownerRegion: 'ET' | 'DJ' | 'CUST' | 'OPS' = 'ET',
|
||||||
|
): ClearanceMilestone {
|
||||||
|
return { milestoneCode: code, status, ownerRegion } as ClearanceMilestone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function importThroughDeclaration(): ClearanceMilestone[] {
|
||||||
|
return [
|
||||||
|
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||||
|
ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'),
|
||||||
|
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||||
|
ms('UNDER_CUSTOMS_CLEARANCE', 'PENDING', 'ET'),
|
||||||
|
ms('DECLARED', 'PENDING', 'ET'),
|
||||||
|
ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'),
|
||||||
|
ms('DUTY_TAX_PAID', 'PENDING', 'CUST'),
|
||||||
|
ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'),
|
||||||
|
ms('DO_COLLECTED', 'PENDING', 'DJ'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeService(milestones: ClearanceMilestone[]) {
|
||||||
|
const contractsRepository = {
|
||||||
|
currentCycle: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
setCycleStatus: jest.fn(),
|
||||||
|
updateCycle: jest.fn(),
|
||||||
|
};
|
||||||
|
const milestoneService = {
|
||||||
|
listForContract: jest.fn().mockResolvedValue(milestones),
|
||||||
|
skipForContract: jest.fn(),
|
||||||
|
completeForContract: jest.fn(),
|
||||||
|
completeWithMetadataForContract: jest.fn(),
|
||||||
|
};
|
||||||
|
const service = new ClearanceWorkflowService(
|
||||||
|
contractsRepository as never,
|
||||||
|
milestoneService as never,
|
||||||
|
);
|
||||||
|
return { service, milestoneService, contractsRepository };
|
||||||
|
}
|
||||||
|
|
||||||
|
const importContract = {
|
||||||
|
id: 'c-import',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
contractKind: 'ONE_TIME',
|
||||||
|
} as Contract;
|
||||||
|
|
||||||
|
const exportContract = {
|
||||||
|
id: 'c-export',
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
contractKind: 'ONE_TIME',
|
||||||
|
} as Contract;
|
||||||
|
|
||||||
|
describe('ClearanceWorkflowService', () => {
|
||||||
|
describe('boundaryMilestone', () => {
|
||||||
|
it('uses DO_COLLECTED for import and EXPORT_RELEASED for export', () => {
|
||||||
|
const { service } = makeService([]);
|
||||||
|
expect(service.boundaryMilestone('IMPORT')).toBe('DO_COLLECTED');
|
||||||
|
expect(service.boundaryMilestone('EXPORT')).toBe('EXPORT_RELEASED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertPriorComplete', () => {
|
||||||
|
it('rejects when a prior milestone is still pending', async () => {
|
||||||
|
const milestones = importThroughDeclaration().map((m) =>
|
||||||
|
m.milestoneCode === 'DOCUMENTS_APPROVED'
|
||||||
|
? ms('DOCUMENTS_APPROVED', 'PENDING', 'ET')
|
||||||
|
: m,
|
||||||
|
);
|
||||||
|
const { service } = makeService(milestones);
|
||||||
|
await expect(
|
||||||
|
service.assertPriorComplete('c-import', 'IMPORT', 'DECLARED'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows proceeding when prior milestones are completed or skipped', async () => {
|
||||||
|
const milestones = [
|
||||||
|
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||||
|
ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'),
|
||||||
|
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||||
|
ms('UNDER_CUSTOMS_CLEARANCE', 'COMPLETED', 'ET'),
|
||||||
|
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||||
|
ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'),
|
||||||
|
ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'),
|
||||||
|
ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'),
|
||||||
|
ms('DO_COLLECTED', 'PENDING', 'DJ'),
|
||||||
|
];
|
||||||
|
const { service } = makeService(milestones);
|
||||||
|
await expect(
|
||||||
|
service.assertPriorComplete('c-import', 'IMPORT', 'TRANSIT_PERMIT_UPLOADED'),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isBoundaryComplete', () => {
|
||||||
|
it('returns true only when boundary milestone is completed', async () => {
|
||||||
|
const done = [
|
||||||
|
...importThroughDeclaration().slice(0, -1),
|
||||||
|
ms('DO_COLLECTED', 'COMPLETED', 'DJ'),
|
||||||
|
];
|
||||||
|
const { service: doneSvc } = makeService(done);
|
||||||
|
await expect(doneSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(true);
|
||||||
|
|
||||||
|
const pending = importThroughDeclaration();
|
||||||
|
const { service: pendingSvc } = makeService(pending);
|
||||||
|
await expect(pendingSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onDutySkipped', () => {
|
||||||
|
it('skips duty milestones on the contract', async () => {
|
||||||
|
const { service, milestoneService } = makeService([]);
|
||||||
|
await service.onDutySkipped('c-import');
|
||||||
|
expect(milestoneService.skipForContract).toHaveBeenCalledWith(
|
||||||
|
'c-import',
|
||||||
|
'DUTY_TAXES_ADVISED',
|
||||||
|
);
|
||||||
|
expect(milestoneService.skipForContract).toHaveBeenCalledWith(
|
||||||
|
'c-import',
|
||||||
|
'DUTY_TAX_PAID',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeNextAction — import happy path', () => {
|
||||||
|
it('prompts customer to upload docs first', () => {
|
||||||
|
const { service } = makeService([ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST')]);
|
||||||
|
const next = service.computeNextAction(importContract, null, [
|
||||||
|
ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST'),
|
||||||
|
]);
|
||||||
|
expect(next?.actor).toBe('CUSTOMER');
|
||||||
|
expect(next?.milestoneCode).toBe('IMPORT_DOCS_UPLOADED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prompts ET review after customer docs', () => {
|
||||||
|
const milestones = [
|
||||||
|
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||||
|
ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'),
|
||||||
|
];
|
||||||
|
const { service } = makeService(milestones);
|
||||||
|
const next = service.computeNextAction(importContract, null, milestones);
|
||||||
|
expect(next?.actor).toBe('GL_ET');
|
||||||
|
expect(next?.action).toMatch(/Review/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prompts duty toggle when declaration done and duty unset', () => {
|
||||||
|
const milestones = [
|
||||||
|
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||||
|
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||||
|
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||||
|
ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'),
|
||||||
|
];
|
||||||
|
const cycle = { dutyRequired: null } as ContractClearanceCycle;
|
||||||
|
const { service } = makeService(milestones);
|
||||||
|
const next = service.computeNextAction(importContract, cycle, milestones);
|
||||||
|
expect(next?.actor).toBe('GL_ET');
|
||||||
|
expect(next?.action).toMatch(/duty/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prompts customer duty slip when duty required and advised', () => {
|
||||||
|
const milestones = [
|
||||||
|
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||||
|
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||||
|
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||||
|
ms('DUTY_TAXES_ADVISED', 'COMPLETED', 'ET'),
|
||||||
|
ms('DUTY_TAX_PAID', 'PENDING', 'CUST'),
|
||||||
|
];
|
||||||
|
const cycle = { dutyRequired: true } as ContractClearanceCycle;
|
||||||
|
const { service } = makeService(milestones);
|
||||||
|
const next = service.computeNextAction(importContract, cycle, milestones);
|
||||||
|
expect(next?.actor).toBe('CUSTOMER');
|
||||||
|
expect(next?.milestoneCode).toBe('DUTY_TAX_PAID');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips duty path when duty not required', () => {
|
||||||
|
const milestones = [
|
||||||
|
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||||
|
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||||
|
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||||
|
ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'),
|
||||||
|
ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'),
|
||||||
|
ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'),
|
||||||
|
];
|
||||||
|
const cycle = { dutyRequired: false } as ContractClearanceCycle;
|
||||||
|
const { service } = makeService(milestones);
|
||||||
|
const next = service.computeNextAction(importContract, cycle, milestones);
|
||||||
|
expect(next?.actor).toBe('GL_ET');
|
||||||
|
expect(next?.milestoneCode).toBe('TRANSIT_PERMIT_UPLOADED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prompts DJ for DO then ET booking when pre-booking complete', () => {
|
||||||
|
const milestones = [
|
||||||
|
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||||
|
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||||
|
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||||
|
ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'),
|
||||||
|
ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'),
|
||||||
|
ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'),
|
||||||
|
ms('DO_COLLECTED', 'PENDING', 'DJ'),
|
||||||
|
];
|
||||||
|
const cycle = { dutyRequired: false } as ContractClearanceCycle;
|
||||||
|
const { service } = makeService(milestones);
|
||||||
|
const djNext = service.computeNextAction(importContract, cycle, milestones);
|
||||||
|
expect(djNext?.actor).toBe('GL_DJ');
|
||||||
|
|
||||||
|
const booked = milestones.map((m) =>
|
||||||
|
m.milestoneCode === 'DO_COLLECTED' ? ms('DO_COLLECTED', 'COMPLETED', 'DJ') : m,
|
||||||
|
);
|
||||||
|
const etNext = service.computeNextAction(importContract, cycle, booked);
|
||||||
|
expect(etNext?.actor).toBe('GL_ET');
|
||||||
|
expect(etNext?.action).toMatch(/booking/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeNextAction — export RO hold', () => {
|
||||||
|
it('surfaces DJ action when RO is on hold', () => {
|
||||||
|
const milestones = [
|
||||||
|
ms('EXPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||||
|
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||||
|
ms('RELEASE_ORDER_SECURED', 'PENDING', 'DJ'),
|
||||||
|
];
|
||||||
|
const cycle = {
|
||||||
|
roHoldReason: 'Vessel departs in 1 day(s) — minimum lead time is 2 day(s).',
|
||||||
|
} as ContractClearanceCycle;
|
||||||
|
const { service } = makeService(milestones);
|
||||||
|
const next = service.computeNextAction(exportContract, cycle, milestones);
|
||||||
|
expect(next?.actor).toBe('GL_DJ');
|
||||||
|
expect(next?.blockedReason).toMatch(/minimum lead time/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inferPhase', () => {
|
||||||
|
it('places import contract in customer duty phase when duty outstanding', () => {
|
||||||
|
const milestones = [
|
||||||
|
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||||
|
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||||
|
ms('DECLARED', 'COMPLETED', 'ET'),
|
||||||
|
ms('DUTY_TAX_PAID', 'PENDING', 'CUST'),
|
||||||
|
];
|
||||||
|
const cycle = { dutyRequired: true } as ContractClearanceCycle;
|
||||||
|
const { service } = makeService(milestones);
|
||||||
|
const phase = service.inferPhase(importContract, cycle, milestones);
|
||||||
|
expect(phase).toBe(ContractDocPhase.CustomerDuty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('queue helpers', () => {
|
||||||
|
it('returns first pending ET-owned milestone code', () => {
|
||||||
|
const { service } = makeService([
|
||||||
|
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||||
|
ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'),
|
||||||
|
ms('DO_COLLECTED', 'PENDING', 'DJ'),
|
||||||
|
]);
|
||||||
|
expect(service.etPendingMilestoneCodes([])).toBeNull();
|
||||||
|
expect(
|
||||||
|
service.etPendingMilestoneCodes([
|
||||||
|
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||||
|
ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'),
|
||||||
|
]),
|
||||||
|
).toBe('DOCUMENTS_APPROVED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns first pending DJ-owned milestone code', () => {
|
||||||
|
const { service } = makeService([]);
|
||||||
|
expect(
|
||||||
|
service.djPendingMilestoneCodes([
|
||||||
|
ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'),
|
||||||
|
ms('DO_COLLECTED', 'PENDING', 'DJ'),
|
||||||
|
]),
|
||||||
|
).toBe('DO_COLLECTED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { ContractDocPhase } from '@edr/types';
|
||||||
|
|
||||||
|
import { ContractsRepository } from './contracts.repository';
|
||||||
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
|
import { MilestoneMetadata } from './entities/clearance-milestone.entity';
|
||||||
|
import { splitMilestones } from './clearance-milestone.catalog';
|
||||||
|
import { Contract } from './entities/contract.entity';
|
||||||
|
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||||
|
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||||
|
|
||||||
|
export type ClearanceActorRole = 'CUSTOMER' | 'GL_ET' | 'GL_DJ' | 'OPERATIONS';
|
||||||
|
|
||||||
|
export interface ClearanceNextAction {
|
||||||
|
actor: ClearanceActorRole;
|
||||||
|
action: string;
|
||||||
|
milestoneCode?: string | null;
|
||||||
|
blockedReason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMPORT_BOUNDARY = 'DO_COLLECTED';
|
||||||
|
const EXPORT_BOUNDARY = 'EXPORT_RELEASED';
|
||||||
|
|
||||||
|
const IMPORT_DOC_UPLOADED = 'IMPORT_DOCS_UPLOADED';
|
||||||
|
const EXPORT_DOC_UPLOADED = 'EXPORT_DOCS_UPLOADED';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ClearanceWorkflowService {
|
||||||
|
constructor(
|
||||||
|
private readonly contractsRepository: ContractsRepository,
|
||||||
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
boundaryMilestone(tradeDirection: string): string {
|
||||||
|
return tradeDirection === 'IMPORT' ? IMPORT_BOUNDARY : EXPORT_BOUNDARY;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMilestones(contractId: string): Promise<ClearanceMilestone[]> {
|
||||||
|
return this.milestoneService.listForContract(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isBoundaryComplete(contractId: string, tradeDirection: string): Promise<boolean> {
|
||||||
|
const code = this.boundaryMilestone(tradeDirection);
|
||||||
|
const milestones = await this.listMilestones(contractId);
|
||||||
|
const m = milestones.find((x) => x.milestoneCode === code);
|
||||||
|
return m?.status === 'COMPLETED';
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertBoundaryComplete(contract: Contract): Promise<void> {
|
||||||
|
const ok = await this.isBoundaryComplete(contract.id, contract.tradeDirection);
|
||||||
|
if (!ok) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Pre-booking clearance is not complete — ${this.boundaryMilestone(contract.tradeDirection)} must be finished before booking.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertPriorComplete(
|
||||||
|
contractId: string,
|
||||||
|
tradeDirection: string,
|
||||||
|
targetCode: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const { preBooking } = splitMilestones(tradeDirection);
|
||||||
|
const milestones = await this.listMilestones(contractId);
|
||||||
|
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||||
|
const targetIdx = preBooking.findIndex((d) => d.code === targetCode);
|
||||||
|
if (targetIdx < 0) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < targetIdx; i++) {
|
||||||
|
const code = preBooking[i]!.code;
|
||||||
|
const m = byCode.get(code);
|
||||||
|
if (!m) continue;
|
||||||
|
if (m.status === 'SKIPPED') continue;
|
||||||
|
if (m.status !== 'COMPLETED') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Complete "${preBooking[i]!.label}" before proceeding.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async skipMilestones(contractId: string, codes: string[]): Promise<void> {
|
||||||
|
for (const code of codes) {
|
||||||
|
await this.milestoneService.skipForContract(contractId, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeMilestone(
|
||||||
|
contractId: string,
|
||||||
|
code: string,
|
||||||
|
userId?: string,
|
||||||
|
metadata?: MilestoneMetadata,
|
||||||
|
): Promise<ClearanceMilestone> {
|
||||||
|
if (metadata && Object.keys(metadata).length > 0) {
|
||||||
|
return this.milestoneService.completeWithMetadataForContract(
|
||||||
|
contractId,
|
||||||
|
code,
|
||||||
|
metadata,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.milestoneService.completeForContract(contractId, code, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onCustomerDocsUploaded(contractId: string, tradeDirection: string): Promise<void> {
|
||||||
|
const uploaded =
|
||||||
|
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
|
||||||
|
await this.completeMilestone(contractId, uploaded);
|
||||||
|
await this.completeMilestone(contractId, 'PENDING_DOCUMENT_REVIEW');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onAllDocsApproved(contractId: string): Promise<void> {
|
||||||
|
await this.completeMilestone(contractId, 'DOCUMENTS_APPROVED');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onDeclarationUploaded(contractId: string, userId?: string): Promise<void> {
|
||||||
|
await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE');
|
||||||
|
await this.completeMilestone(contractId, 'DECLARED', userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onDutySkipped(contractId: string): Promise<void> {
|
||||||
|
await this.skipMilestones(contractId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onExportReleased(contractId: string, userId?: string): Promise<void> {
|
||||||
|
await this.completeMilestone(contractId, 'EXPORT_RELEASED', userId);
|
||||||
|
await this.markReadyForBooking(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markReadyForBooking(contractId: string): Promise<void> {
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'CLEARANCE_READY_FOR_BOOKING',
|
||||||
|
clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING',
|
||||||
|
} as never);
|
||||||
|
if (cycle) {
|
||||||
|
await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', {
|
||||||
|
clearanceReadyAt: new Date(),
|
||||||
|
currentPhase: ContractDocPhase.GlEtPostClearance,
|
||||||
|
});
|
||||||
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
|
currentPhase: ContractDocPhase.GlEtPostClearance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvePhase(
|
||||||
|
contract: Contract,
|
||||||
|
cycle: ContractClearanceCycle | null,
|
||||||
|
milestones: ClearanceMilestone[],
|
||||||
|
): ContractDocPhase {
|
||||||
|
if (cycle?.currentPhase) {
|
||||||
|
return cycle.currentPhase as ContractDocPhase;
|
||||||
|
}
|
||||||
|
return this.inferPhase(contract, cycle, milestones);
|
||||||
|
}
|
||||||
|
|
||||||
|
inferPhase(
|
||||||
|
contract: Contract,
|
||||||
|
cycle: ContractClearanceCycle | null,
|
||||||
|
milestones: ClearanceMilestone[],
|
||||||
|
): ContractDocPhase {
|
||||||
|
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||||
|
const isDone = (code: string) =>
|
||||||
|
byCode.get(code)?.status === 'COMPLETED' || byCode.get(code)?.status === 'SKIPPED';
|
||||||
|
|
||||||
|
const docUploaded =
|
||||||
|
contract.tradeDirection === 'IMPORT'
|
||||||
|
? isDone(IMPORT_DOC_UPLOADED)
|
||||||
|
: isDone(EXPORT_DOC_UPLOADED);
|
||||||
|
|
||||||
|
if (!docUploaded) return ContractDocPhase.CustomerIntake;
|
||||||
|
if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview;
|
||||||
|
|
||||||
|
if (contract.tradeDirection === 'EXPORT') {
|
||||||
|
if (!isDone('RELEASE_ORDER_SECURED')) {
|
||||||
|
if (cycle?.roHoldReason) return ContractDocPhase.GlDjCollection;
|
||||||
|
return ContractDocPhase.GlDjCollection;
|
||||||
|
}
|
||||||
|
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
||||||
|
if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance;
|
||||||
|
return ContractDocPhase.GlEtPostClearance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import
|
||||||
|
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
||||||
|
if (cycle?.dutyRequired === true && !isDone('DUTY_TAX_PAID')) {
|
||||||
|
return ContractDocPhase.CustomerDuty;
|
||||||
|
}
|
||||||
|
if (!isDone('TRANSIT_PERMIT_UPLOADED')) return ContractDocPhase.GlEtPostClearance;
|
||||||
|
if (!isDone(IMPORT_BOUNDARY)) return ContractDocPhase.GlDjCollection;
|
||||||
|
return ContractDocPhase.GlEtPostClearance;
|
||||||
|
}
|
||||||
|
|
||||||
|
computeNextAction(
|
||||||
|
contract: Contract,
|
||||||
|
cycle: ContractClearanceCycle | null,
|
||||||
|
milestones: ClearanceMilestone[],
|
||||||
|
): ClearanceNextAction | null {
|
||||||
|
if (cycle?.roHoldReason) {
|
||||||
|
return {
|
||||||
|
actor: 'GL_DJ',
|
||||||
|
action: 'Re-upload Release Order or request port amendment',
|
||||||
|
milestoneCode: 'RELEASE_ORDER_SECURED',
|
||||||
|
blockedReason: cycle.roHoldReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||||
|
const pending = (code: string) => {
|
||||||
|
const m = byCode.get(code);
|
||||||
|
return m && m.status === 'PENDING';
|
||||||
|
};
|
||||||
|
const isDone = (code: string) => {
|
||||||
|
const m = byCode.get(code);
|
||||||
|
return m?.status === 'COMPLETED' || m?.status === 'SKIPPED';
|
||||||
|
};
|
||||||
|
|
||||||
|
const docCode =
|
||||||
|
contract.tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
|
||||||
|
|
||||||
|
if (pending(docCode) || !isDone(docCode)) {
|
||||||
|
return {
|
||||||
|
actor: 'CUSTOMER',
|
||||||
|
action: 'Upload clearance documents',
|
||||||
|
milestoneCode: docCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDone('DOCUMENTS_APPROVED')) {
|
||||||
|
return {
|
||||||
|
actor: 'GL_ET',
|
||||||
|
action: 'Review and approve customer documents',
|
||||||
|
milestoneCode: 'DOCUMENTS_APPROVED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contract.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',
|
||||||
|
action: 'Upload customs declaration (EX3/EX8)',
|
||||||
|
milestoneCode: 'DECLARED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!isDone(EXPORT_BOUNDARY)) {
|
||||||
|
return {
|
||||||
|
actor: 'GL_ET',
|
||||||
|
action: 'Confirm export release',
|
||||||
|
milestoneCode: EXPORT_BOUNDARY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
actor: 'GL_ET',
|
||||||
|
action: 'Create shipment booking',
|
||||||
|
milestoneCode: EXPORT_BOUNDARY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import
|
||||||
|
if (!isDone('DECLARED')) {
|
||||||
|
return {
|
||||||
|
actor: 'GL_ET',
|
||||||
|
action: 'Upload customs declaration (IM4/IM5)',
|
||||||
|
milestoneCode: 'DECLARED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cycle?.dutyRequired === null || cycle?.dutyRequired === undefined) {
|
||||||
|
return {
|
||||||
|
actor: 'GL_ET',
|
||||||
|
action: 'Set whether duty/tax applies',
|
||||||
|
milestoneCode: 'DUTY_TAXES_ADVISED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cycle.dutyRequired && !isDone('DUTY_TAX_PAID')) {
|
||||||
|
if (!isDone('DUTY_TAXES_ADVISED')) {
|
||||||
|
return {
|
||||||
|
actor: 'GL_ET',
|
||||||
|
action: 'Advise duty and tax amount',
|
||||||
|
milestoneCode: 'DUTY_TAXES_ADVISED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
actor: 'CUSTOMER',
|
||||||
|
action: 'Upload duty/tax payment slip',
|
||||||
|
milestoneCode: 'DUTY_TAX_PAID',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDone('TRANSIT_PERMIT_UPLOADED')) {
|
||||||
|
return {
|
||||||
|
actor: 'GL_ET',
|
||||||
|
action: 'Upload transit permit screenshot',
|
||||||
|
milestoneCode: 'TRANSIT_PERMIT_UPLOADED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDone(IMPORT_BOUNDARY)) {
|
||||||
|
return {
|
||||||
|
actor: 'GL_DJ',
|
||||||
|
action: 'Upload Delivery Order',
|
||||||
|
milestoneCode: IMPORT_BOUNDARY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
actor: 'GL_ET',
|
||||||
|
action: 'Create shipment booking',
|
||||||
|
milestoneCode: IMPORT_BOUNDARY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contracts where the next pending milestone is owned by ET. */
|
||||||
|
etPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null {
|
||||||
|
const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'ET');
|
||||||
|
return pending?.milestoneCode ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contracts where the next pending milestone is owned by DJ. */
|
||||||
|
djPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null {
|
||||||
|
const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'DJ');
|
||||||
|
return pending?.milestoneCode ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import { Contract } from './entities/contract.entity';
|
|||||||
import { ContractRoute } from './entities/contract-route.entity';
|
import { ContractRoute } from './entities/contract-route.entity';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||||
|
|
||||||
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
|
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
|
||||||
@@ -56,6 +57,7 @@ export class ContractBookingService {
|
|||||||
private readonly containerTypesService: ContainerTypesService,
|
private readonly containerTypesService: ContainerTypesService,
|
||||||
private readonly ruleEngineService: RuleEngineService,
|
private readonly ruleEngineService: RuleEngineService,
|
||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
@@ -246,10 +248,14 @@ export class ContractBookingService {
|
|||||||
}
|
}
|
||||||
return 'GL_ET';
|
return 'GL_ET';
|
||||||
}
|
}
|
||||||
// ONE_TIME customs — UNCHANGED: requires the finalized contract cycle.
|
// ONE_TIME customs — pre-booking boundary milestone must be complete.
|
||||||
if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') {
|
const boundaryOk = await this.workflowService.isBoundaryComplete(
|
||||||
|
contract.id,
|
||||||
|
contract.tradeDirection,
|
||||||
|
);
|
||||||
|
if (!boundaryOk) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Contract clearance is not ready for booking yet.',
|
'Pre-booking clearance is not complete — booking cannot be created yet.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return 'GL_ET';
|
return 'GL_ET';
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||||
|
import { ContractDocPhase } from '@edr/types';
|
||||||
|
|
||||||
|
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ContractsService, PaginatedContracts } from './contracts.service';
|
import { ContractsService, PaginatedContracts } from './contracts.service';
|
||||||
import { contractClearanceCodes } from './contract-clearance.util';
|
import { contractClearanceCodes } from './contract-clearance.util';
|
||||||
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||||
|
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||||
|
|
||||||
|
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||||
|
|
||||||
export interface ContractClearanceDocument {
|
export interface ContractClearanceDocument {
|
||||||
fileKey: string;
|
fileKey: string;
|
||||||
@@ -34,6 +41,28 @@ export interface ContractClearanceView {
|
|||||||
outputCode: string | null;
|
outputCode: string | null;
|
||||||
documents: ContractClearanceDocument[];
|
documents: ContractClearanceDocument[];
|
||||||
allApproved: boolean;
|
allApproved: boolean;
|
||||||
|
phase?: string | null;
|
||||||
|
milestones?: Array<{
|
||||||
|
id: string;
|
||||||
|
milestoneCode: string;
|
||||||
|
milestoneLabel: string;
|
||||||
|
status: string;
|
||||||
|
ownerRegion?: string | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
sortOrder: number;
|
||||||
|
}>;
|
||||||
|
nextAction?: {
|
||||||
|
actor: string;
|
||||||
|
action: string;
|
||||||
|
milestoneCode?: string | null;
|
||||||
|
blockedReason?: string | null;
|
||||||
|
} | null;
|
||||||
|
dutyRequired?: boolean | null;
|
||||||
|
roHold?: boolean;
|
||||||
|
roHoldReason?: string | null;
|
||||||
|
vesselDepartureDate?: string | null;
|
||||||
|
roAmendmentRequestedAt?: string | null;
|
||||||
|
bookingReady?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -43,8 +72,20 @@ export class ContractClearanceService {
|
|||||||
private readonly contractsService: ContractsService,
|
private readonly contractsService: ContractsService,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||||
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
|
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private assertPhasedCustoms(contract: Contract): void {
|
||||||
|
if (!contract.customsClearingEnabled) {
|
||||||
|
throw new BadRequestException('Phased clearance applies only to customs contracts.');
|
||||||
|
}
|
||||||
|
if (contract.contractKind !== 'ONE_TIME') {
|
||||||
|
throw new BadRequestException('Phased clearance (Phase 1) applies to one-time contracts.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The pre-booking clearance document grid for a contract (Path B). */
|
/** The pre-booking clearance document grid for a contract (Path B). */
|
||||||
async getClearanceView(contractId: string): Promise<ContractClearanceView> {
|
async getClearanceView(contractId: string): Promise<ContractClearanceView> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
@@ -109,6 +150,13 @@ export class ContractClearanceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allApproved = await this.isClearanceFullyApproved(contract);
|
const allApproved = await this.isClearanceFullyApproved(contract);
|
||||||
|
const milestones = await this.workflowService.listMilestones(contractId);
|
||||||
|
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
|
||||||
|
const nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
|
||||||
|
const boundary = await this.workflowService.isBoundaryComplete(
|
||||||
|
contractId,
|
||||||
|
contract.tradeDirection,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contractId,
|
contractId,
|
||||||
@@ -120,6 +168,25 @@ export class ContractClearanceService {
|
|||||||
outputCode,
|
outputCode,
|
||||||
documents,
|
documents,
|
||||||
allApproved,
|
allApproved,
|
||||||
|
phase,
|
||||||
|
milestones: milestones.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
milestoneCode: m.milestoneCode,
|
||||||
|
milestoneLabel: m.milestoneLabel,
|
||||||
|
status: m.status,
|
||||||
|
ownerRegion: m.ownerRegion,
|
||||||
|
metadata: (m.metadata ?? null) as Record<string, unknown> | null,
|
||||||
|
sortOrder: m.sortOrder,
|
||||||
|
})),
|
||||||
|
nextAction,
|
||||||
|
dutyRequired: cycle?.dutyRequired ?? null,
|
||||||
|
roHold: Boolean(cycle?.roHoldReason),
|
||||||
|
roHoldReason: cycle?.roHoldReason ?? null,
|
||||||
|
vesselDepartureDate: cycle?.vesselDepartureDate ?? null,
|
||||||
|
roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt
|
||||||
|
? cycle.roAmendmentRequestedAt.toISOString()
|
||||||
|
: null,
|
||||||
|
bookingReady: boundary,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,8 +329,15 @@ export class ContractClearanceService {
|
|||||||
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
|
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
|
||||||
} as never);
|
} as never);
|
||||||
if (cycle) {
|
if (cycle) {
|
||||||
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
|
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW', {
|
||||||
|
currentPhase: ContractDocPhase.GlEtReview,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME') {
|
||||||
|
await this.workflowService.onCustomerDocsUploaded(contractId, contract.tradeDirection);
|
||||||
|
}
|
||||||
|
|
||||||
return this.contractsService.findById(contractId);
|
return this.contractsService.findById(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +465,23 @@ export class ContractClearanceService {
|
|||||||
}
|
}
|
||||||
} else if (status === 'APPROVED') {
|
} else if (status === 'APPROVED') {
|
||||||
await this.bumpToUnderReviewWhenFullyApproved(contractId);
|
await this.bumpToUnderReviewWhenFullyApproved(contractId);
|
||||||
|
const refreshed = await this.contractsService.findById(contractId);
|
||||||
|
if (
|
||||||
|
refreshed.customsClearingEnabled &&
|
||||||
|
refreshed.contractKind === 'ONE_TIME' &&
|
||||||
|
(await this.isClearanceFullyApproved(refreshed))
|
||||||
|
) {
|
||||||
|
await this.workflowService.onAllDocsApproved(contractId);
|
||||||
|
const c = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (c) {
|
||||||
|
await this.contractsRepository.updateCycle(c.id, {
|
||||||
|
currentPhase:
|
||||||
|
refreshed.tradeDirection === 'EXPORT'
|
||||||
|
? ContractDocPhase.GlDjCollection
|
||||||
|
: ContractDocPhase.GlEtOutput,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.contractsService.findById(contractId);
|
return this.contractsService.findById(contractId);
|
||||||
@@ -567,4 +658,395 @@ export class ContractClearanceService {
|
|||||||
sortOrder: filter.sortOrder ?? 'DESC',
|
sortOrder: filter.sortOrder ?? 'DESC',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Phased clearance actions (ONE_TIME customs, Phase 1) ───────────────────
|
||||||
|
|
||||||
|
async uploadDeclaration(
|
||||||
|
contractId: string,
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
this.assertPhasedCustoms(contract);
|
||||||
|
await this.workflowService.assertPriorComplete(
|
||||||
|
contractId,
|
||||||
|
contract.tradeDirection,
|
||||||
|
'DECLARED',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new BadRequestException('No declaration documents uploaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
await this.filesService.upsertByCode({
|
||||||
|
resourceId: contractId,
|
||||||
|
resource: 'contracts',
|
||||||
|
code: file.fieldname,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.workflowService.onDeclarationUploaded(contractId, userId);
|
||||||
|
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (cycle) {
|
||||||
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
|
currentPhase:
|
||||||
|
contract.tradeDirection === 'EXPORT'
|
||||||
|
? ContractDocPhase.GlEtPostClearance
|
||||||
|
: ContractDocPhase.CustomerDuty,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async adviseDuty(
|
||||||
|
contractId: string,
|
||||||
|
dto: AdviseContractDutyDto,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
this.assertPhasedCustoms(contract);
|
||||||
|
if (contract.tradeDirection !== 'IMPORT') {
|
||||||
|
throw new BadRequestException('Duty advice applies only to import contracts.');
|
||||||
|
}
|
||||||
|
await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DUTY_TAXES_ADVISED');
|
||||||
|
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||||
|
|
||||||
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
|
dutyRequired: dto.dutyRequired,
|
||||||
|
currentPhase: dto.dutyRequired
|
||||||
|
? ContractDocPhase.CustomerDuty
|
||||||
|
: ContractDocPhase.GlEtPostClearance,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dto.dutyRequired) {
|
||||||
|
await this.workflowService.onDutySkipped(contractId);
|
||||||
|
} else {
|
||||||
|
if (dto.amount == null || dto.amount < 0) {
|
||||||
|
throw new BadRequestException('Duty amount is required when duty applies.');
|
||||||
|
}
|
||||||
|
await this.milestoneService.adviseDutyForContract(
|
||||||
|
contractId,
|
||||||
|
{
|
||||||
|
amount: dto.amount,
|
||||||
|
currency: dto.currency ?? 'ETB',
|
||||||
|
declarationSerial: dto.declarationSerial,
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadDutySlip(
|
||||||
|
contractId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
this.assertPhasedCustoms(contract);
|
||||||
|
if (contract.tradeDirection !== 'IMPORT') {
|
||||||
|
throw new BadRequestException('Duty slip upload applies only to import contracts.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (!cycle?.dutyRequired) {
|
||||||
|
throw new BadRequestException('Duty/tax is not required for this clearance.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file) throw new BadRequestException('No payment slip uploaded');
|
||||||
|
|
||||||
|
await this.filesService.upsertByCode({
|
||||||
|
resourceId: contractId,
|
||||||
|
resource: 'contracts',
|
||||||
|
code: 'duty_tax_receipt',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.workflowService.completeMilestone(contractId, 'DUTY_TAX_PAID');
|
||||||
|
if (cycle) {
|
||||||
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
|
currentPhase: ContractDocPhase.GlEtPostClearance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadTransitPermit(
|
||||||
|
contractId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
this.assertPhasedCustoms(contract);
|
||||||
|
if (contract.tradeDirection !== 'IMPORT') {
|
||||||
|
throw new BadRequestException('Transit permit applies only to import contracts.');
|
||||||
|
}
|
||||||
|
await this.workflowService.assertPriorComplete(
|
||||||
|
contractId,
|
||||||
|
'IMPORT',
|
||||||
|
'TRANSIT_PERMIT_UPLOADED',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!file) throw new BadRequestException('No transit permit uploaded');
|
||||||
|
|
||||||
|
await this.filesService.upsertByCode({
|
||||||
|
resourceId: contractId,
|
||||||
|
resource: 'contracts',
|
||||||
|
code: 'transit_permitted',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.workflowService.completeMilestone(contractId, 'TRANSIT_PERMIT_UPLOADED', userId);
|
||||||
|
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (cycle) {
|
||||||
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
|
currentPhase: ContractDocPhase.GlDjCollection,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadDeliveryOrder(
|
||||||
|
contractId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
this.assertPhasedCustoms(contract);
|
||||||
|
if (contract.tradeDirection !== 'IMPORT') {
|
||||||
|
throw new BadRequestException('Delivery Order applies only to import contracts.');
|
||||||
|
}
|
||||||
|
await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DO_COLLECTED');
|
||||||
|
|
||||||
|
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
||||||
|
|
||||||
|
await this.filesService.upsertByCode({
|
||||||
|
resourceId: contractId,
|
||||||
|
resource: 'contracts',
|
||||||
|
code: 'delivery_order',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId);
|
||||||
|
await this.workflowService.markReadyForBooking(contractId);
|
||||||
|
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveRoMinDays(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE);
|
||||||
|
const first = setting.children?.[0];
|
||||||
|
const n = Number(first?.value);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 2;
|
||||||
|
} catch {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private daysUntil(dateStr: string): number {
|
||||||
|
const target = new Date(dateStr);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
target.setHours(0, 0, 0, 0);
|
||||||
|
return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadReleaseOrder(
|
||||||
|
contractId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
vesselDepartureDate: string,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<{ contract: Contract; hold: boolean; holdReason?: string }> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
this.assertPhasedCustoms(contract);
|
||||||
|
if (contract.tradeDirection !== 'EXPORT') {
|
||||||
|
throw new BadRequestException('Release Order applies only to export contracts.');
|
||||||
|
}
|
||||||
|
await this.workflowService.assertPriorComplete(
|
||||||
|
contractId,
|
||||||
|
'EXPORT',
|
||||||
|
'RELEASE_ORDER_SECURED',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!file) throw new BadRequestException('No Release Order uploaded');
|
||||||
|
if (!vesselDepartureDate?.trim()) {
|
||||||
|
throw new BadRequestException('Vessel departure date is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const minDays = await this.resolveRoMinDays();
|
||||||
|
const leadDays = this.daysUntil(vesselDepartureDate);
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||||
|
|
||||||
|
await this.filesService.upsertByCode({
|
||||||
|
resourceId: contractId,
|
||||||
|
resource: 'contracts',
|
||||||
|
code: 'release_order',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
|
vesselDepartureDate,
|
||||||
|
roAmendmentRequestedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (leadDays < minDays) {
|
||||||
|
const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`;
|
||||||
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
|
roHoldReason: reason,
|
||||||
|
currentPhase: ContractDocPhase.GlDjCollection,
|
||||||
|
});
|
||||||
|
return { contract: await this.contractsService.findById(contractId), hold: true, holdReason: reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
|
roHoldReason: null,
|
||||||
|
currentPhase: ContractDocPhase.GlEtOutput,
|
||||||
|
});
|
||||||
|
await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId);
|
||||||
|
|
||||||
|
return { contract: await this.contractsService.findById(contractId), hold: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestRoAmendment(
|
||||||
|
contractId: string,
|
||||||
|
note?: string,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
this.assertPhasedCustoms(contract);
|
||||||
|
if (contract.tradeDirection !== 'EXPORT') {
|
||||||
|
throw new BadRequestException('RO amendment applies only to export contracts.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||||
|
|
||||||
|
const reason =
|
||||||
|
note?.trim() ||
|
||||||
|
'Port amendment requested — vessel departure window is too short. A new Release Order will be required.';
|
||||||
|
|
||||||
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
|
roAmendmentRequestedAt: new Date(),
|
||||||
|
roHoldReason: reason,
|
||||||
|
currentPhase: ContractDocPhase.GlDjCollection,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
await this.contractsRepository.createReviewNote(
|
||||||
|
contractId,
|
||||||
|
reason,
|
||||||
|
'CHANGES_REQUESTED',
|
||||||
|
userId,
|
||||||
|
'GL_DJ',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmExportRelease(contractId: string, userId?: string): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
this.assertPhasedCustoms(contract);
|
||||||
|
if (contract.tradeDirection !== 'EXPORT') {
|
||||||
|
throw new BadRequestException('Export release applies only to export contracts.');
|
||||||
|
}
|
||||||
|
await this.workflowService.assertPriorComplete(contractId, 'EXPORT', 'EXPORT_RELEASED');
|
||||||
|
|
||||||
|
await this.workflowService.onExportReleased(contractId, userId);
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL ET queue: customs ONE_TIME contracts with a pending ET-owned milestone. */
|
||||||
|
async etQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||||
|
const base = await this.contractsRepository.findAllPaginated({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 500,
|
||||||
|
statuses: ['AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING'],
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
contractKind: 'ONE_TIME',
|
||||||
|
sortBy: filter.sortBy,
|
||||||
|
sortOrder: filter.sortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered: typeof base.items = [];
|
||||||
|
for (const c of base.items) {
|
||||||
|
const milestones = await this.workflowService.listMilestones(c.id);
|
||||||
|
const pending = this.workflowService.etPendingMilestoneCodes(milestones);
|
||||||
|
const next = this.workflowService.computeNextAction(
|
||||||
|
c,
|
||||||
|
await this.contractsRepository.currentCycle(c.id),
|
||||||
|
milestones,
|
||||||
|
);
|
||||||
|
if (pending || next?.actor === 'GL_ET') filtered.push(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = filter.page ?? 1;
|
||||||
|
const pageSize = filter.pageSize ?? 50;
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const items = filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: filtered.length,
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: filtered.length,
|
||||||
|
totalPages: Math.ceil(filtered.length / pageSize) || 1,
|
||||||
|
hasNextPage: start + pageSize < filtered.length,
|
||||||
|
hasPreviousPage: page > 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL DJ queue: customs ONE_TIME contracts with a pending DJ-owned milestone or RO hold. */
|
||||||
|
async djQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||||
|
const base = await this.contractsRepository.findAllPaginated({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 500,
|
||||||
|
statuses: ['AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING'],
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
contractKind: 'ONE_TIME',
|
||||||
|
sortBy: filter.sortBy,
|
||||||
|
sortOrder: filter.sortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filtered: typeof base.items = [];
|
||||||
|
for (const c of base.items) {
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(c.id);
|
||||||
|
const milestones = await this.workflowService.listMilestones(c.id);
|
||||||
|
const pending = this.workflowService.djPendingMilestoneCodes(milestones);
|
||||||
|
const next = this.workflowService.computeNextAction(c, cycle, milestones);
|
||||||
|
if (cycle?.roHoldReason || pending || next?.actor === 'GL_DJ') filtered.push(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = filter.page ?? 1;
|
||||||
|
const pageSize = filter.pageSize ?? 50;
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const items = filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: filtered.length,
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: filtered.length,
|
||||||
|
totalPages: Math.ceil(filtered.length / pageSize) || 1,
|
||||||
|
hasNextPage: start + pageSize < filtered.length,
|
||||||
|
hasPreviousPage: page > 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import {
|
|||||||
Res,
|
Res,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
|
UploadedFile,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
@@ -72,6 +73,10 @@ import {
|
|||||||
CompleteMilestoneDto,
|
CompleteMilestoneDto,
|
||||||
ReportIncidentDto,
|
ReportIncidentDto,
|
||||||
} from './dto/gl-operations.dto';
|
} from './dto/gl-operations.dto';
|
||||||
|
import {
|
||||||
|
AdviseContractDutyDto,
|
||||||
|
RoAmendmentDto,
|
||||||
|
} from './dto/phased-clearance.dto';
|
||||||
|
|
||||||
@ApiTags('contracts')
|
@ApiTags('contracts')
|
||||||
@Controller('contracts')
|
@Controller('contracts')
|
||||||
@@ -511,11 +516,126 @@ export class ContractsController {
|
|||||||
|
|
||||||
@Post(':id/clearance/finalize')
|
@Post(':id/clearance/finalize')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance)
|
@BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance)
|
||||||
@ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING' })
|
@ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)' })
|
||||||
finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
|
finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.clearanceService.finalize(id);
|
return this.clearanceService.finalize(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/declaration')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'GL ET uploads customs declaration (IM4/IM5 or EX3/EX8)' })
|
||||||
|
uploadDeclaration(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.uploadDeclaration(id, files ?? [], resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/duty')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise)
|
||||||
|
@ApiOperation({ summary: 'GL ET sets duty/tax requirement and advises amount' })
|
||||||
|
adviseContractDuty(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: AdviseContractDutyDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.adviseDuty(id, dto, resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/duty-slip')
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on contract' })
|
||||||
|
uploadContractDutySlip(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.uploadDutySlip(id, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/transit-permit')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'GL ET uploads transit permit screenshot (import)' })
|
||||||
|
uploadTransitPermit(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.uploadTransitPermit(id, file, resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/delivery-order')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'GL DJ uploads Delivery Order (import)' })
|
||||||
|
uploadDeliveryOrder(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/release-order')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'GL DJ uploads Release Order + vessel departure date (export)' })
|
||||||
|
uploadReleaseOrder(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@Body('vesselDepartureDate') vesselDepartureDate: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.uploadReleaseOrder(
|
||||||
|
id,
|
||||||
|
file,
|
||||||
|
vesselDepartureDate,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/ro-amendment')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
|
@ApiOperation({ summary: 'GL DJ requests port amendment when RO vessel window is too short' })
|
||||||
|
requestRoAmendment(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: RoAmendmentDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.requestRoAmendment(id, dto.note, resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/export-release')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
|
@ApiOperation({ summary: 'GL ET confirms export release after declaration' })
|
||||||
|
confirmExportRelease(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.confirmExportRelease(id, resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('clearance/et-queue')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
|
@ApiOperation({ summary: 'GL Ethiopia phased clearance queue' })
|
||||||
|
etClearanceQueue(@Query() filter: FilterContractDto) {
|
||||||
|
return this.clearanceService.etQueue(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('clearance/dj-queue')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
|
@ApiOperation({ summary: 'GL Djibouti phased clearance queue' })
|
||||||
|
djClearanceQueue(@Query() filter: FilterContractDto) {
|
||||||
|
return this.clearanceService.djQueue(filter);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
|
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
|
||||||
|
|
||||||
@Get('clearance/ops-queue')
|
@Get('clearance/ops-queue')
|
||||||
@@ -696,6 +816,18 @@ export class ContractsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('bookings/:bookingId/transport-document')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'GL ET uploads export transport document after wagon allocation' })
|
||||||
|
uploadTransportDocument(
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.glOperationsService.uploadTransportDocument(bookingId, file);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('bookings/:bookingId/documents')
|
@Post('bookings/:bookingId/documents')
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||||
@@ -18,6 +18,7 @@ import { ContractsRepository } from './contracts.repository';
|
|||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
import { ContractTransitionService } from './contract-transition.service';
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
import { ContractClearanceService } from './contract-clearance.service';
|
import { ContractClearanceService } from './contract-clearance.service';
|
||||||
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
import { ContractBookingService } from './contract-booking.service';
|
import { ContractBookingService } from './contract-booking.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { GlOperationsService } from './gl-operations.service';
|
import { GlOperationsService } from './gl-operations.service';
|
||||||
@@ -71,7 +72,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
||||||
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
||||||
BookingsModule,
|
forwardRef(() => BookingsModule),
|
||||||
ExchangeModule.forRootAsync({
|
ExchangeModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||||
@@ -85,6 +86,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
ContractPricingService,
|
ContractPricingService,
|
||||||
ContractTransitionService,
|
ContractTransitionService,
|
||||||
ContractClearanceService,
|
ContractClearanceService,
|
||||||
|
ClearanceWorkflowService,
|
||||||
ContractBookingService,
|
ContractBookingService,
|
||||||
ClearanceMilestoneService,
|
ClearanceMilestoneService,
|
||||||
GlOperationsService,
|
GlOperationsService,
|
||||||
@@ -103,6 +105,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
ContractPricingService,
|
ContractPricingService,
|
||||||
ContractTransitionService,
|
ContractTransitionService,
|
||||||
ContractClearanceService,
|
ContractClearanceService,
|
||||||
|
ClearanceWorkflowService,
|
||||||
ContractBookingService,
|
ContractBookingService,
|
||||||
ClearanceMilestoneService,
|
ClearanceMilestoneService,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -518,7 +518,17 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
cycleId: string,
|
cycleId: string,
|
||||||
status: string,
|
status: string,
|
||||||
fields: Partial<
|
fields: Partial<
|
||||||
Pick<ContractClearanceCycle, 'bookingId' | 'clearanceReadyAt' | 'completedAt'>
|
Pick<
|
||||||
|
ContractClearanceCycle,
|
||||||
|
| 'bookingId'
|
||||||
|
| 'clearanceReadyAt'
|
||||||
|
| 'completedAt'
|
||||||
|
| 'dutyRequired'
|
||||||
|
| 'vesselDepartureDate'
|
||||||
|
| 'roAmendmentRequestedAt'
|
||||||
|
| 'roHoldReason'
|
||||||
|
| 'currentPhase'
|
||||||
|
>
|
||||||
> = {},
|
> = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.dataSource
|
await this.dataSource
|
||||||
@@ -526,6 +536,23 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
.update(cycleId, { status, ...fields } as never);
|
.update(cycleId, { status, ...fields } as never);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateCycle(
|
||||||
|
cycleId: string,
|
||||||
|
fields: Partial<
|
||||||
|
Pick<
|
||||||
|
ContractClearanceCycle,
|
||||||
|
| 'dutyRequired'
|
||||||
|
| 'vesselDepartureDate'
|
||||||
|
| 'roAmendmentRequestedAt'
|
||||||
|
| 'roHoldReason'
|
||||||
|
| 'currentPhase'
|
||||||
|
| 'status'
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.dataSource.getRepository(ContractClearanceCycle).update(cycleId, fields as never);
|
||||||
|
}
|
||||||
|
|
||||||
/** Link the GL-created booking to a clearance cycle. */
|
/** Link the GL-created booking to a clearance cycle. */
|
||||||
async linkBooking(cycleId: string, bookingId: string): Promise<void> {
|
async linkBooking(cycleId: string, bookingId: string): Promise<void> {
|
||||||
await this.dataSource
|
await this.dataSource
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsBoolean, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class AdviseContractDutyDto {
|
||||||
|
@ApiProperty({ description: 'Whether the customer must pay duty/tax' })
|
||||||
|
@IsBoolean()
|
||||||
|
dutyRequired!: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Duty amount (required when dutyRequired is true)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
amount?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 'ETB' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Declaration / payment reference code' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
declarationSerial?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReleaseOrderDto {
|
||||||
|
@ApiProperty({ description: 'Vessel departure date (ISO date YYYY-MM-DD)' })
|
||||||
|
@IsString()
|
||||||
|
vesselDepartureDate!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoAmendmentDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Note to customer / ET GL about the amendment request' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
@@ -34,4 +34,21 @@ export class ContractClearanceCycle extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||||
completedAt?: Date | null;
|
completedAt?: Date | null;
|
||||||
|
|
||||||
|
/** ET GL toggle: whether customer must pay duty/tax before DO collection (import). */
|
||||||
|
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
||||||
|
dutyRequired?: boolean | null;
|
||||||
|
|
||||||
|
/** Export RO vessel departure date (Path B export). */
|
||||||
|
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
|
||||||
|
vesselDepartureDate?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
|
||||||
|
roAmendmentRequestedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'ro_hold_reason', type: 'text', nullable: true })
|
||||||
|
roHoldReason?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'current_phase', type: 'varchar', length: 40, nullable: true })
|
||||||
|
currentPhase?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const DOC_CODE_TO_MILESTONE: Record<string, string> = {
|
|||||||
import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET
|
import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET
|
||||||
full_in_interchange: 'OFFLOADED', // export — GL DJ
|
full_in_interchange: 'OFFLOADED', // export — GL DJ
|
||||||
final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET
|
final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET
|
||||||
|
export_transport_document: 'EXPORT_TRANSPORT_ISSUED', // export — GL ET post-allocation
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,4 +159,47 @@ export class GlOperationsService {
|
|||||||
}
|
}
|
||||||
return { uploaded: files.length, completedMilestones };
|
return { uploaded: files.length, completedMilestones };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL ET uploads export transport document after wagon allocation (export ONE_TIME).
|
||||||
|
*/
|
||||||
|
async uploadTransportDocument(
|
||||||
|
bookingId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
): Promise<{ uploaded: boolean; milestoneCompleted: boolean }> {
|
||||||
|
const booking = await this.getBooking(bookingId);
|
||||||
|
if (booking.tradeDirection !== 'EXPORT') {
|
||||||
|
throw new BadRequestException('Transport document upload applies to export shipments only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||||
|
const wagonAllocated = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED');
|
||||||
|
const wagonDone =
|
||||||
|
wagonAllocated?.status === 'COMPLETED' || booking.schedulingStatus === 'SCHEDULED';
|
||||||
|
if (!wagonDone) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Wagon must be allocated before the transport document can be uploaded.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file) throw new BadRequestException('No transport document uploaded');
|
||||||
|
|
||||||
|
await this.filesService.upsertByCode({
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: 'export_transport_document',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (wagonAllocated && wagonAllocated.status !== 'COMPLETED') {
|
||||||
|
await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.milestoneService.completeByDocTrigger(
|
||||||
|
{ bookingId },
|
||||||
|
'EXPORT_TRANSPORT_ISSUED',
|
||||||
|
);
|
||||||
|
|
||||||
|
return { uploaded: true, milestoneCompleted: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
OnModuleInit,
|
OnModuleInit,
|
||||||
|
Optional,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
|
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
wagonTypeDimensionsFromEntity,
|
wagonTypeDimensionsFromEntity,
|
||||||
} from './train-capacity.util';
|
} from './train-capacity.util';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||||
|
|
||||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||||
interface Capacity {
|
interface Capacity {
|
||||||
@@ -179,6 +181,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
private readonly notifier: BookingNotifierService,
|
private readonly notifier: BookingNotifierService,
|
||||||
private readonly scheduler: SchedulerRegistry,
|
private readonly scheduler: SchedulerRegistry,
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
|
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
|
||||||
@@ -1002,6 +1005,16 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
this.notifier.secured(booking, reason);
|
this.notifier.secured(booking, reason);
|
||||||
void this.triggerWagonAllocation(scheduleId);
|
void this.triggerWagonAllocation(scheduleId);
|
||||||
|
void this.markWagonAllocatedMilestone(booking.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markWagonAllocatedMilestone(bookingId: string): Promise<void> {
|
||||||
|
if (!this.milestoneService) return;
|
||||||
|
try {
|
||||||
|
await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED');
|
||||||
|
} catch {
|
||||||
|
// Booking may have no milestone rows (non-contract path).
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { TrainSchedulingService } from './train-scheduling.service';
|
|||||||
import { BookingBatchService } from './booking-batch.service';
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
import { BookingNotifierService } from './booking-notifier.service';
|
import { BookingNotifierService } from './booking-notifier.service';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { ContractsModule } from '../contracts/contracts.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -49,6 +50,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
|||||||
TrainSchedulesModule,
|
TrainSchedulesModule,
|
||||||
forwardRef(() => WarehousesModule),
|
forwardRef(() => WarehousesModule),
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
|
forwardRef(() => ContractsModule),
|
||||||
],
|
],
|
||||||
controllers: [TrainSchedulingController],
|
controllers: [TrainSchedulingController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'),
|
perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'),
|
||||||
perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'),
|
perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'),
|
||||||
perm('a3000001-0001-4000-8000-00000000000d', 'edr_freight_app:contracts:ops_clearance_review', 'Operations review of self-clearance docs (Path A)'),
|
perm('a3000001-0001-4000-8000-00000000000d', 'edr_freight_app:contracts:ops_clearance_review', 'Operations review of self-clearance docs (Path A)'),
|
||||||
|
perm('a3000001-0001-4000-8000-00000000000e', 'edr_freight_app:contracts:clearance_et_actions', 'GL Ethiopia phased clearance actions'),
|
||||||
|
perm('a3000001-0001-4000-8000-00000000000f', 'edr_freight_app:contracts:clearance_dj_actions', 'GL Djibouti phased clearance actions'),
|
||||||
|
perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'),
|
||||||
];
|
];
|
||||||
|
|
||||||
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
||||||
@@ -149,6 +152,9 @@ export const FREIGHT_PERMS = {
|
|||||||
finalizeClearance: 'edr_freight_app:contracts:finalize_clearance',
|
finalizeClearance: 'edr_freight_app:contracts:finalize_clearance',
|
||||||
createBooking: 'edr_freight_app:contracts:create_booking',
|
createBooking: 'edr_freight_app:contracts:create_booking',
|
||||||
opsClearanceReview: 'edr_freight_app:contracts:ops_clearance_review',
|
opsClearanceReview: 'edr_freight_app:contracts:ops_clearance_review',
|
||||||
|
clearanceEtActions: 'edr_freight_app:contracts:clearance_et_actions',
|
||||||
|
clearanceDjActions: 'edr_freight_app:contracts:clearance_dj_actions',
|
||||||
|
clearanceDutyAdvise: 'edr_freight_app:contracts:clearance_duty_advise',
|
||||||
},
|
},
|
||||||
trainScheduling: {
|
trainScheduling: {
|
||||||
view: 'edr_freight_app:train_scheduling:view',
|
view: 'edr_freight_app:train_scheduling:view',
|
||||||
@@ -237,6 +243,8 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.contracts.clearanceReview,
|
FREIGHT_PERMS.contracts.clearanceReview,
|
||||||
FREIGHT_PERMS.contracts.finalizeClearance,
|
FREIGHT_PERMS.contracts.finalizeClearance,
|
||||||
FREIGHT_PERMS.contracts.createBooking,
|
FREIGHT_PERMS.contracts.createBooking,
|
||||||
|
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||||
|
FREIGHT_PERMS.contracts.clearanceDutyAdvise,
|
||||||
FREIGHT_PERMS.bookings.clearanceView,
|
FREIGHT_PERMS.bookings.clearanceView,
|
||||||
FREIGHT_PERMS.bookings.reviewDocuments,
|
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||||
FREIGHT_PERMS.bookings.uploadClearanceOutput,
|
FREIGHT_PERMS.bookings.uploadClearanceOutput,
|
||||||
@@ -247,6 +255,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
// damage reports. Read-only on the contract; no booking creation.
|
// damage reports. Read-only on the contract; no booking creation.
|
||||||
glDjibouti: [
|
glDjibouti: [
|
||||||
FREIGHT_PERMS.contracts.view,
|
FREIGHT_PERMS.contracts.view,
|
||||||
|
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||||
FREIGHT_PERMS.bookings.clearanceView,
|
FREIGHT_PERMS.bookings.clearanceView,
|
||||||
FREIGHT_PERMS.bookings.uploadClearanceOutput,
|
FREIGHT_PERMS.bookings.uploadClearanceOutput,
|
||||||
FREIGHT_PERMS.bookings.operations,
|
FREIGHT_PERMS.bookings.operations,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
FileText,
|
FileText,
|
||||||
|
Flag,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Network,
|
Network,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
Ship,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Train,
|
Train,
|
||||||
Truck,
|
Truck,
|
||||||
@@ -35,6 +37,9 @@ import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPa
|
|||||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||||
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
|
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
|
||||||
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
||||||
|
import GlEthiopiaClearanceListPage from "./pages/contracts/GlEthiopiaClearanceListPage";
|
||||||
|
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
|
||||||
|
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
|
||||||
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
||||||
import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage";
|
import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage";
|
||||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||||
@@ -136,6 +141,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <ShieldCheck />,
|
icon: <ShieldCheck />,
|
||||||
permission: FREIGHT_PERMS.contracts.clearanceReview,
|
permission: FREIGHT_PERMS.contracts.clearanceReview,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "GL Ethiopia Clearance",
|
||||||
|
href: "/dashboard/gl-ethiopia/clearance",
|
||||||
|
icon: <Flag />,
|
||||||
|
permission: FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "GL Djibouti Clearance",
|
||||||
|
href: "/dashboard/gl-djibouti/clearance",
|
||||||
|
icon: <Ship />,
|
||||||
|
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Train Schedules",
|
label: "Train Schedules",
|
||||||
href: "/dashboard/operations/train-scheduling-v2",
|
href: "/dashboard/operations/train-scheduling-v2",
|
||||||
@@ -509,6 +526,46 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="gl-ethiopia/clearance"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceEtActions}>
|
||||||
|
<GlEthiopiaClearanceListPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="gl-ethiopia/clearance/:id"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceEtActions}>
|
||||||
|
<GlClearanceDetailPage
|
||||||
|
backTo="/dashboard/gl-ethiopia/clearance"
|
||||||
|
breadcrumbsLabel="GL Ethiopia Clearance"
|
||||||
|
roleMode="ET"
|
||||||
|
/>
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="gl-djibouti/clearance"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
|
||||||
|
<GlDjiboutiClearanceListPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="gl-djibouti/clearance/:id"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
|
||||||
|
<GlClearanceDetailPage
|
||||||
|
backTo="/dashboard/gl-djibouti/clearance"
|
||||||
|
breadcrumbsLabel="GL Djibouti Clearance"
|
||||||
|
roleMode="DJ"
|
||||||
|
/>
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{/* Path A ops queue out of scope for now → fold into the GL hub. */}
|
{/* Path A ops queue out of scope for now → fold into the GL hub. */}
|
||||||
<Route
|
<Route
|
||||||
path="contracts/ops-clearance"
|
path="contracts/ops-clearance"
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
const BRAND_GREEN = "var(--freight-brand, #0A6F4D)";
|
||||||
|
|
||||||
|
const PHASE_LABELS: Record<string, string> = {
|
||||||
|
CUSTOMER_INTAKE: "Customer docs",
|
||||||
|
GL_ET_REVIEW: "GL ET review",
|
||||||
|
GL_DJ_COLLECTION: "GL Djibouti",
|
||||||
|
GL_ET_OUTPUT: "Declaration",
|
||||||
|
CUSTOMER_DUTY: "Duty / tax",
|
||||||
|
GL_ET_POST_CLEARANCE: "ET clearance",
|
||||||
|
GL_DJ_LOADING: "Loading",
|
||||||
|
POST_TRANSIT: "Transit",
|
||||||
|
};
|
||||||
|
|
||||||
|
const IMPORT_PHASES = [
|
||||||
|
"CUSTOMER_INTAKE",
|
||||||
|
"GL_ET_REVIEW",
|
||||||
|
"GL_ET_OUTPUT",
|
||||||
|
"CUSTOMER_DUTY",
|
||||||
|
"GL_ET_POST_CLEARANCE",
|
||||||
|
"GL_DJ_COLLECTION",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const EXPORT_PHASES = [
|
||||||
|
"CUSTOMER_INTAKE",
|
||||||
|
"GL_ET_REVIEW",
|
||||||
|
"GL_DJ_COLLECTION",
|
||||||
|
"GL_ET_OUTPUT",
|
||||||
|
"GL_ET_POST_CLEARANCE",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function phaseIndex(phases: readonly string[], current?: string | null): number {
|
||||||
|
if (!current) return 0;
|
||||||
|
const idx = phases.indexOf(current);
|
||||||
|
return idx >= 0 ? idx : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClearancePhaseStepper({
|
||||||
|
clearance,
|
||||||
|
tradeDirection,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
clearance?: Freight.ContractClearanceView | null;
|
||||||
|
tradeDirection?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const phases = tradeDirection === "EXPORT" ? EXPORT_PHASES : IMPORT_PHASES;
|
||||||
|
const current = clearance?.phase ?? phases[0];
|
||||||
|
const activeIdx = phaseIndex(phases, current);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap={0} wrap="nowrap" align="flex-start" style={{ overflowX: "auto" }}>
|
||||||
|
{phases.map((phase, index) => {
|
||||||
|
const isComplete = index < activeIdx;
|
||||||
|
const isActive = index === activeIdx;
|
||||||
|
const isLast = index === phases.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={phase} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: compact ? 72 : 88 }}>
|
||||||
|
<Group gap={0} wrap="nowrap" align="center">
|
||||||
|
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: compact ? 28 : 34,
|
||||||
|
height: compact ? 28 : 34,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: isComplete ? BRAND_GREEN : isActive ? "white" : "var(--mantine-color-gray-1)",
|
||||||
|
border: isActive
|
||||||
|
? `2px solid ${BRAND_GREEN}`
|
||||||
|
: isComplete
|
||||||
|
? "2px solid transparent"
|
||||||
|
: "2px solid var(--mantine-color-gray-3)",
|
||||||
|
color: isComplete ? "white" : isActive ? BRAND_GREEN : "var(--mantine-color-gray-5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isComplete ? <Check size={compact ? 14 : 16} strokeWidth={3} /> : null}
|
||||||
|
</Box>
|
||||||
|
<Text
|
||||||
|
size={compact ? "10px" : "xs"}
|
||||||
|
fw={isActive ? 600 : 500}
|
||||||
|
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
|
||||||
|
ta="center"
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{PHASE_LABELS[phase] ?? phase}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{!isLast && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 2,
|
||||||
|
marginInline: 6,
|
||||||
|
marginBottom: compact ? 16 : 20,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
FileInput,
|
||||||
|
Group,
|
||||||
|
NumberInput,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { DateInput } from "@mantine/dates";
|
||||||
|
import { AlertTriangle, FileText, Receipt, Ship, Upload } from "lucide-react";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
|
import { ActionShell } from "@/components/contracts/gl-actions/ActionShell";
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
|
type RoleMode = "ET" | "DJ" | "ALL";
|
||||||
|
|
||||||
|
export function PhasedClearanceActionPanel({
|
||||||
|
contractId,
|
||||||
|
clearance,
|
||||||
|
tradeDirection,
|
||||||
|
roleMode = "ALL",
|
||||||
|
onChanged,
|
||||||
|
bookingCreateHref,
|
||||||
|
}: {
|
||||||
|
contractId: string;
|
||||||
|
clearance: Freight.ContractClearanceView;
|
||||||
|
tradeDirection: string;
|
||||||
|
roleMode?: RoleMode;
|
||||||
|
onChanged?: () => void;
|
||||||
|
bookingCreateHref?: string;
|
||||||
|
}) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
||||||
|
const canDj = hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
|
||||||
|
const showEt = roleMode === "ET" || roleMode === "ALL";
|
||||||
|
const showDj = roleMode === "DJ" || roleMode === "ALL";
|
||||||
|
|
||||||
|
const next = clearance.nextAction;
|
||||||
|
const isImport = tradeDirection === "IMPORT";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
{clearance.roHold && clearance.roHoldReason ? (
|
||||||
|
<Alert color="orange" icon={<AlertTriangle size={16} />} title="Release Order on hold">
|
||||||
|
{clearance.roHoldReason}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{next ? (
|
||||||
|
<Alert color="blue" variant="light" title="Next step">
|
||||||
|
<Text size="sm">
|
||||||
|
<strong>{next.actor.replace("_", " ")}</strong> — {next.action}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showEt && canEt && isImport ? (
|
||||||
|
<DeclarationCard contractId={contractId} onChanged={onChanged} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showEt && canEt && isImport ? (
|
||||||
|
<DutyCard contractId={contractId} clearance={clearance} onChanged={onChanged} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showEt && canEt && isImport ? (
|
||||||
|
<TransitPermitCard contractId={contractId} onChanged={onChanged} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showDj && canDj && isImport ? (
|
||||||
|
<DeliveryOrderCard contractId={contractId} onChanged={onChanged} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showDj && canDj && !isImport ? (
|
||||||
|
<ReleaseOrderCard
|
||||||
|
contractId={contractId}
|
||||||
|
clearance={clearance}
|
||||||
|
onChanged={onChanged}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showEt && canEt && !isImport ? (
|
||||||
|
<DeclarationCard contractId={contractId} onChanged={onChanged} exportMode />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showEt && canEt && !isImport ? (
|
||||||
|
<ExportReleaseCard contractId={contractId} clearance={clearance} onChanged={onChanged} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{clearance.bookingReady && bookingCreateHref && showEt && canEt ? (
|
||||||
|
<SectionCard icon={Ship} title="Create booking" accent="edr-green">
|
||||||
|
<Text size="sm" c="dimmed" mb="sm">
|
||||||
|
Pre-booking clearance is complete. Create the shipment booking for the customer.
|
||||||
|
</Text>
|
||||||
|
<Button component="a" href={bookingCreateHref} color="edr-green">
|
||||||
|
Create shipment booking
|
||||||
|
</Button>
|
||||||
|
</SectionCard>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeclarationCard({
|
||||||
|
contractId,
|
||||||
|
onChanged,
|
||||||
|
exportMode = false,
|
||||||
|
}: {
|
||||||
|
contractId: string;
|
||||||
|
onChanged?: () => void;
|
||||||
|
exportMode?: boolean;
|
||||||
|
}) {
|
||||||
|
const [files, setFiles] = useState<Record<string, File | null>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fields = exportMode
|
||||||
|
? [
|
||||||
|
{ key: "ex3", label: "EX3" },
|
||||||
|
{ key: "ex8", label: "EX8" },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ key: "im4", label: "IM4" },
|
||||||
|
{ key: "im5", label: "IM5 (optional)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionShell
|
||||||
|
icon={FileText}
|
||||||
|
title="Customs declaration"
|
||||||
|
subtitle={exportMode ? "Upload EX3 / EX8" : "Upload IM4 / IM5"}
|
||||||
|
done={false}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{fields.map((f) => (
|
||||||
|
<FileInput
|
||||||
|
key={f.key}
|
||||||
|
label={f.label}
|
||||||
|
placeholder="Choose file"
|
||||||
|
value={files[f.key] ?? null}
|
||||||
|
onChange={(file) => setFiles((prev) => ({ ...prev, [f.key]: file }))}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={loading}
|
||||||
|
leftSection={<Upload size={16} />}
|
||||||
|
onClick={async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await contractsService.uploadDeclaration(contractId, files);
|
||||||
|
toast.success("Declaration uploaded");
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Submit declaration
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</ActionShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DutyCard({
|
||||||
|
contractId,
|
||||||
|
clearance,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
contractId: string;
|
||||||
|
clearance: Freight.ContractClearanceView;
|
||||||
|
onChanged?: () => void;
|
||||||
|
}) {
|
||||||
|
const [dutyRequired, setDutyRequired] = useState(clearance.dutyRequired ?? true);
|
||||||
|
const [amount, setAmount] = useState<number | string>("");
|
||||||
|
const [currency, setCurrency] = useState("ETB");
|
||||||
|
const [serial, setSerial] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const advised = clearance.milestones?.some(
|
||||||
|
(m) => m.milestoneCode === "DUTY_TAXES_ADVISED" && m.status === "COMPLETED",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionShell
|
||||||
|
icon={Receipt}
|
||||||
|
title="Duty & tax"
|
||||||
|
subtitle="Toggle whether duty applies and advise the amount"
|
||||||
|
done={advised && clearance.dutyRequired === false}
|
||||||
|
doneLabel={clearance.dutyRequired === false ? "Not required" : advised ? "Advised" : undefined}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Switch
|
||||||
|
label="Customer must pay duty/tax"
|
||||||
|
checked={dutyRequired}
|
||||||
|
onChange={(e) => setDutyRequired(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
{dutyRequired ? (
|
||||||
|
<>
|
||||||
|
<Group grow>
|
||||||
|
<NumberInput label="Amount" value={amount} onChange={setAmount} min={0} size="sm" />
|
||||||
|
<Select
|
||||||
|
label="Currency"
|
||||||
|
data={["ETB", "USD"]}
|
||||||
|
value={currency}
|
||||||
|
onChange={(v) => setCurrency(v ?? "ETB")}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<TextInput
|
||||||
|
label="Declaration / payment code"
|
||||||
|
value={serial}
|
||||||
|
onChange={(e) => setSerial(e.currentTarget.value)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={loading}
|
||||||
|
onClick={async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await contractsService.adviseContractDuty(contractId, {
|
||||||
|
dutyRequired,
|
||||||
|
amount: dutyRequired ? Number(amount) : undefined,
|
||||||
|
currency,
|
||||||
|
declarationSerial: serial || undefined,
|
||||||
|
});
|
||||||
|
toast.success(dutyRequired ? "Duty advised" : "Duty step skipped");
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save duty settings
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</ActionShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransitPermitCard({
|
||||||
|
contractId,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
contractId: string;
|
||||||
|
onChanged?: () => void;
|
||||||
|
}) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionShell
|
||||||
|
icon={Upload}
|
||||||
|
title="Transit permit"
|
||||||
|
subtitle="Upload transit permitted screenshot"
|
||||||
|
done={false}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<FileInput
|
||||||
|
label="Transit permit screenshot"
|
||||||
|
value={file}
|
||||||
|
onChange={setFile}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={loading}
|
||||||
|
disabled={!file}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!file) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await contractsService.uploadContractTransitPermit(contractId, file);
|
||||||
|
toast.success("Transit permit uploaded");
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Upload transit permit
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</ActionShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeliveryOrderCard({
|
||||||
|
contractId,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
contractId: string;
|
||||||
|
onChanged?: () => void;
|
||||||
|
}) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionShell
|
||||||
|
icon={Ship}
|
||||||
|
title="Delivery Order"
|
||||||
|
subtitle="GL Djibouti uploads the DO"
|
||||||
|
done={false}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<FileInput label="Delivery Order" value={file} onChange={setFile} size="sm" />
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={loading}
|
||||||
|
disabled={!file}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!file) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await contractsService.uploadDeliveryOrder(contractId, file);
|
||||||
|
toast.success("Delivery Order uploaded");
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Upload DO
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</ActionShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReleaseOrderCard({
|
||||||
|
contractId,
|
||||||
|
clearance,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
contractId: string;
|
||||||
|
clearance: Freight.ContractClearanceView;
|
||||||
|
onChanged?: () => void;
|
||||||
|
}) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [vesselDate, setVesselDate] = useState<Date | null>(
|
||||||
|
clearance.vesselDepartureDate ? new Date(clearance.vesselDepartureDate) : null,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [amendLoading, setAmendLoading] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionShell
|
||||||
|
icon={Ship}
|
||||||
|
title="Release Order"
|
||||||
|
subtitle="Upload RO and vessel departure date"
|
||||||
|
done={false}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<FileInput label="Release Order" value={file} onChange={setFile} size="sm" />
|
||||||
|
<DateInput
|
||||||
|
label="Vessel departure date"
|
||||||
|
value={vesselDate}
|
||||||
|
onChange={setVesselDate}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={loading}
|
||||||
|
disabled={!file || !vesselDate}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!file || !vesselDate) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const iso = vesselDate.toISOString().slice(0, 10);
|
||||||
|
const result = await contractsService.uploadReleaseOrder(
|
||||||
|
contractId,
|
||||||
|
file,
|
||||||
|
iso,
|
||||||
|
);
|
||||||
|
if (result.hold) {
|
||||||
|
toast.error(result.holdReason ?? "Vessel date too soon");
|
||||||
|
} else {
|
||||||
|
toast.success("Release Order accepted");
|
||||||
|
}
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Upload RO
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
loading={amendLoading}
|
||||||
|
onClick={async () => {
|
||||||
|
setAmendLoading(true);
|
||||||
|
try {
|
||||||
|
await contractsService.requestRoAmendment(
|
||||||
|
contractId,
|
||||||
|
"Port amendment requested — vessel window too short.",
|
||||||
|
);
|
||||||
|
toast.success("Amendment request recorded");
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Failed");
|
||||||
|
} finally {
|
||||||
|
setAmendLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Request amendment
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</ActionShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExportReleaseCard({
|
||||||
|
contractId,
|
||||||
|
clearance,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
contractId: string;
|
||||||
|
clearance: Freight.ContractClearanceView;
|
||||||
|
onChanged?: () => void;
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const done = clearance.bookingReady;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionShell
|
||||||
|
icon={FileText}
|
||||||
|
title="Export release"
|
||||||
|
subtitle="Confirm customs clearance complete"
|
||||||
|
done={done}
|
||||||
|
doneLabel={done ? "Ready for booking" : undefined}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={loading}
|
||||||
|
disabled={done}
|
||||||
|
onClick={async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await contractsService.confirmExportRelease(contractId);
|
||||||
|
toast.success("Export released — ready for booking");
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confirm export release
|
||||||
|
</Button>
|
||||||
|
</ActionShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { AssignStationCard } from "./AssignStationCard";
|
|||||||
import { AssignRiskCard } from "./AssignRiskCard";
|
import { AssignRiskCard } from "./AssignRiskCard";
|
||||||
import { AdviseDutyCard } from "./AdviseDutyCard";
|
import { AdviseDutyCard } from "./AdviseDutyCard";
|
||||||
import { GlDocumentUploadCard } from "./GlDocumentUploadCard";
|
import { GlDocumentUploadCard } from "./GlDocumentUploadCard";
|
||||||
|
import { TransportDocumentCard } from "./TransportDocumentCard";
|
||||||
import { IncidentReportCard } from "./IncidentReportCard";
|
import { IncidentReportCard } from "./IncidentReportCard";
|
||||||
|
|
||||||
export interface GlActionsPanelProps {
|
export interface GlActionsPanelProps {
|
||||||
@@ -39,6 +40,16 @@ export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
|
|||||||
() => findMilestone(milestones, "DUTY_TAXES_ADVISED"),
|
() => findMilestone(milestones, "DUTY_TAXES_ADVISED"),
|
||||||
[milestones],
|
[milestones],
|
||||||
);
|
);
|
||||||
|
const wagonMs = useMemo(
|
||||||
|
() => findMilestone(milestones, "WAGON_ALLOCATED"),
|
||||||
|
[milestones],
|
||||||
|
);
|
||||||
|
const transportMs = useMemo(
|
||||||
|
() => findMilestone(milestones, "EXPORT_TRANSPORT_ISSUED"),
|
||||||
|
[milestones],
|
||||||
|
);
|
||||||
|
const showTransport =
|
||||||
|
wagonMs?.status === "COMPLETED" && transportMs?.status !== "COMPLETED";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard icon={Flag} title="Global Logistics actions" accent="edr-green">
|
<SectionCard icon={Flag} title="Global Logistics actions" accent="edr-green">
|
||||||
@@ -56,6 +67,8 @@ export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
|
|||||||
|
|
||||||
<GlDocumentUploadCard bookingId={bookingId} />
|
<GlDocumentUploadCard bookingId={bookingId} />
|
||||||
|
|
||||||
|
{showTransport ? <TransportDocumentCard bookingId={bookingId} /> : null}
|
||||||
|
|
||||||
{riskMs ? (
|
{riskMs ? (
|
||||||
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
|
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button, FileInput, Stack } from "@mantine/core";
|
||||||
|
import { FileText } from "lucide-react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { ActionShell } from "./ActionShell";
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
|
||||||
|
export function TransportDocumentCard({ bookingId }: { bookingId: string }) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionShell
|
||||||
|
icon={FileText}
|
||||||
|
title="Export transport document"
|
||||||
|
subtitle="Upload after wagon allocation (GL Ethiopia)"
|
||||||
|
done={false}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<FileInput
|
||||||
|
label="Transport document"
|
||||||
|
value={file}
|
||||||
|
onChange={setFile}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={loading}
|
||||||
|
disabled={!file}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!file) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await contractsService.uploadTransportDocument(bookingId, file);
|
||||||
|
toast.success("Transport document uploaded");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Upload document
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</ActionShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -145,6 +145,21 @@ export const URL_CONSTANTS = {
|
|||||||
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
|
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
|
||||||
`/contracts/${id}/clearance/output-documents`,
|
`/contracts/${id}/clearance/output-documents`,
|
||||||
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
|
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
|
||||||
|
CLEARANCE_DECLARATION: (id: string) => `/contracts/${id}/clearance/declaration`,
|
||||||
|
CLEARANCE_DUTY: (id: string) => `/contracts/${id}/clearance/duty`,
|
||||||
|
CLEARANCE_DUTY_SLIP: (id: string) => `/contracts/${id}/clearance/duty-slip`,
|
||||||
|
CLEARANCE_TRANSIT_PERMIT: (id: string) =>
|
||||||
|
`/contracts/${id}/clearance/transit-permit`,
|
||||||
|
CLEARANCE_DELIVERY_ORDER: (id: string) =>
|
||||||
|
`/contracts/${id}/clearance/delivery-order`,
|
||||||
|
CLEARANCE_RELEASE_ORDER: (id: string) =>
|
||||||
|
`/contracts/${id}/clearance/release-order`,
|
||||||
|
CLEARANCE_RO_AMENDMENT: (id: string) =>
|
||||||
|
`/contracts/${id}/clearance/ro-amendment`,
|
||||||
|
CLEARANCE_EXPORT_RELEASE: (id: string) =>
|
||||||
|
`/contracts/${id}/clearance/export-release`,
|
||||||
|
CLEARANCE_ET_QUEUE: "/contracts/clearance/et-queue",
|
||||||
|
CLEARANCE_DJ_QUEUE: "/contracts/clearance/dj-queue",
|
||||||
// Path A self-clearance — Operations reviews the customer's own clearance docs.
|
// Path A self-clearance — Operations reviews the customer's own clearance docs.
|
||||||
OPS_CLEARANCE_QUEUE: "/contracts/clearance/ops-queue",
|
OPS_CLEARANCE_QUEUE: "/contracts/clearance/ops-queue",
|
||||||
OPS_CLEARANCE_REVIEW: (id: string) =>
|
OPS_CLEARANCE_REVIEW: (id: string) =>
|
||||||
@@ -178,6 +193,8 @@ export const URL_CONSTANTS = {
|
|||||||
`/contracts/bookings/${bookingId}/station-assign`,
|
`/contracts/bookings/${bookingId}/station-assign`,
|
||||||
BOOKING_GL_DOCUMENTS: (bookingId: string) =>
|
BOOKING_GL_DOCUMENTS: (bookingId: string) =>
|
||||||
`/contracts/bookings/${bookingId}/documents`,
|
`/contracts/bookings/${bookingId}/documents`,
|
||||||
|
BOOKING_TRANSPORT_DOCUMENT: (bookingId: string) =>
|
||||||
|
`/contracts/bookings/${bookingId}/transport-document`,
|
||||||
BOOKING_INCIDENTS: (bookingId: string) =>
|
BOOKING_INCIDENTS: (bookingId: string) =>
|
||||||
`/contracts/bookings/${bookingId}/incidents`,
|
`/contracts/bookings/${bookingId}/incidents`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -52,6 +52,22 @@ export function useContractClearanceQueue(enabled = true) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useEtClearanceQueue(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("ET"),
|
||||||
|
queryFn: () => contractsService.getEtClearanceQueue(),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDjClearanceQueue(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("DJ"),
|
||||||
|
queryFn: () => contractsService.getDjClearanceQueue(),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
|
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
|
||||||
export function useOpsClearanceQueue(enabled = true) {
|
export function useOpsClearanceQueue(enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ export const FREIGHT_PERMS = {
|
|||||||
clearanceReview: "edr_freight_app:contracts:clearance_review",
|
clearanceReview: "edr_freight_app:contracts:clearance_review",
|
||||||
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
|
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
|
||||||
createBooking: "edr_freight_app:contracts:create_booking",
|
createBooking: "edr_freight_app:contracts:create_booking",
|
||||||
opsClearanceReview: "edr_freight_app:contracts:ops_clearance_review",
|
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
||||||
|
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
|
||||||
|
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
||||||
},
|
},
|
||||||
trainScheduling: {
|
trainScheduling: {
|
||||||
view: "edr_freight_app:train_scheduling:view",
|
view: "edr_freight_app:train_scheduling:view",
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ import { PageContainer } from "@/components/page/PageContainer";
|
|||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||||
|
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
||||||
|
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||||
|
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
||||||
@@ -40,6 +43,7 @@ export default function ContractClearanceDetailPage() {
|
|||||||
data: clearance,
|
data: clearance,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
|
refetch,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
||||||
queryFn: () => contractsService.getClearance(id!),
|
queryFn: () => contractsService.getClearance(id!),
|
||||||
@@ -61,7 +65,7 @@ export default function ContractClearanceDetailPage() {
|
|||||||
const reference = contract?.reference ?? "Clearance";
|
const reference = contract?.reference ?? "Clearance";
|
||||||
// Customs (Path B) hub. The customer always creates the booking in the portal
|
// Customs (Path B) hub. The customer always creates the booking in the portal
|
||||||
// after GL finalizes clearance — there is no GL "Create booking" action here.
|
// after GL finalizes clearance — there is no GL "Create booking" action here.
|
||||||
const ready = clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
|
const ready = clearance?.bookingReady ?? clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
|
||||||
const clearanceReadOnly = Boolean(
|
const clearanceReadOnly = Boolean(
|
||||||
contract?.status &&
|
contract?.status &&
|
||||||
[
|
[
|
||||||
@@ -152,15 +156,23 @@ export default function ContractClearanceDetailPage() {
|
|||||||
|
|
||||||
<ClearanceHero contract={contract} stats={stats} />
|
<ClearanceHero contract={contract} stats={stats} />
|
||||||
|
|
||||||
|
{contract?.contractKind === "ONE_TIME" && contract.customsClearingEnabled ? (
|
||||||
|
<Paper withBorder radius="md" p="lg">
|
||||||
|
<ClearancePhaseStepper
|
||||||
|
clearance={clearance}
|
||||||
|
tradeDirection={contract.tradeDirection}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{ready ? (
|
{ready ? (
|
||||||
<Alert
|
<Alert
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="md"
|
radius="md"
|
||||||
icon={<PackageCheck size={16} />}
|
icon={<PackageCheck size={16} />}
|
||||||
title="Clearance finalized"
|
title="Clearance complete"
|
||||||
>
|
>
|
||||||
Customs clearance is complete. The customer can now create the
|
Pre-booking clearance is complete. GL Ethiopia can create the shipment booking.
|
||||||
shipment booking from the portal — no further action is needed here.
|
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -171,55 +183,81 @@ export default function ContractClearanceDetailPage() {
|
|||||||
hideSummary
|
hideSummary
|
||||||
selfClear={false}
|
selfClear={false}
|
||||||
readOnly={clearanceReadOnly}
|
readOnly={clearanceReadOnly}
|
||||||
|
onChanged={() => void refetch()}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
<Box style={{ position: "sticky", top: 24 }}>
|
<Stack gap="md">
|
||||||
<SectionCard
|
{contract?.contractKind === "ONE_TIME" && contract.customsClearingEnabled ? (
|
||||||
icon={PackageCheck}
|
<PhasedClearanceActionPanel
|
||||||
title="Review progress"
|
contractId={id!}
|
||||||
accent="edr-green"
|
clearance={clearance}
|
||||||
>
|
tradeDirection={contract.tradeDirection}
|
||||||
<Stack align="center" gap="sm">
|
roleMode="ALL"
|
||||||
<RingProgress
|
onChanged={() => void refetch()}
|
||||||
size={140}
|
bookingCreateHref={
|
||||||
thickness={12}
|
ready ? `/dashboard/contracts/${id}/create-booking` : undefined
|
||||||
roundCaps
|
}
|
||||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
/>
|
||||||
label={
|
) : (
|
||||||
<Stack gap={0} align="center">
|
<Box style={{ position: "sticky", top: 24 }}>
|
||||||
<Text fw={800} fz={26} lh={1}>
|
<SectionCard
|
||||||
{stats.pct}%
|
icon={PackageCheck}
|
||||||
</Text>
|
title="Review progress"
|
||||||
<Text size="xs" c="dimmed">
|
accent="edr-green"
|
||||||
approved
|
>
|
||||||
</Text>
|
<Stack align="center" gap="sm">
|
||||||
</Stack>
|
<RingProgress
|
||||||
}
|
size={140}
|
||||||
/>
|
thickness={12}
|
||||||
<Group gap="lg" justify="center">
|
roundCaps
|
||||||
<ProgressStat
|
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||||
color="edr-green"
|
label={
|
||||||
label="Approved"
|
<Stack gap={0} align="center">
|
||||||
value={stats.approved}
|
<Text fw={800} fz={26} lh={1}>
|
||||||
/>
|
{stats.pct}%
|
||||||
<ProgressStat
|
</Text>
|
||||||
color="red"
|
<Text size="xs" c="dimmed">
|
||||||
label="Queried"
|
approved
|
||||||
value={stats.queried}
|
</Text>
|
||||||
/>
|
</Stack>
|
||||||
<ProgressStat
|
}
|
||||||
color="gray"
|
/>
|
||||||
label="Pending"
|
<Group gap="lg" justify="center">
|
||||||
value={stats.pending}
|
<ProgressStat
|
||||||
/>
|
color="edr-green"
|
||||||
</Group>
|
label="Approved"
|
||||||
</Stack>
|
value={stats.approved}
|
||||||
</SectionCard>
|
/>
|
||||||
</Box>
|
<ProgressStat
|
||||||
|
color="red"
|
||||||
|
label="Queried"
|
||||||
|
value={stats.queried}
|
||||||
|
/>
|
||||||
|
<ProgressStat
|
||||||
|
color="gray"
|
||||||
|
label="Pending"
|
||||||
|
value={stats.pending}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{clearance.milestones && clearance.milestones.length > 0 ? (
|
||||||
|
<ClearanceMilestoneTimeline
|
||||||
|
milestones={
|
||||||
|
clearance.milestones as Parameters<
|
||||||
|
typeof ClearanceMilestoneTimeline
|
||||||
|
>[0]["milestones"]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { Alert, Grid, Loader, Paper, Stack, Text } from "@mantine/core";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
|
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||||
|
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
||||||
|
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||||
|
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||||
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
||||||
|
|
||||||
|
export default function GlClearanceDetailPage({
|
||||||
|
backTo,
|
||||||
|
breadcrumbsLabel,
|
||||||
|
roleMode,
|
||||||
|
}: {
|
||||||
|
backTo: string;
|
||||||
|
breadcrumbsLabel: string;
|
||||||
|
roleMode: "ET" | "DJ";
|
||||||
|
}) {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { data: contract } = useContractDetail(id);
|
||||||
|
const {
|
||||||
|
data: clearance,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
||||||
|
queryFn: () => contractsService.getClearance(id!),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Stack align="center" py={80}>
|
||||||
|
<Loader color="edr-green" />
|
||||||
|
<Text c="dimmed">Loading clearance…</Text>
|
||||||
|
</Stack>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !clearance || !contract) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Alert color="red" icon={<AlertCircle size={16} />}>
|
||||||
|
Could not load clearance for this contract.
|
||||||
|
</Alert>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingHref =
|
||||||
|
roleMode === "ET" ? `/dashboard/contracts/${id}/create-booking` : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<PageHeader
|
||||||
|
title={contract.reference}
|
||||||
|
backTo={backTo}
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: breadcrumbsLabel, href: backTo },
|
||||||
|
{ label: contract.reference },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="lg">
|
||||||
|
<ClearancePhaseStepper
|
||||||
|
clearance={clearance}
|
||||||
|
tradeDirection={contract.tradeDirection}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={{ base: 12, lg: roleMode === "DJ" ? 12 : 7 }}>
|
||||||
|
{roleMode === "ET" ? (
|
||||||
|
<ContractClearanceReviewSection
|
||||||
|
contractId={id!}
|
||||||
|
hideSummary
|
||||||
|
selfClear={false}
|
||||||
|
readOnly={false}
|
||||||
|
onChanged={() => void refetch()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SectionCard title="Contract context" accent="edr-green">
|
||||||
|
<Text size="sm" c="dimmed" mb="sm">
|
||||||
|
Review upstream status before uploading Djibouti documents.
|
||||||
|
</Text>
|
||||||
|
<ContractClearanceReviewSection
|
||||||
|
contractId={id!}
|
||||||
|
hideSummary
|
||||||
|
selfClear={false}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, lg: roleMode === "DJ" ? 12 : 5 }}>
|
||||||
|
<PhasedClearanceActionPanel
|
||||||
|
contractId={id!}
|
||||||
|
clearance={clearance}
|
||||||
|
tradeDirection={contract.tradeDirection}
|
||||||
|
roleMode={roleMode}
|
||||||
|
onChanged={() => void refetch()}
|
||||||
|
bookingCreateHref={bookingHref}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{clearance.milestones && clearance.milestones.length > 0 ? (
|
||||||
|
<ClearanceMilestoneTimeline
|
||||||
|
milestones={clearance.milestones as Parameters<typeof ClearanceMilestoneTimeline>[0]["milestones"]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
|
||||||
|
import { ChevronRight, Ship } from "lucide-react";
|
||||||
|
|
||||||
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
|
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||||
|
|
||||||
|
export default function GlDjiboutiClearanceListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, isLoading } = useDjClearanceQueue();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title="GL Djibouti — Clearance"
|
||||||
|
subtitle="Contracts awaiting Djibouti GL action (DO / RO)."
|
||||||
|
/>
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py={60}>
|
||||||
|
<Loader color="edr-green" />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{(data?.items ?? []).length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
|
No contracts need Djibouti GL action right now.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
(data?.items ?? []).map((c) => (
|
||||||
|
<Card
|
||||||
|
key={c.id}
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
padding="md"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="sm">
|
||||||
|
<Ship size={18} className="text-[color:var(--freight-brand)]" />
|
||||||
|
<div>
|
||||||
|
<Text fw={700}>{c.reference}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{c.tradeDirection} · {c.status}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge variant="light" color="cyan">
|
||||||
|
{c.tradeDirection}
|
||||||
|
</Badge>
|
||||||
|
<ChevronRight size={18} className="text-muted-foreground" />
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
|
||||||
|
import { ChevronRight, Flag } from "lucide-react";
|
||||||
|
|
||||||
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
|
import { useEtClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||||
|
|
||||||
|
export default function GlEthiopiaClearanceListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, isLoading } = useEtClearanceQueue();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title="GL Ethiopia — Clearance"
|
||||||
|
subtitle="Contracts awaiting Ethiopia GL action in the phased clearance workflow."
|
||||||
|
/>
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py={60}>
|
||||||
|
<Loader color="edr-green" />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{(data?.items ?? []).length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
|
No contracts need ET GL action right now.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
(data?.items ?? []).map((c) => (
|
||||||
|
<Card
|
||||||
|
key={c.id}
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
padding="md"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/dashboard/gl-ethiopia/clearance/${c.id}`)}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="sm">
|
||||||
|
<Flag size={18} className="text-[color:var(--freight-brand)]" />
|
||||||
|
<div>
|
||||||
|
<Text fw={700}>{c.reference}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{c.tradeDirection} · {c.status}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge variant="light" color="edr-green">
|
||||||
|
{c.tradeDirection}
|
||||||
|
</Badge>
|
||||||
|
<ChevronRight size={18} className="text-muted-foreground" />
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -207,6 +207,105 @@ export const contractsService = {
|
|||||||
finalizeClearance: (id: string) =>
|
finalizeClearance: (id: string) =>
|
||||||
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE(id)),
|
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE(id)),
|
||||||
|
|
||||||
|
getEtClearanceQueue: async (): Promise<PaginatedContracts> => {
|
||||||
|
const response = await client.get<PaginatedContracts>(C.CLEARANCE_ET_QUEUE);
|
||||||
|
const data = unwrap(response.data);
|
||||||
|
return {
|
||||||
|
items: (data.items ?? []) as Freight.IContract[],
|
||||||
|
total: data.total ?? 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getDjClearanceQueue: async (): Promise<PaginatedContracts> => {
|
||||||
|
const response = await client.get<PaginatedContracts>(C.CLEARANCE_DJ_QUEUE);
|
||||||
|
const data = unwrap(response.data);
|
||||||
|
return {
|
||||||
|
items: (data.items ?? []) as Freight.IContract[],
|
||||||
|
total: data.total ?? 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadDeclaration: async (
|
||||||
|
id: string,
|
||||||
|
files: Record<string, File | null>,
|
||||||
|
): Promise<Freight.IContract> => {
|
||||||
|
const form = new FormData();
|
||||||
|
for (const [key, file] of Object.entries(files)) {
|
||||||
|
if (file) form.append(key, file);
|
||||||
|
}
|
||||||
|
const response = await client.post(C.CLEARANCE_DECLARATION(id), form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
return unwrap(response.data) as Freight.IContract;
|
||||||
|
},
|
||||||
|
|
||||||
|
adviseContractDuty: (
|
||||||
|
id: string,
|
||||||
|
payload: {
|
||||||
|
dutyRequired: boolean;
|
||||||
|
amount?: number;
|
||||||
|
currency?: string;
|
||||||
|
declarationSerial?: string;
|
||||||
|
},
|
||||||
|
) => postContract<Freight.IContract>(C.CLEARANCE_DUTY(id), payload),
|
||||||
|
|
||||||
|
uploadContractTransitPermit: async (
|
||||||
|
id: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<Freight.IContract> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const response = await client.post(C.CLEARANCE_TRANSIT_PERMIT(id), form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
return unwrap(response.data) as Freight.IContract;
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadDeliveryOrder: async (
|
||||||
|
id: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<Freight.IContract> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const response = await client.post(C.CLEARANCE_DELIVERY_ORDER(id), form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
return unwrap(response.data) as Freight.IContract;
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadReleaseOrder: async (
|
||||||
|
id: string,
|
||||||
|
file: File,
|
||||||
|
vesselDepartureDate: string,
|
||||||
|
): Promise<{ contract: Freight.IContract; hold: boolean; holdReason?: string }> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
form.append("vesselDepartureDate", vesselDepartureDate);
|
||||||
|
const response = await client.post(C.CLEARANCE_RELEASE_ORDER(id), form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
return unwrap(response.data) as {
|
||||||
|
contract: Freight.IContract;
|
||||||
|
hold: boolean;
|
||||||
|
holdReason?: string;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
requestRoAmendment: (id: string, note?: string) =>
|
||||||
|
postContract<Freight.IContract>(C.CLEARANCE_RO_AMENDMENT(id), { note }),
|
||||||
|
|
||||||
|
confirmExportRelease: (id: string) =>
|
||||||
|
postContract<Freight.IContract>(C.CLEARANCE_EXPORT_RELEASE(id)),
|
||||||
|
|
||||||
|
uploadTransportDocument: async (bookingId: string, file: File) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const response = await client.post(C.BOOKING_TRANSPORT_DOCUMENT(bookingId), form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
// ── Path A self-clearance (Operations review) ──
|
// ── Path A self-clearance (Operations review) ──
|
||||||
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
|
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
|
||||||
const response = await client.get<PaginatedContracts>(
|
const response = await client.get<PaginatedContracts>(
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export const URL_CONSTANTS = {
|
|||||||
CLEARANCE: (id: string) => `/api/contracts/${id}/clearance`,
|
CLEARANCE: (id: string) => `/api/contracts/${id}/clearance`,
|
||||||
CLEARANCE_DOCUMENTS: (id: string) =>
|
CLEARANCE_DOCUMENTS: (id: string) =>
|
||||||
`/api/contracts/${id}/clearance/documents`,
|
`/api/contracts/${id}/clearance/documents`,
|
||||||
|
CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`,
|
||||||
BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`,
|
BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`,
|
||||||
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,
|
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,
|
||||||
BOOKING_MILESTONES: (bookingId: string) =>
|
BOOKING_MILESTONES: (bookingId: string) =>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
const BRAND_GREEN = "var(--freight-brand, #0A6F4D)";
|
||||||
|
|
||||||
|
const PHASE_LABELS: Record<string, string> = {
|
||||||
|
CUSTOMER_INTAKE: "Customer docs",
|
||||||
|
GL_ET_REVIEW: "GL ET review",
|
||||||
|
GL_DJ_COLLECTION: "GL Djibouti",
|
||||||
|
GL_ET_OUTPUT: "Declaration",
|
||||||
|
CUSTOMER_DUTY: "Duty / tax",
|
||||||
|
GL_ET_POST_CLEARANCE: "ET clearance",
|
||||||
|
GL_DJ_LOADING: "Loading",
|
||||||
|
POST_TRANSIT: "Transit",
|
||||||
|
};
|
||||||
|
|
||||||
|
const IMPORT_PHASES = [
|
||||||
|
"CUSTOMER_INTAKE",
|
||||||
|
"GL_ET_REVIEW",
|
||||||
|
"GL_ET_OUTPUT",
|
||||||
|
"CUSTOMER_DUTY",
|
||||||
|
"GL_ET_POST_CLEARANCE",
|
||||||
|
"GL_DJ_COLLECTION",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const EXPORT_PHASES = [
|
||||||
|
"CUSTOMER_INTAKE",
|
||||||
|
"GL_ET_REVIEW",
|
||||||
|
"GL_DJ_COLLECTION",
|
||||||
|
"GL_ET_OUTPUT",
|
||||||
|
"GL_ET_POST_CLEARANCE",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function phaseIndex(phases: readonly string[], current?: string | null): number {
|
||||||
|
if (!current) return 0;
|
||||||
|
const idx = phases.indexOf(current);
|
||||||
|
return idx >= 0 ? idx : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClearancePhaseStepper({
|
||||||
|
clearance,
|
||||||
|
tradeDirection,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
clearance?: Freight.ContractClearanceView | null;
|
||||||
|
tradeDirection?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const phases = tradeDirection === "EXPORT" ? EXPORT_PHASES : IMPORT_PHASES;
|
||||||
|
const current = clearance?.phase ?? phases[0];
|
||||||
|
const activeIdx = phaseIndex(phases, current);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap={0} wrap="nowrap" align="flex-start" style={{ overflowX: "auto" }}>
|
||||||
|
{phases.map((phase, index) => {
|
||||||
|
const isComplete = index < activeIdx;
|
||||||
|
const isActive = index === activeIdx;
|
||||||
|
const isLast = index === phases.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={phase} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: compact ? 72 : 88 }}>
|
||||||
|
<Group gap={0} wrap="nowrap" align="center">
|
||||||
|
<Stack gap={4} align="center" style={{ flexShrink: 0 }}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: compact ? 28 : 34,
|
||||||
|
height: compact ? 28 : 34,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: isComplete ? BRAND_GREEN : isActive ? "white" : "var(--mantine-color-gray-1)",
|
||||||
|
border: isActive
|
||||||
|
? `2px solid ${BRAND_GREEN}`
|
||||||
|
: isComplete
|
||||||
|
? "2px solid transparent"
|
||||||
|
: "2px solid var(--mantine-color-gray-3)",
|
||||||
|
color: isComplete ? "white" : isActive ? BRAND_GREEN : "var(--mantine-color-gray-5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isComplete ? <Check size={compact ? 14 : 16} strokeWidth={3} /> : null}
|
||||||
|
</Box>
|
||||||
|
<Text
|
||||||
|
size={compact ? "10px" : "xs"}
|
||||||
|
fw={isActive ? 600 : 500}
|
||||||
|
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
|
||||||
|
ta="center"
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{PHASE_LABELS[phase] ?? phase}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{!isLast && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: 2,
|
||||||
|
marginInline: 6,
|
||||||
|
marginBottom: compact ? 16 : 20,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, Button, FileInput, Paper, Stack, Text } from "@mantine/core";
|
||||||
|
import { AlertTriangle, Receipt, Upload } from "lucide-react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
import { ClearancePhaseStepper } from "./ClearancePhaseStepper";
|
||||||
|
|
||||||
|
const BORDER = "#E6ECF2";
|
||||||
|
|
||||||
|
export function ContractClearanceWorkflowBanner({
|
||||||
|
contract,
|
||||||
|
}: {
|
||||||
|
contract: Freight.IContract;
|
||||||
|
}) {
|
||||||
|
const isPhased =
|
||||||
|
contract.customsClearingEnabled && contract.contractKind === "ONE_TIME";
|
||||||
|
|
||||||
|
const { data: clearance, refetch } = useQuery({
|
||||||
|
queryKey: ["contract-clearance", contract.id],
|
||||||
|
queryFn: () => contractsService.getClearance(contract.id),
|
||||||
|
enabled: isPhased,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isPhased || !clearance) return null;
|
||||||
|
|
||||||
|
const dutyPending =
|
||||||
|
clearance.dutyRequired &&
|
||||||
|
clearance.milestones?.some(
|
||||||
|
(m) => m.milestoneCode === "DUTY_TAXES_ADVISED" && m.status === "COMPLETED",
|
||||||
|
) &&
|
||||||
|
!clearance.milestones?.some(
|
||||||
|
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text fw={700} size="sm">
|
||||||
|
Clearance progress
|
||||||
|
</Text>
|
||||||
|
<ClearancePhaseStepper
|
||||||
|
clearance={clearance}
|
||||||
|
tradeDirection={contract.tradeDirection}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
|
||||||
|
{clearance.roHold && clearance.roHoldReason ? (
|
||||||
|
<Alert color="orange" icon={<AlertTriangle size={16} />} title="Release Order on hold">
|
||||||
|
{clearance.roHoldReason}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{clearance.nextAction?.actor === "CUSTOMER" ? (
|
||||||
|
<Alert color="blue" variant="light">
|
||||||
|
{clearance.nextAction.action}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{dutyPending ? (
|
||||||
|
<DutySlipUpload contractId={contract.id} onUploaded={() => void refetch()} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{clearance.bookingReady ? (
|
||||||
|
<Alert color="green" variant="light">
|
||||||
|
Clearance is complete. Global Logistics will create your shipment booking shortly.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DutySlipUpload({
|
||||||
|
contractId,
|
||||||
|
onUploaded,
|
||||||
|
}: {
|
||||||
|
contractId: string;
|
||||||
|
onUploaded: () => void;
|
||||||
|
}) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<GroupLabel icon={Receipt} text="Duty / tax payment" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Upload your duty/tax payment slip so clearance can continue.
|
||||||
|
</Text>
|
||||||
|
<FileInput
|
||||||
|
label="Payment slip"
|
||||||
|
value={file}
|
||||||
|
onChange={setFile}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="orange"
|
||||||
|
loading={loading}
|
||||||
|
disabled={!file}
|
||||||
|
leftSection={<Upload size={16} />}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!file) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await contractsService.uploadContractDutySlip(contractId, file);
|
||||||
|
toast.success("Payment slip uploaded");
|
||||||
|
onUploaded();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Submit payment slip
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<Icon size={16} />
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ import { useFileViewer } from "@/hooks/useFileViewer";
|
|||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
||||||
import { ContractClearancePanel } from "./ContractClearancePanel";
|
import { ContractClearancePanel } from "./ContractClearancePanel";
|
||||||
|
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
|
||||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||||
import {
|
import {
|
||||||
BORDER,
|
BORDER,
|
||||||
@@ -542,6 +543,10 @@ export default function ContractDetailPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{customsPath && contract.contractKind === "ONE_TIME" ? (
|
||||||
|
<ContractClearanceWorkflowBanner contract={contract} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
{canUploadClearance && (
|
{canUploadClearance && (
|
||||||
<Paper
|
<Paper
|
||||||
withBorder
|
withBorder
|
||||||
|
|||||||
@@ -246,6 +246,18 @@ export const contractsService = {
|
|||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
uploadContractDutySlip: async (
|
||||||
|
id: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<Freight.IContract> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const { data } = await client.post(C.CLEARANCE_DUTY_SLIP(id), form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
// ── Booking under contract (Path A customer) ──
|
// ── Booking under contract (Path A customer) ──
|
||||||
createBookingUnderContract: async (
|
createBookingUnderContract: async (
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -259,6 +259,27 @@ export interface ContractClearanceView {
|
|||||||
documents: ContractClearanceDocument[];
|
documents: ContractClearanceDocument[];
|
||||||
/** True once every required customer document is APPROVED. */
|
/** True once every required customer document is APPROVED. */
|
||||||
allApproved: boolean;
|
allApproved: boolean;
|
||||||
|
/** Current phased clearance step (ONE_TIME customs). */
|
||||||
|
phase?: ContractDocPhase | null;
|
||||||
|
/** Pre-booking milestone rows for this contract cycle. */
|
||||||
|
milestones?: IClearanceMilestone[];
|
||||||
|
/** What should happen next in the workflow. */
|
||||||
|
nextAction?: ClearanceNextAction | null;
|
||||||
|
dutyRequired?: boolean | null;
|
||||||
|
roHold?: boolean;
|
||||||
|
roHoldReason?: string | null;
|
||||||
|
vesselDepartureDate?: string | null;
|
||||||
|
roAmendmentRequestedAt?: string | null;
|
||||||
|
bookingReady?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClearanceActorRole = "CUSTOMER" | "GL_ET" | "GL_DJ" | "OPERATIONS";
|
||||||
|
|
||||||
|
export interface ClearanceNextAction {
|
||||||
|
actor: ClearanceActorRole;
|
||||||
|
action: string;
|
||||||
|
milestoneCode?: string | null;
|
||||||
|
blockedReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Phased clearance document upload slots (doc §5.13). */
|
/** Phased clearance document upload slots (doc §5.13). */
|
||||||
@@ -334,6 +355,7 @@ export const IMPORT_MILESTONES = [
|
|||||||
"DECLARED",
|
"DECLARED",
|
||||||
"DUTY_TAXES_ADVISED",
|
"DUTY_TAXES_ADVISED",
|
||||||
"DUTY_TAX_PAID",
|
"DUTY_TAX_PAID",
|
||||||
|
"TRANSIT_PERMIT_UPLOADED",
|
||||||
"DO_COLLECTED",
|
"DO_COLLECTED",
|
||||||
"WAGON_REQUESTED",
|
"WAGON_REQUESTED",
|
||||||
"FREIGHT_PAYMENT_SETTLED",
|
"FREIGHT_PAYMENT_SETTLED",
|
||||||
@@ -366,6 +388,7 @@ export const EXPORT_MILESTONES = [
|
|||||||
"FREIGHT_PAYMENT_PENDING",
|
"FREIGHT_PAYMENT_PENDING",
|
||||||
"FREIGHT_PAYMENT_SETTLED",
|
"FREIGHT_PAYMENT_SETTLED",
|
||||||
"WAGON_ALLOCATED",
|
"WAGON_ALLOCATED",
|
||||||
|
"EXPORT_TRANSPORT_ISSUED",
|
||||||
"CARGO_ARRIVED",
|
"CARGO_ARRIVED",
|
||||||
"READY_FOR_LOADING",
|
"READY_FOR_LOADING",
|
||||||
"LOADED",
|
"LOADED",
|
||||||
|
|||||||
Reference in New Issue
Block a user