mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
@@ -118,8 +118,7 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||
migrationsRun: true,
|
||||
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
|
||||
synchronize: false,
|
||||
logging: process.env.DB_LOG
|
||||
? process.env.DB_LOG === "true"
|
||||
: process.env.NODE_ENV === "development",
|
||||
logging:
|
||||
process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -200,7 +200,9 @@ export class ContractDocumentViewModelBuilder {
|
||||
serviceType: this.valueOrDash(
|
||||
contract.serviceType?.serviceName ?? contract.serviceType?.code,
|
||||
),
|
||||
scheduledDate: this.formatDate(contract.estimatedShipmentDate),
|
||||
// Estimated shipment date was removed from the contract wizard; the
|
||||
// binding scheduled date is set per-booking, not on the contract.
|
||||
scheduledDate: this.formatDate(null),
|
||||
contractType: this.valueOrDash(contract.contractType),
|
||||
cargoDescription: this.valueOrDash(cargoName),
|
||||
totalWeightVgm: '—',
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Bulk / break-bulk freight can now declare HOW MUCH of the cargo is hazardous
|
||||
* or refrigerated, in the cargo's own unit of measure (tons for PER_TON, item
|
||||
* count for PER_ITEM). These two columns hold that amount on the booking; they
|
||||
* stay 0 for container freight (which tracks it per line on booking_container)
|
||||
* and for bulk cargo with no hazardous/reefer portion. The existing
|
||||
* is_hazardous / is_reefer booleans remain the surcharge trigger.
|
||||
*/
|
||||
export class AddBulkHazmatReeferQuantity1828000000000 implements MigrationInterface {
|
||||
name = 'AddBulkHazmatReeferQuantity1828000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_reefer_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_reefer_quantity;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_hazardous_quantity;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class BookingClearanceMeta1829000000002 implements MigrationInterface {
|
||||
name = 'BookingClearanceMeta1829000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS clearance_current_phase VARCHAR(40);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS clearance_current_phase;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS duty_required;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS vessel_departure_date;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS ro_amendment_requested_at;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS ro_hold_reason;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class DropCargoTypeShowFreeTextBox1830000000000 implements MigrationInterface {
|
||||
name = 'DropCargoTypeShowFreeTextBox1830000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types
|
||||
DROP COLUMN IF EXISTS show_free_text_box
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargo_types
|
||||
ADD COLUMN IF NOT EXISTS show_free_text_box boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class RouteStatusAndSegmentKm1830000000001 implements MigrationInterface {
|
||||
name = 'RouteStatusAndSegmentKm1830000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes
|
||||
ADD COLUMN IF NOT EXISTS status varchar(32) NOT NULL DEFAULT 'AVAILABLE'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.routes
|
||||
SET status = CASE WHEN is_active = true THEN 'AVAILABLE' ELSE 'STOP_WORKING' END
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight."IDX_routes_name"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes DROP COLUMN IF EXISTS name
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes DROP COLUMN IF EXISTS is_active
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_routes_status" ON freight.routes (status)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.route_milestones
|
||||
ADD COLUMN IF NOT EXISTS distance_km numeric(10,2)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.route_milestones DROP COLUMN IF EXISTS distance_km
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes
|
||||
ADD COLUMN IF NOT EXISTS name varchar(120)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.routes SET name = id::text WHERE name IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes ALTER COLUMN name SET NOT NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes
|
||||
ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.routes
|
||||
SET is_active = CASE WHEN status = 'AVAILABLE' THEN true ELSE false END
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.routes DROP COLUMN IF EXISTS status
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight."IDX_routes_status"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_routes_name" ON freight.routes (name)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class PreClearanceFinalizedAt1830000000002 implements MigrationInterface {
|
||||
name = 'PreClearanceFinalizedAt1830000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at TIMESTAMPTZ;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at TIMESTAMPTZ;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS pre_clearance_finalized_at;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS pre_clearance_finalized_at;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,6 @@ export function buildCargoTypeTree(
|
||||
id: child.id,
|
||||
name: child.cargoTypeName,
|
||||
code: child.code,
|
||||
show_free_text_box: child.showFreeTextBox,
|
||||
unit_of_measure: child.unitOfMeasure ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -35,6 +35,8 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
{} as never, // fileUploadSettingsService
|
||||
{} as never, // bookingBatchService
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository, ruleEngineService };
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
||||
fileUploadSettingsService as never,
|
||||
{} as never, // bookingBatchService
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository };
|
||||
}
|
||||
@@ -128,6 +130,8 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
||||
fileUploadSettingsService as never,
|
||||
{} as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository };
|
||||
}
|
||||
@@ -196,6 +200,8 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
||||
fileUploadSettingsService as never,
|
||||
{} as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository, filesService };
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ describe('BookingTransitionService — operation review', () => {
|
||||
{} as never, // fileUploadSettingsService
|
||||
bookingBatchService as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, bookingsRepository, bookingBatchService };
|
||||
}
|
||||
|
||||
@@ -7,28 +7,30 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { assertCanApproveBookingStep } from "../../common/freight-permission.util";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { eatDay } from "../train-scheduling/batch-window.util";
|
||||
import { isRoadService } from "./road.util";
|
||||
import { RuleEngineService } from "../rule-engine/rule-engine.service";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
||||
import { BookingContractService } from "./booking-contract.service";
|
||||
import { BookingPricingService } from "./booking-pricing.service";
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import { assertBookingStatus } from "./booking-status.util";
|
||||
import { clearanceCodesForBooking } from "./clearance.util";
|
||||
import {
|
||||
computeNextStep,
|
||||
type BookingNextStep,
|
||||
} from "./booking-next-step.util";
|
||||
import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto";
|
||||
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { BookingInvoiceService } from "./booking-invoice.service";
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { isRoadService } from './road.util';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { clearanceCodesForBooking } from './clearance.util';
|
||||
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingClearanceService } from '../contracts/booking-clearance.service';
|
||||
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
|
||||
import { ContractDocPhase } from '@edr/types';
|
||||
|
||||
|
||||
import { Freight } from "@edr/types";
|
||||
import { BookingInvoiceService } from "./booking-invoice.service";
|
||||
|
||||
@Injectable()
|
||||
export class BookingTransitionService {
|
||||
@@ -44,8 +46,17 @@ export class BookingTransitionService {
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
@Inject(forwardRef(() => BookingsService))
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
) { }
|
||||
@Inject(forwardRef(() => BookingClearanceService))
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
@Inject(forwardRef(() => ClearanceWorkflowService))
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
|
||||
) {}
|
||||
|
||||
private isPhasedGeneralCustoms(booking: Booking): boolean {
|
||||
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
|
||||
}
|
||||
|
||||
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
@@ -511,8 +522,19 @@ export class BookingTransitionService {
|
||||
note: string | null;
|
||||
}>;
|
||||
allApproved: boolean;
|
||||
phase?: string | null;
|
||||
milestones?: unknown[];
|
||||
nextAction?: unknown;
|
||||
dutyRequired?: boolean | null;
|
||||
roHold?: boolean;
|
||||
roHoldReason?: string | null;
|
||||
vesselDepartureDate?: string | null;
|
||||
operationReady?: boolean;
|
||||
}> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
return this.bookingClearanceService.getClearanceView(bookingId);
|
||||
}
|
||||
const { inputCode, outputCode, includesCustoms } =
|
||||
clearanceCodesForBooking(booking);
|
||||
|
||||
@@ -668,6 +690,18 @@ export class BookingTransitionService {
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: "DOCUMENTS_UNDER_REVIEW",
|
||||
} as never);
|
||||
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
await this.workflowService.onCustomerDocsUploadedForBooking(
|
||||
bookingId,
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||
} as never);
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
@@ -735,6 +769,15 @@ export class BookingTransitionService {
|
||||
"A note is required when querying a document",
|
||||
);
|
||||
}
|
||||
if (
|
||||
status === 'QUERIED' &&
|
||||
this.isPhasedGeneralCustoms(booking) &&
|
||||
booking.preClearanceFinalizedAt
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Customer documents cannot be queried after pre-clearance is finalized.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.setDocumentReviewStatus(
|
||||
bookingId,
|
||||
@@ -751,8 +794,30 @@ export class BookingTransitionService {
|
||||
"CHANGES_REQUESTED",
|
||||
staffId,
|
||||
);
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
return this.bookingsService.findById(bookingId);
|
||||
|
||||
const updated = await this.bookingsService.findById(bookingId);
|
||||
if (this.isPhasedGeneralCustoms(updated)) {
|
||||
const allApproved = await this.isClearanceFullyApproved(updated);
|
||||
if (allApproved) {
|
||||
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
|
||||
const phase =
|
||||
updated.tradeDirection === 'EXPORT'
|
||||
? ContractDocPhase.GlDjCollection
|
||||
: ContractDocPhase.GlEtOutput;
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: phase,
|
||||
} as never);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */
|
||||
@@ -788,7 +853,12 @@ export class BookingTransitionService {
|
||||
*/
|
||||
async finalizeClearance(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
throw new BadRequestException(
|
||||
'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.',
|
||||
);
|
||||
}
|
||||
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
||||
|
||||
const approved = await this.isClearanceFullyApproved(booking);
|
||||
if (!approved) {
|
||||
|
||||
@@ -12,14 +12,15 @@ import {
|
||||
Request,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
@@ -30,17 +31,22 @@ import {
|
||||
} from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
|
||||
import { BookingContractService } from "./booking-contract.service";
|
||||
import { BookingPricingService } from "./booking-pricing.service";
|
||||
import { BookingTransitionService } from "./booking-transition.service";
|
||||
import { BookingReferenceDataService } from "./booking-reference-data.service";
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto";
|
||||
import { CreateBookingDto } from "./dto/create-booking.dto";
|
||||
import { BookingListSummaryDto } from "./dto/booking-list-summary.dto";
|
||||
import { FilterBookingDto } from "./dto/filter-booking.dto";
|
||||
import { GeneratePriceResponseDto } from "./dto/generate-price-response.dto";
|
||||
import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto";
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { BookingClearanceService } from '../contracts/booking-clearance.service';
|
||||
import {
|
||||
AdviseContractDutyDto,
|
||||
RoAmendmentDto,
|
||||
} from '../contracts/dto/phased-clearance.dto';
|
||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
||||
import { CreateBookingDto } from './dto/create-booking.dto';
|
||||
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
|
||||
import { FilterBookingDto } from './dto/filter-booking.dto';
|
||||
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import {
|
||||
AcceptIntakeDto,
|
||||
ApproveStepDto,
|
||||
@@ -75,7 +81,8 @@ export class BookingsController {
|
||||
private readonly pricingService: BookingPricingService,
|
||||
private readonly transitionService: BookingTransitionService,
|
||||
private readonly contractService: BookingContractService,
|
||||
) { }
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@@ -358,7 +365,21 @@ export class BookingsController {
|
||||
|
||||
// ── Document clearance (post counter-sign) ────────────────────────────────
|
||||
|
||||
@Get(":id/clearance")
|
||||
@Get('clearance/et-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' })
|
||||
getBookingEtClearanceQueue() {
|
||||
return this.bookingClearanceService.etQueue();
|
||||
}
|
||||
|
||||
@Get('clearance/dj-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({ summary: 'GL DJ queue — general customs bookings awaiting DJ action' })
|
||||
getBookingDjClearanceQueue() {
|
||||
return this.bookingClearanceService.djQueue();
|
||||
}
|
||||
|
||||
@Get(':id/clearance')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Document-clearance grid (required docs + upload + GL review status)",
|
||||
@@ -469,7 +490,161 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(":id/staff/request-changes")
|
||||
@Post(':id/clearance/declaration')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET uploads customs declaration on booking (GENERAL customs)' })
|
||||
async uploadBookingDeclaration(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDeclaration(
|
||||
id,
|
||||
files ?? [],
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise)
|
||||
@UseInterceptors(FileInterceptor('attachment'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET sets duty/tax on booking with notice attachment' })
|
||||
async adviseBookingDuty(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('dutyRequired') dutyRequiredRaw: string,
|
||||
@Body('amount') amountRaw: string | undefined,
|
||||
@Body('currency') currency: string | undefined,
|
||||
@Body('declarationSerial') declarationSerial: string | undefined,
|
||||
@UploadedFile() attachment: Express.Multer.File | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1';
|
||||
const dto: AdviseContractDutyDto = {
|
||||
dutyRequired,
|
||||
amount:
|
||||
amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined,
|
||||
currency: currency ?? 'ETB',
|
||||
declarationSerial,
|
||||
};
|
||||
const booking = await this.bookingClearanceService.adviseDuty(
|
||||
id,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
attachment,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/finalize-pre-clearance')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
|
||||
async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.bookingClearanceService.finalizePreClearance(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty-slip')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' })
|
||||
async uploadBookingDutySlip(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDutySlip(id, file);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/transit-permit')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
async uploadBookingTransitPermit(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadTransitPermit(
|
||||
id,
|
||||
files ?? [],
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/delivery-order')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
async uploadBookingDeliveryOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||||
id,
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/release-order')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
async uploadBookingReleaseOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('vesselDepartureDate') vesselDepartureDate: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const result = await this.bookingClearanceService.uploadReleaseOrder(
|
||||
id,
|
||||
file,
|
||||
vesselDepartureDate,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return {
|
||||
...this.transitionService.enrichBookingResponse(result.booking),
|
||||
hold: result.hold,
|
||||
holdReason: result.holdReason,
|
||||
};
|
||||
}
|
||||
|
||||
@Post(':id/clearance/ro-amendment')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
async requestBookingRoAmendment(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RoAmendmentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.requestRoAmendment(
|
||||
id,
|
||||
dto.note,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/export-release')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
async confirmBookingExportRelease(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.confirmExportRelease(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
|
||||
@ApiOperation({ summary: "Staff return booking for customer updates" })
|
||||
async requestChanges(
|
||||
|
||||
@@ -4,38 +4,43 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
|
||||
|
||||
// import { CustomersModule } from '../customers/customers.module';
|
||||
import { CompaniesModule } from "../companies/companies.module";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import { RuleEngineModule } from "../rule-engine/rule-engine.module";
|
||||
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
|
||||
import { SignaturesModule } from "../signatures/signatures.module";
|
||||
import { BillingModule } from "../billing/billing.module";
|
||||
import { FirstMileModule } from "../first-mile/first-mile.module";
|
||||
import { BookingContractService } from "./booking-contract.service";
|
||||
import { BookingInvoiceService } from "./booking-invoice.service";
|
||||
import { BookingPricingService } from "./booking-pricing.service";
|
||||
import { BookingReferenceDataService } from "./booking-reference-data.service";
|
||||
import { BookingTransitionService } from "./booking-transition.service";
|
||||
import { BookingsController } from "./bookings.controller";
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import { ConsolidationService } from "./consolidation.service";
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { BookingApprovalStep } from "./entities/booking-approval-step.entity";
|
||||
import { BookingCargoModifier } from "./entities/booking-cargo-modifier.entity";
|
||||
import { BookingDocumentReview } from "./entities/booking-document-review.entity";
|
||||
import { BookingContainer } from "./entities/booking-container.entity";
|
||||
import { BookingRateSnapshot } from "./entities/booking-rate-snapshot.entity";
|
||||
import { BookingContractSignature } from "./entities/booking-contract-signature.entity";
|
||||
import { BookingReviewNote } from "./entities/booking-review-note.entity";
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
import { CompaniesModule } from '../companies/companies.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { MinioModule } from '../minio/minio.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { FirstMileModule } from '../first-mile/first-mile.module';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingInvoiceService } from './booking-invoice.service';
|
||||
// import { BookingPaymentController } from './booking-payment.controller';
|
||||
// import { BookingPaymentService } from './booking-payment.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
// import { PayController } from './pay.controller';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
|
||||
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { ContractsModule } from '../contracts/contracts.module';
|
||||
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
|
||||
import { ContractPdfService } from "../../contracts/contract-pdf.service";
|
||||
import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder";
|
||||
import { ContractRendererService } from "../../contracts/contract-renderer.service";
|
||||
import { ContractTemplateResolver } from "../../contracts/contract-template.resolver";
|
||||
import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder";
|
||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -53,6 +58,8 @@ import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.modu
|
||||
BillingModule,
|
||||
forwardRef(() => FirstMileModule),
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
forwardRef(() => ContractsModule),
|
||||
forwardRef(() => ContractsModule),
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
CompaniesModule,
|
||||
|
||||
@@ -121,6 +121,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
vgmPerUnitTons: number;
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
weightResult: ContainerWeightResult;
|
||||
}>,
|
||||
): Promise<BookingContainer[]> {
|
||||
@@ -133,11 +135,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
|
||||
const totalVgm = item.quantity * item.vgmPerUnitTons;
|
||||
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
|
||||
// A per-line breakdown can never exceed the line's own quantity.
|
||||
const clamp = (v?: number) =>
|
||||
Math.max(0, Math.min(item.quantity, Math.floor(Number(v ?? 0)) || 0));
|
||||
|
||||
const row = containerRepo.create({
|
||||
bookingId,
|
||||
containerTypeId: item.containerTypeId,
|
||||
quantity: item.quantity,
|
||||
hazardousQuantity: clamp(item.hazardousQuantity),
|
||||
reeferQuantity: clamp(item.reeferQuantity),
|
||||
vgmPerUnitTons: item.vgmPerUnitTons,
|
||||
totalVgmTons: totalVgm,
|
||||
wagonsRequired,
|
||||
@@ -483,6 +490,15 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** Bookings in any of the given statuses (clearance queue helpers). */
|
||||
async findByStatuses(statuses: string[]): Promise<Booking[]> {
|
||||
if (!statuses.length) return [];
|
||||
return this.repository.find({
|
||||
where: { status: In(statuses) },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
|
||||
async findQueue(options: {
|
||||
status: string | string[];
|
||||
|
||||
@@ -67,6 +67,17 @@ const NEEDS_ACTION_STATUSES = [
|
||||
'APPROVED_PENDING_SIGNATURE',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Clamp a bulk hazardous/reefer amount into 0..cargoAmount: it can never exceed
|
||||
* the total cargo it's a portion of, and is never negative.
|
||||
*/
|
||||
function clampToCargo(value: number | undefined, cargoAmount: number): number {
|
||||
const v = Number(value ?? 0);
|
||||
if (!Number.isFinite(v) || v <= 0) return 0;
|
||||
const cap = Number.isFinite(cargoAmount) && cargoAmount > 0 ? cargoAmount : 0;
|
||||
return Math.min(v, cap);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingsService {
|
||||
constructor(
|
||||
@@ -506,6 +517,16 @@ export class BookingsService {
|
||||
// the container type at pricing time, so the booking-level flag stays off
|
||||
// for container freight to avoid double-counting.
|
||||
isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
|
||||
// Bulk-only hazardous/reefer amount, clamped to the cargo amount. Container
|
||||
// freight tracks this per line, so these are 0 for CONTAINER.
|
||||
bulkHazardousQuantity:
|
||||
dto.freightType === 'BULK'
|
||||
? clampToCargo(dto.bulkHazardousQuantity, dto.cargoTotalWeightVgm)
|
||||
: 0,
|
||||
bulkReeferQuantity:
|
||||
dto.freightType === 'BULK'
|
||||
? clampToCargo(dto.bulkReeferQuantity, dto.cargoTotalWeightVgm)
|
||||
: 0,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
pnrCode: dto.pnrCode,
|
||||
financialTerms: dto.financialTerms,
|
||||
@@ -528,6 +549,8 @@ export class BookingsService {
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
hazardousQuantity: c.hazardousQuantity,
|
||||
reeferQuantity: c.reeferQuantity,
|
||||
weightResult: ruleResult.containerWeightResults[i],
|
||||
})),
|
||||
);
|
||||
@@ -665,6 +688,8 @@ export class BookingsService {
|
||||
containers,
|
||||
);
|
||||
|
||||
const cargoAmount =
|
||||
dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0);
|
||||
const updates: Record<string, unknown> = {
|
||||
...dto,
|
||||
freightType,
|
||||
@@ -675,6 +700,22 @@ export class BookingsService {
|
||||
freightType === 'BULK'
|
||||
? (dto.isReefer ?? existing.isReefer ?? false)
|
||||
: false,
|
||||
// Bulk-only hazardous/reefer amount, clamped to the cargo amount; 0 for
|
||||
// container freight (per-line on the containers instead).
|
||||
bulkHazardousQuantity:
|
||||
freightType === 'BULK'
|
||||
? clampToCargo(
|
||||
dto.bulkHazardousQuantity ?? Number(existing.bulkHazardousQuantity ?? 0),
|
||||
cargoAmount,
|
||||
)
|
||||
: 0,
|
||||
bulkReeferQuantity:
|
||||
freightType === 'BULK'
|
||||
? clampToCargo(
|
||||
dto.bulkReeferQuantity ?? Number(existing.bulkReeferQuantity ?? 0),
|
||||
cargoAmount,
|
||||
)
|
||||
: 0,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
tradeDirection,
|
||||
};
|
||||
@@ -721,6 +762,8 @@ export class BookingsService {
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
hazardousQuantity: c.hazardousQuantity,
|
||||
reeferQuantity: c.reeferQuantity,
|
||||
weightResult: ruleResult.containerWeightResults[i],
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -72,9 +72,6 @@ export class BookingReferenceCargoTypeChildDto {
|
||||
@ApiProperty({ example: 'BULK_COFFEE' })
|
||||
code!: string;
|
||||
|
||||
@ApiProperty()
|
||||
show_free_text_box!: boolean;
|
||||
|
||||
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false })
|
||||
unit_of_measure?: CargoUnitOfMeasure | null;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,28 @@ export class CreateBookingContainerDto {
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
vgmPerUnitTons!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'How many of this line are hazardous (0..quantity)',
|
||||
minimum: 0,
|
||||
default: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
hazardousQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'How many of this line are refrigerated (0..quantity)',
|
||||
minimum: 0,
|
||||
default: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
reeferQuantity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,6 +342,25 @@ export class CreateBookingDto {
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isReefer?: boolean;
|
||||
|
||||
/**
|
||||
* Bulk-only: how much of the cargo is hazardous / refrigerated, in the cargo's
|
||||
* unit of measure (tons for PER_TON, item count for PER_ITEM). Must not exceed
|
||||
* cargoTotalWeightVgm. Ignored for container freight (per-line on containers).
|
||||
*/
|
||||
@ApiPropertyOptional({ minimum: 0, default: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
bulkHazardousQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0, default: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
bulkReeferQuantity?: number;
|
||||
|
||||
@ApiProperty({ enum: PAYMENT_CURRENCIES })
|
||||
@IsIn([...PAYMENT_CURRENCIES])
|
||||
paymentCurrency!: string;
|
||||
|
||||
@@ -321,6 +321,19 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||
isReefer!: boolean;
|
||||
|
||||
/**
|
||||
* Bulk-only hazardous / reefer amount, in the cargo's own unit of measure
|
||||
* (tons for PER_TON commodities, item count for PER_ITEM) — i.e. how much of
|
||||
* `cargoTotalWeightVgm` is hazardous / refrigerated. 0 when none. Container
|
||||
* freight carries this per line on `booking_container` instead, so these stay
|
||||
* 0 for CONTAINER bookings. The booleans above remain the surcharge trigger.
|
||||
*/
|
||||
@Column({ name: 'bulk_hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
bulkHazardousQuantity!: number;
|
||||
|
||||
@Column({ name: 'bulk_reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
bulkReeferQuantity!: number;
|
||||
|
||||
@Column({ name: 'payment_currency', type: 'varchar', length: 5 })
|
||||
paymentCurrency!: string;
|
||||
|
||||
@@ -435,6 +448,25 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'gl_station_yard_id', type: 'uuid', nullable: true })
|
||||
glStationYardId?: string | null;
|
||||
|
||||
/** Per-booking phased clearance (GENERAL + customs). */
|
||||
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
|
||||
clearanceCurrentPhase?: string | null;
|
||||
|
||||
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
|
||||
dutyRequired?: boolean | null;
|
||||
|
||||
@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: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true })
|
||||
preClearanceFinalizedAt?: Date | null;
|
||||
|
||||
/** GL staff user bound to this shipment by the station manager. */
|
||||
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
|
||||
glAssignedStaffId?: string | null;
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ContractDocPhase } from '@edr/types';
|
||||
|
||||
import { BookingClearanceService } from './booking-clearance.service';
|
||||
import type { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
const generalImportBooking = {
|
||||
id: 'b-general',
|
||||
status: 'DOCUMENTS_UNDER_REVIEW',
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: 'CONTAINER',
|
||||
customsClearingEnabled: true,
|
||||
contractKind: 'GENERAL',
|
||||
contractId: 'c-1',
|
||||
dutyRequired: true,
|
||||
roHoldReason: null,
|
||||
vesselDepartureDate: null,
|
||||
} as Booking;
|
||||
|
||||
const generalExportBooking = {
|
||||
...generalImportBooking,
|
||||
id: 'b-export',
|
||||
tradeDirection: 'EXPORT',
|
||||
dutyRequired: null,
|
||||
} as Booking;
|
||||
|
||||
function makeService(overrides?: {
|
||||
booking?: Booking;
|
||||
workflowThrows?: boolean;
|
||||
}) {
|
||||
const booking = overrides?.booking ?? generalImportBooking;
|
||||
const bookingsRepository = {
|
||||
findDocumentReviews: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(booking),
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn().mockResolvedValue(booking),
|
||||
};
|
||||
const filesService = {
|
||||
upsertByCode: jest.fn().mockResolvedValue({}),
|
||||
findByResource: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const fileUploadSettingsService = {
|
||||
getByCode: jest.fn().mockRejectedValue(new Error('no setting')),
|
||||
};
|
||||
const workflowService = {
|
||||
assertPriorCompleteForBooking: overrides?.workflowThrows
|
||||
? jest.fn().mockRejectedValue(new BadRequestException('Prior milestone incomplete'))
|
||||
: jest.fn().mockResolvedValue(undefined),
|
||||
completeMilestoneForBooking: jest.fn().mockResolvedValue(undefined),
|
||||
onDeclarationUploadedForBooking: jest.fn().mockResolvedValue(undefined),
|
||||
onDutySkippedForBooking: jest.fn().mockResolvedValue(undefined),
|
||||
listMilestonesForBooking: jest.fn().mockResolvedValue([]),
|
||||
resolvePhaseForBooking: jest.fn().mockReturnValue(null),
|
||||
computeNextActionForBooking: jest.fn().mockReturnValue(null),
|
||||
isBoundaryCompleteForBooking: jest.fn().mockResolvedValue(false),
|
||||
markReadyForOperation: jest.fn().mockResolvedValue(undefined),
|
||||
onExportReleasedForBooking: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const milestoneService = {
|
||||
adviseDuty: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const dropdownSettingsService = {
|
||||
getByCode: jest.fn().mockResolvedValue({
|
||||
children: [{ value: '2' }],
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new BookingClearanceService(
|
||||
bookingsRepository as never,
|
||||
bookingsService as never,
|
||||
filesService as never,
|
||||
fileUploadSettingsService as never,
|
||||
workflowService as never,
|
||||
milestoneService as never,
|
||||
dropdownSettingsService as never,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
bookingsRepository,
|
||||
bookingsService,
|
||||
filesService,
|
||||
workflowService,
|
||||
milestoneService,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BookingClearanceService', () => {
|
||||
describe('adviseDuty', () => {
|
||||
it('skips duty milestones when duty is not required', async () => {
|
||||
const { service, workflowService, bookingsRepository } = makeService();
|
||||
await service.adviseDuty('b-general', { dutyRequired: false });
|
||||
|
||||
expect(workflowService.onDutySkippedForBooking).toHaveBeenCalledWith('b-general');
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-general',
|
||||
expect.objectContaining({
|
||||
dutyRequired: false,
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('records duty advice when duty applies', async () => {
|
||||
const { service, milestoneService } = makeService();
|
||||
await service.adviseDuty('b-general', {
|
||||
dutyRequired: true,
|
||||
amount: 1500,
|
||||
currency: 'ETB',
|
||||
declarationSerial: 'DS-1',
|
||||
});
|
||||
|
||||
expect(milestoneService.adviseDuty).toHaveBeenCalledWith(
|
||||
'b-general',
|
||||
expect.objectContaining({ amount: 1500, currency: 'ETB', declarationSerial: 'DS-1' }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadDutySlip', () => {
|
||||
it('rejects when duty is not required', async () => {
|
||||
const { service } = makeService({
|
||||
booking: { ...generalImportBooking, dutyRequired: false } as Booking,
|
||||
});
|
||||
await expect(
|
||||
service.uploadDutySlip('b-general', { fieldname: 'file' } as Express.Multer.File),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('uploads slip and completes DUTY_TAX_PAID on happy path', async () => {
|
||||
const { service, filesService, workflowService, bookingsRepository } = makeService();
|
||||
const file = { fieldname: 'file' } as Express.Multer.File;
|
||||
|
||||
await service.uploadDutySlip('b-general', file);
|
||||
|
||||
expect(filesService.upsertByCode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceId: 'b-general',
|
||||
code: 'duty_tax_receipt',
|
||||
file,
|
||||
}),
|
||||
);
|
||||
expect(workflowService.completeMilestoneForBooking).toHaveBeenCalledWith(
|
||||
'b-general',
|
||||
'DUTY_TAX_PAID',
|
||||
);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-general',
|
||||
expect.objectContaining({
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadDeclaration', () => {
|
||||
it('rejects when a prior milestone is incomplete', async () => {
|
||||
const { service } = makeService({ workflowThrows: true });
|
||||
await expect(
|
||||
service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadReleaseOrder', () => {
|
||||
it('places RO on hold when vessel departs too soon', async () => {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateStr = tomorrow.toISOString().slice(0, 10);
|
||||
|
||||
const { service, bookingsRepository } = makeService({ booking: generalExportBooking });
|
||||
const result = await service.uploadReleaseOrder(
|
||||
'b-export',
|
||||
{ fieldname: 'ro' } as Express.Multer.File,
|
||||
dateStr,
|
||||
);
|
||||
|
||||
expect(result.hold).toBe(true);
|
||||
expect(result.holdReason).toMatch(/minimum lead time/i);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-export',
|
||||
expect.objectContaining({ roHoldReason: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,618 @@
|
||||
import { BadRequestException, 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 { FilesService } from '../files/files.service';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
|
||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||
|
||||
export interface BookingClearanceView {
|
||||
bookingId: string;
|
||||
status: string;
|
||||
includesCustoms: boolean;
|
||||
inputCode: string | null;
|
||||
outputCode: string | null;
|
||||
documents: Array<{
|
||||
fileKey: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
uploadedBy: 'customer' | 'gl';
|
||||
settingCode: string;
|
||||
file: { id: string; name: string; url: string } | null;
|
||||
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
|
||||
note: string | null;
|
||||
}>;
|
||||
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;
|
||||
operationReady?: boolean;
|
||||
preClearanceFinalized?: boolean;
|
||||
dutyAdvice?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
declarationSerial?: string | null;
|
||||
noticeFile?: { id: string; name: string; url: string } | null;
|
||||
} | null;
|
||||
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingClearanceService {
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||
) {}
|
||||
|
||||
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
|
||||
if (!booking.customsClearingEnabled) {
|
||||
throw new BadRequestException('Phased clearance applies only to customs bookings.');
|
||||
}
|
||||
if (booking.contractKind !== 'GENERAL') {
|
||||
throw new BadRequestException('Per-booking phased clearance applies to general contracts.');
|
||||
}
|
||||
if (!booking.contractId) {
|
||||
throw new BadRequestException('Booking is not linked to a contract.');
|
||||
}
|
||||
}
|
||||
|
||||
private async loadBooking(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
await this.assertPhasedGeneralCustoms(booking);
|
||||
return booking;
|
||||
}
|
||||
|
||||
async getClearanceView(bookingId: string): Promise<BookingClearanceView> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
const { inputCode, outputCode, includesCustoms } = clearanceCodesForBooking(booking);
|
||||
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const fileByCode = new Map(files.map((f) => [f.code, f]));
|
||||
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
|
||||
const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]));
|
||||
|
||||
const documents: BookingClearanceView['documents'] = [];
|
||||
|
||||
const pushSetting = async (code: string | null, uploadedBy: 'customer' | 'gl') => {
|
||||
if (!code) return;
|
||||
let setting;
|
||||
try {
|
||||
setting = await this.fileUploadSettingsService.getByCode(code);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const field of setting.fields ?? []) {
|
||||
const file = fileByCode.get(field.fileKey) ?? null;
|
||||
const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null;
|
||||
documents.push({
|
||||
fileKey: field.fileKey,
|
||||
label: field.fileLabel,
|
||||
required: field.isRequired,
|
||||
uploadedBy,
|
||||
settingCode: code,
|
||||
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||
reviewStatus: review?.status ?? null,
|
||||
note: review?.note ?? null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await pushSetting(inputCode, 'customer');
|
||||
await pushSetting(outputCode, 'gl');
|
||||
|
||||
for (const f of files) {
|
||||
if (!f.code?.startsWith('custom_')) continue;
|
||||
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
||||
documents.push({
|
||||
fileKey: f.code,
|
||||
label: f.name,
|
||||
required: false,
|
||||
uploadedBy: 'customer',
|
||||
settingCode: 'custom',
|
||||
file: { id: f.id, name: f.name, url: f.url },
|
||||
reviewStatus: review?.status ?? null,
|
||||
note: review?.note ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const allApproved = await this.isClearanceFullyApproved(booking);
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
||||
const phase = this.workflowService.resolvePhaseForBooking(booking, milestones);
|
||||
const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones);
|
||||
const boundary = await this.workflowService.isBoundaryCompleteForBooking(
|
||||
bookingId,
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
||||
const workflowFiles = buildWorkflowFiles(
|
||||
files,
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
status: booking.status,
|
||||
includesCustoms,
|
||||
inputCode,
|
||||
outputCode,
|
||||
documents,
|
||||
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: booking.dutyRequired ?? null,
|
||||
roHold: Boolean(booking.roHoldReason),
|
||||
roHoldReason: booking.roHoldReason ?? null,
|
||||
vesselDepartureDate: booking.vesselDepartureDate ?? null,
|
||||
roAmendmentRequestedAt: booking.roAmendmentRequestedAt
|
||||
? booking.roAmendmentRequestedAt.toISOString()
|
||||
: null,
|
||||
operationReady: boundary,
|
||||
preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt),
|
||||
dutyAdvice,
|
||||
workflowFiles,
|
||||
};
|
||||
}
|
||||
|
||||
private buildDutyAdvice(
|
||||
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
||||
milestones: ClearanceMilestone[],
|
||||
): BookingClearanceView['dutyAdvice'] {
|
||||
const advised = milestones.find(
|
||||
(m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED',
|
||||
);
|
||||
if (!advised?.metadata) return null;
|
||||
const amount = advised.metadata.dutyAmount;
|
||||
const currency = advised.metadata.dutyCurrency;
|
||||
if (typeof amount !== 'number' || typeof currency !== 'string') return null;
|
||||
const notice = files.find((f) => f.code === 'duty_tax_notice');
|
||||
return {
|
||||
amount,
|
||||
currency,
|
||||
declarationSerial:
|
||||
typeof advised.metadata.declarationSerial === 'string'
|
||||
? advised.metadata.declarationSerial
|
||||
: null,
|
||||
noticeFile: notice
|
||||
? { id: notice.id, name: notice.name, url: notice.url }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async isClearanceFullyApproved(booking: Booking): Promise<boolean> {
|
||||
const { inputCode } = clearanceCodesForBooking(booking);
|
||||
if (!inputCode) return true;
|
||||
let setting;
|
||||
try {
|
||||
setting = await this.fileUploadSettingsService.getByCode(inputCode);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
||||
if (required.length === 0) return true;
|
||||
const reviews = await this.bookingsRepository.findDocumentReviews(booking.id);
|
||||
return required.every((field) =>
|
||||
reviews.some(
|
||||
(r) =>
|
||||
r.settingCode === inputCode &&
|
||||
r.fileKey === field.fileKey &&
|
||||
r.status === 'APPROVED',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
isPhasedGeneralCustomsBooking(booking: Booking): boolean {
|
||||
return (
|
||||
Boolean(booking.customsClearingEnabled) &&
|
||||
booking.contractKind === 'GENERAL' &&
|
||||
Boolean(booking.contractId)
|
||||
);
|
||||
}
|
||||
|
||||
async uploadDeclaration(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
|
||||
const allApproved = await this.isClearanceFullyApproved(booking);
|
||||
if (!allApproved) {
|
||||
throw new BadRequestException(
|
||||
'All required customer documents must be approved before uploading a declaration.',
|
||||
);
|
||||
}
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
||||
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
|
||||
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
|
||||
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
|
||||
}
|
||||
await this.workflowService.assertPriorCompleteForBooking(
|
||||
bookingId,
|
||||
tradeDirection,
|
||||
'UNDER_CUSTOMS_CLEARANCE',
|
||||
);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No declaration documents uploaded');
|
||||
}
|
||||
|
||||
await persistDeclarationUploads(this.filesService, bookingId, 'bookings', files);
|
||||
|
||||
await this.workflowService.onDeclarationUploadedForBooking(bookingId, userId);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase:
|
||||
tradeDirection === 'EXPORT'
|
||||
? ContractDocPhase.GlEtPostClearance
|
||||
: ContractDocPhase.CustomerDuty,
|
||||
} as never);
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async adviseDuty(
|
||||
bookingId: string,
|
||||
dto: AdviseContractDutyDto,
|
||||
userId?: string,
|
||||
attachment?: Express.Multer.File,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Duty advice applies only to import bookings.');
|
||||
}
|
||||
await this.workflowService.assertPriorCompleteForBooking(
|
||||
bookingId,
|
||||
'IMPORT',
|
||||
'DUTY_TAXES_ADVISED',
|
||||
);
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
dutyRequired: dto.dutyRequired,
|
||||
clearanceCurrentPhase: dto.dutyRequired
|
||||
? ContractDocPhase.CustomerDuty
|
||||
: ContractDocPhase.GlEtPostClearance,
|
||||
} as never);
|
||||
|
||||
if (!dto.dutyRequired) {
|
||||
await this.workflowService.onDutySkippedForBooking(bookingId);
|
||||
} else {
|
||||
if (dto.amount == null || dto.amount < 0) {
|
||||
throw new BadRequestException('Duty amount is required when duty applies.');
|
||||
}
|
||||
if (!attachment) {
|
||||
throw new BadRequestException('Duty notice attachment is required when duty applies.');
|
||||
}
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'duty_tax_notice',
|
||||
file: attachment,
|
||||
});
|
||||
await this.milestoneService.adviseDuty(
|
||||
bookingId,
|
||||
{
|
||||
amount: dto.amount,
|
||||
currency: dto.currency ?? 'ETB',
|
||||
declarationSerial: dto.declarationSerial,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Duty slip upload applies only to import bookings.');
|
||||
}
|
||||
if (!booking.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: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'duty_tax_receipt',
|
||||
file,
|
||||
});
|
||||
|
||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DUTY_TAX_PAID');
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
} as never);
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async uploadTransitPermit(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Transit permit applies only to import bookings.');
|
||||
}
|
||||
await this.workflowService.assertPriorCompleteForBooking(
|
||||
bookingId,
|
||||
'IMPORT',
|
||||
'TRANSIT_PERMIT_UPLOADED',
|
||||
);
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No transit permit documents uploaded');
|
||||
}
|
||||
|
||||
await persistTransitPermitUploads(this.filesService, bookingId, 'bookings', files);
|
||||
|
||||
await this.workflowService.completeMilestoneForBooking(
|
||||
bookingId,
|
||||
'TRANSIT_PERMIT_UPLOADED',
|
||||
userId,
|
||||
);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
} as never);
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async finalizePreClearance(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Pre-clearance finalize applies only to import bookings.');
|
||||
}
|
||||
|
||||
await this.workflowService.assertPriorCompleteForBooking(
|
||||
bookingId,
|
||||
'IMPORT',
|
||||
'TRANSIT_PERMIT_UPLOADED',
|
||||
);
|
||||
|
||||
if (booking.preClearanceFinalizedAt) {
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
preClearanceFinalizedAt: new Date(),
|
||||
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
|
||||
} as never);
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async uploadDeliveryOrder(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
userId?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Delivery Order applies only to import bookings.');
|
||||
}
|
||||
|
||||
if (!booking.preClearanceFinalizedAt) {
|
||||
throw new BadRequestException(
|
||||
'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.workflowService.assertPriorCompleteForBooking(bookingId, 'IMPORT', 'DO_COLLECTED');
|
||||
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'delivery_order',
|
||||
file,
|
||||
});
|
||||
|
||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
|
||||
await this.workflowService.markReadyForOperation(bookingId);
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
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(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
vesselDepartureDate: string,
|
||||
userId?: string,
|
||||
): Promise<{ booking: Booking; hold: boolean; holdReason?: string }> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException('Release Order applies only to export bookings.');
|
||||
}
|
||||
await this.workflowService.assertPriorCompleteForBooking(
|
||||
bookingId,
|
||||
'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);
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'release_order',
|
||||
file,
|
||||
});
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
vesselDepartureDate,
|
||||
roAmendmentRequestedAt: null,
|
||||
} as never);
|
||||
|
||||
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.bookingsRepository.update(bookingId, {
|
||||
roHoldReason: reason,
|
||||
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
|
||||
} as never);
|
||||
return {
|
||||
booking: await this.bookingsService.findById(bookingId),
|
||||
hold: true,
|
||||
holdReason: reason,
|
||||
};
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
roHoldReason: null,
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
||||
} as never);
|
||||
await this.workflowService.completeMilestoneForBooking(
|
||||
bookingId,
|
||||
'RELEASE_ORDER_SECURED',
|
||||
userId,
|
||||
);
|
||||
|
||||
return { booking: await this.bookingsService.findById(bookingId), hold: false };
|
||||
}
|
||||
|
||||
async requestRoAmendment(
|
||||
bookingId: string,
|
||||
note?: string,
|
||||
userId?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException('RO amendment applies only to export bookings.');
|
||||
}
|
||||
|
||||
const reason =
|
||||
note?.trim() ||
|
||||
'Port amendment requested — vessel departure window is too short. A new Release Order will be required.';
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
roAmendmentRequestedAt: new Date(),
|
||||
roHoldReason: reason,
|
||||
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
|
||||
} as never);
|
||||
|
||||
if (userId) {
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
reason,
|
||||
'CHANGES_REQUESTED',
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async confirmExportRelease(bookingId: string, userId?: string): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException('Export release applies only to export bookings.');
|
||||
}
|
||||
await this.workflowService.assertPriorCompleteForBooking(
|
||||
bookingId,
|
||||
'EXPORT',
|
||||
'EXPORT_RELEASED',
|
||||
);
|
||||
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
async etQueue(): Promise<Booking[]> {
|
||||
const candidates = await this.bookingsRepository.findByStatuses([
|
||||
...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES,
|
||||
]);
|
||||
const filtered: Booking[] = [];
|
||||
for (const b of candidates) {
|
||||
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async djQueue(): Promise<Booking[]> {
|
||||
const candidates = await this.bookingsRepository.findByStatuses([
|
||||
...DJ_BOOKING_QUEUE_STATUSES,
|
||||
]);
|
||||
const filtered: Booking[] = [];
|
||||
for (const b of candidates) {
|
||||
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||
if (
|
||||
belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, {
|
||||
roHoldReason: b.roHoldReason,
|
||||
preClearanceFinalizedAt: b.preClearanceFinalizedAt,
|
||||
})
|
||||
) {
|
||||
filtered.push(b);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,17 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
|
||||
async findById(id: string): Promise<BookingRequest | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id },
|
||||
relations: { contract: true },
|
||||
// Load the contract with the bits the detail page surfaces: customer
|
||||
// (company), service type (mile/customs flags), routes (with yard labels)
|
||||
// and cargo scope.
|
||||
relations: {
|
||||
contract: {
|
||||
company: true,
|
||||
serviceType: true,
|
||||
routes: { originYard: true, destinationYard: true },
|
||||
cargoScope: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
||||
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
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 },
|
||||
WAGON_REQUESTED: { label: 'Wagon Allocation Requested', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
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_SETTLED: { label: 'Payment Settled', ownerRegion: 'CUST', triggeredByDoc: true },
|
||||
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 },
|
||||
READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
LOADED: { label: 'Loaded', ownerRegion: 'OPS', triggeredByDoc: false },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
@@ -39,6 +39,15 @@ export class ClearanceMilestoneService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Seed pre-booking milestones on a booking (GENERAL + customs per-shipment clearance). */
|
||||
async seedPreBookingMilestonesOnBooking(
|
||||
bookingId: string,
|
||||
tradeDirection: string,
|
||||
): Promise<void> {
|
||||
const { preBooking } = splitMilestones(tradeDirection);
|
||||
await this.seed(preBooking, { bookingId });
|
||||
}
|
||||
|
||||
/** Seed the post-booking milestones onto a freshly created booking. */
|
||||
async seedPostBookingMilestones(
|
||||
bookingId: string,
|
||||
@@ -94,7 +103,7 @@ export class ClearanceMilestoneService {
|
||||
throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
|
||||
}
|
||||
if (milestone.status === 'COMPLETED') {
|
||||
throw new BadRequestException(`Milestone ${code} is already completed.`);
|
||||
return milestone;
|
||||
}
|
||||
milestone.status = 'COMPLETED';
|
||||
milestone.triggeredAt = new Date();
|
||||
@@ -178,7 +187,7 @@ export class ClearanceMilestoneService {
|
||||
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
|
||||
}
|
||||
if (milestone.status === 'COMPLETED') {
|
||||
throw new BadRequestException(`Milestone ${code} is already completed.`);
|
||||
return milestone;
|
||||
}
|
||||
milestone.status = 'COMPLETED';
|
||||
milestone.triggeredAt = new Date();
|
||||
@@ -187,6 +196,99 @@ export class ClearanceMilestoneService {
|
||||
return this.repo.save(milestone);
|
||||
}
|
||||
|
||||
/** Skip optional milestones (e.g. duty when not required). */
|
||||
/** Reopen a completed contract milestone so review can continue after a query. */
|
||||
async reopenForContract(contractId: string, code: string): Promise<void> {
|
||||
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
|
||||
if (!milestone || milestone.status !== 'COMPLETED') return;
|
||||
milestone.status = 'PENDING';
|
||||
milestone.triggeredAt = null;
|
||||
milestone.triggeredByUserId = null;
|
||||
await this.repo.save(milestone);
|
||||
}
|
||||
|
||||
/** Reopen a completed booking milestone so review can continue after a query. */
|
||||
async reopenForBooking(bookingId: string, code: string): Promise<void> {
|
||||
const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
|
||||
if (!milestone || milestone.status !== 'COMPLETED') return;
|
||||
milestone.status = 'PENDING';
|
||||
milestone.triggeredAt = null;
|
||||
milestone.triggeredByUserId = null;
|
||||
await this.repo.save(milestone);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async skipForBooking(bookingId: string, code: string): Promise<ClearanceMilestone> {
|
||||
const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
|
||||
if (!milestone) {
|
||||
throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
|
||||
}
|
||||
if (milestone.status === 'COMPLETED') return milestone;
|
||||
milestone.status = 'SKIPPED';
|
||||
milestone.triggeredAt = new Date();
|
||||
return this.repo.save(milestone);
|
||||
}
|
||||
|
||||
async completeWithMetadataForBooking(
|
||||
bookingId: string,
|
||||
code: string,
|
||||
metadata: MilestoneMetadata,
|
||||
userId?: string,
|
||||
note?: string,
|
||||
): Promise<ClearanceMilestone> {
|
||||
return this.completeWithMetadata(bookingId, code, metadata, userId, note);
|
||||
}
|
||||
|
||||
/** 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') {
|
||||
return milestone;
|
||||
}
|
||||
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. */
|
||||
async completeByDocTrigger(
|
||||
scope: { bookingId?: string; contractId?: string },
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
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';
|
||||
import type { Booking } from '../bookings/entities/booking.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),
|
||||
listForBooking: jest.fn().mockResolvedValue(milestones),
|
||||
skipForContract: jest.fn(),
|
||||
completeForContract: jest.fn(),
|
||||
completeWithMetadataForContract: jest.fn(),
|
||||
};
|
||||
const bookingsRepository = { update: jest.fn() };
|
||||
const service = new ClearanceWorkflowService(
|
||||
contractsRepository as never,
|
||||
milestoneService as never,
|
||||
bookingsRepository as never,
|
||||
);
|
||||
return { service, milestoneService, contractsRepository, bookingsRepository };
|
||||
}
|
||||
|
||||
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 ET to finalize pre-clearance after transit permit', () => {
|
||||
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 next = service.computeNextAction(importContract, cycle, milestones);
|
||||
expect(next?.actor).toBe('GL_ET');
|
||||
expect(next?.action).toMatch(/finalize pre-clearance/i);
|
||||
});
|
||||
|
||||
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,
|
||||
preClearanceFinalizedAt: new Date(),
|
||||
} 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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNextActionForBooking', () => {
|
||||
it('prompts customer to proceed after import boundary on booking', () => {
|
||||
const booking = {
|
||||
tradeDirection: 'IMPORT',
|
||||
dutyRequired: false,
|
||||
preClearanceFinalizedAt: new Date(),
|
||||
} as Booking;
|
||||
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', 'COMPLETED', 'DJ'),
|
||||
];
|
||||
const { service } = makeService(milestones);
|
||||
const next = service.computeNextActionForBooking(booking, milestones);
|
||||
expect(next?.actor).toBe('CUSTOMER');
|
||||
expect(next?.action).toMatch(/operation/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,566 @@
|
||||
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';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import type { ClearanceMetaState } from './clearance-workflow.types';
|
||||
import { metaFromBooking } from './clearance-workflow.types';
|
||||
|
||||
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,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
) {}
|
||||
|
||||
boundaryMilestone(tradeDirection: string): string {
|
||||
return tradeDirection === 'IMPORT' ? IMPORT_BOUNDARY : EXPORT_BOUNDARY;
|
||||
}
|
||||
|
||||
// ── Contract scope (ONE_TIME) ─────────────────────────────────────────────
|
||||
|
||||
async listMilestones(contractId: string): Promise<ClearanceMilestone[]> {
|
||||
return this.milestoneService.listForContract(contractId);
|
||||
}
|
||||
|
||||
async listMilestonesForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
|
||||
return this.milestoneService.listForBooking(bookingId);
|
||||
}
|
||||
|
||||
async isBoundaryComplete(contractId: string, tradeDirection: string): Promise<boolean> {
|
||||
return this.isBoundaryCompleteForMilestones(
|
||||
await this.listMilestones(contractId),
|
||||
tradeDirection,
|
||||
);
|
||||
}
|
||||
|
||||
async isBoundaryCompleteForBooking(
|
||||
bookingId: string,
|
||||
tradeDirection: string,
|
||||
): Promise<boolean> {
|
||||
return this.isBoundaryCompleteForMilestones(
|
||||
await this.listMilestonesForBooking(bookingId),
|
||||
tradeDirection,
|
||||
);
|
||||
}
|
||||
|
||||
private isBoundaryCompleteForMilestones(
|
||||
milestones: ClearanceMilestone[],
|
||||
tradeDirection: string,
|
||||
): boolean {
|
||||
const code = this.boundaryMilestone(tradeDirection);
|
||||
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> {
|
||||
await this.assertPriorCompleteOnMilestones(
|
||||
await this.listMilestones(contractId),
|
||||
tradeDirection,
|
||||
targetCode,
|
||||
);
|
||||
}
|
||||
|
||||
async assertPriorCompleteForBooking(
|
||||
bookingId: string,
|
||||
tradeDirection: string,
|
||||
targetCode: string,
|
||||
): Promise<void> {
|
||||
await this.assertPriorCompleteOnMilestones(
|
||||
await this.listMilestonesForBooking(bookingId),
|
||||
tradeDirection,
|
||||
targetCode,
|
||||
);
|
||||
}
|
||||
|
||||
private async assertPriorCompleteOnMilestones(
|
||||
milestones: ClearanceMilestone[],
|
||||
tradeDirection: string,
|
||||
targetCode: string,
|
||||
): Promise<void> {
|
||||
const { preBooking } = splitMilestones(tradeDirection);
|
||||
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 skipMilestonesForBooking(bookingId: string, codes: string[]): Promise<void> {
|
||||
for (const code of codes) {
|
||||
await this.milestoneService.skipForBooking(bookingId, 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 completeMilestoneForBooking(
|
||||
bookingId: string,
|
||||
code: string,
|
||||
userId?: string,
|
||||
metadata?: MilestoneMetadata,
|
||||
): Promise<ClearanceMilestone> {
|
||||
if (metadata && Object.keys(metadata).length > 0) {
|
||||
return this.milestoneService.completeWithMetadataForBooking(
|
||||
bookingId,
|
||||
code,
|
||||
metadata,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
return this.milestoneService.completeForBooking(bookingId, 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 onCustomerDocsUploadedForBooking(
|
||||
bookingId: string,
|
||||
tradeDirection: string,
|
||||
): Promise<void> {
|
||||
const uploaded =
|
||||
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
|
||||
await this.completeMilestoneForBooking(bookingId, uploaded);
|
||||
await this.completeMilestoneForBooking(bookingId, 'PENDING_DOCUMENT_REVIEW');
|
||||
}
|
||||
|
||||
async onAllDocsApproved(contractId: string): Promise<void> {
|
||||
await this.completeMilestone(contractId, 'DOCUMENTS_APPROVED');
|
||||
}
|
||||
|
||||
async onAllDocsApprovedForBooking(bookingId: string): Promise<void> {
|
||||
await this.completeMilestoneForBooking(bookingId, 'DOCUMENTS_APPROVED');
|
||||
}
|
||||
|
||||
/** Customer doc queried or re-uploaded — document approval milestone must reopen. */
|
||||
async onDocumentReviewReopened(contractId: string): Promise<void> {
|
||||
await this.milestoneService.reopenForContract(contractId, 'DOCUMENTS_APPROVED');
|
||||
}
|
||||
|
||||
async onDocumentReviewReopenedForBooking(bookingId: string): Promise<void> {
|
||||
await this.milestoneService.reopenForBooking(bookingId, 'DOCUMENTS_APPROVED');
|
||||
}
|
||||
|
||||
async onDeclarationUploaded(contractId: string, userId?: string): Promise<void> {
|
||||
await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE');
|
||||
await this.completeMilestone(contractId, 'DECLARED', userId);
|
||||
}
|
||||
|
||||
async onDeclarationUploadedForBooking(bookingId: string, userId?: string): Promise<void> {
|
||||
await this.completeMilestoneForBooking(bookingId, 'UNDER_CUSTOMS_CLEARANCE');
|
||||
await this.completeMilestoneForBooking(bookingId, 'DECLARED', userId);
|
||||
}
|
||||
|
||||
async onDutySkipped(contractId: string): Promise<void> {
|
||||
await this.skipMilestones(contractId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']);
|
||||
}
|
||||
|
||||
async onDutySkippedForBooking(bookingId: string): Promise<void> {
|
||||
await this.skipMilestonesForBooking(bookingId, ['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 onExportReleasedForBooking(bookingId: string, userId?: string): Promise<void> {
|
||||
await this.completeMilestoneForBooking(bookingId, 'EXPORT_RELEASED', userId);
|
||||
await this.markReadyForOperation(bookingId);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** GENERAL per-booking: boundary complete → customer may proceed to operations. */
|
||||
async markReadyForOperation(bookingId: string): Promise<void> {
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'CLEARANCE_READY',
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
} as never);
|
||||
}
|
||||
|
||||
resolvePhase(
|
||||
contract: Contract,
|
||||
cycle: ContractClearanceCycle | null,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ContractDocPhase {
|
||||
const meta: ClearanceMetaState = {
|
||||
dutyRequired: cycle?.dutyRequired ?? null,
|
||||
vesselDepartureDate: cycle?.vesselDepartureDate ?? null,
|
||||
roHoldReason: cycle?.roHoldReason ?? null,
|
||||
currentPhase: cycle?.currentPhase ?? null,
|
||||
};
|
||||
return this.resolvePhaseFromMeta(contract.tradeDirection, meta, milestones);
|
||||
}
|
||||
|
||||
resolvePhaseForBooking(
|
||||
booking: Booking,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ContractDocPhase {
|
||||
return this.resolvePhaseFromMeta(
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
metaFromBooking(booking),
|
||||
milestones,
|
||||
);
|
||||
}
|
||||
|
||||
private resolvePhaseFromMeta(
|
||||
tradeDirection: string,
|
||||
meta: ClearanceMetaState,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ContractDocPhase {
|
||||
if (meta.currentPhase) {
|
||||
return meta.currentPhase as ContractDocPhase;
|
||||
}
|
||||
return this.inferPhaseFromMeta(tradeDirection, meta, milestones);
|
||||
}
|
||||
|
||||
inferPhase(
|
||||
contract: Contract,
|
||||
cycle: ContractClearanceCycle | null,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ContractDocPhase {
|
||||
return this.inferPhaseFromMeta(
|
||||
contract.tradeDirection,
|
||||
{
|
||||
dutyRequired: cycle?.dutyRequired ?? null,
|
||||
roHoldReason: cycle?.roHoldReason ?? null,
|
||||
},
|
||||
milestones,
|
||||
);
|
||||
}
|
||||
|
||||
inferPhaseForBooking(booking: Booking, milestones: ClearanceMilestone[]): ContractDocPhase {
|
||||
return this.inferPhaseFromMeta(
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
metaFromBooking(booking),
|
||||
milestones,
|
||||
);
|
||||
}
|
||||
|
||||
private inferPhaseFromMeta(
|
||||
tradeDirection: string,
|
||||
meta: ClearanceMetaState,
|
||||
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 =
|
||||
tradeDirection === 'IMPORT'
|
||||
? isDone(IMPORT_DOC_UPLOADED)
|
||||
: isDone(EXPORT_DOC_UPLOADED);
|
||||
|
||||
if (!docUploaded) return ContractDocPhase.CustomerIntake;
|
||||
if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview;
|
||||
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
if (!isDone('RELEASE_ORDER_SECURED')) {
|
||||
return ContractDocPhase.GlDjCollection;
|
||||
}
|
||||
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
||||
if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance;
|
||||
return ContractDocPhase.GlEtPostClearance;
|
||||
}
|
||||
|
||||
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
||||
if (meta.dutyRequired === true && !isDone('DUTY_TAX_PAID')) {
|
||||
return ContractDocPhase.CustomerDuty;
|
||||
}
|
||||
if (!isDone('TRANSIT_PERMIT_UPLOADED')) return ContractDocPhase.GlEtPostClearance;
|
||||
if (!meta.preClearanceFinalizedAt) return ContractDocPhase.GlEtPostClearance;
|
||||
if (!isDone(IMPORT_BOUNDARY)) return ContractDocPhase.GlDjCollection;
|
||||
return ContractDocPhase.GlEtPostClearance;
|
||||
}
|
||||
|
||||
computeNextAction(
|
||||
contract: Contract,
|
||||
cycle: ContractClearanceCycle | null,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ClearanceNextAction | null {
|
||||
return this.computeNextActionFromMeta(
|
||||
contract.tradeDirection,
|
||||
{
|
||||
dutyRequired: cycle?.dutyRequired ?? null,
|
||||
roHoldReason: cycle?.roHoldReason ?? null,
|
||||
preClearanceFinalizedAt: cycle?.preClearanceFinalizedAt ?? null,
|
||||
},
|
||||
milestones,
|
||||
'contract',
|
||||
);
|
||||
}
|
||||
|
||||
computeNextActionForBooking(
|
||||
booking: Booking,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ClearanceNextAction | null {
|
||||
return this.computeNextActionFromMeta(
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
metaFromBooking(booking),
|
||||
milestones,
|
||||
'booking',
|
||||
);
|
||||
}
|
||||
|
||||
private computeNextActionFromMeta(
|
||||
tradeDirection: string,
|
||||
meta: ClearanceMetaState,
|
||||
milestones: ClearanceMilestone[],
|
||||
terminalScope: 'contract' | 'booking',
|
||||
): ClearanceNextAction | null {
|
||||
if (meta.roHoldReason) {
|
||||
return {
|
||||
actor: 'GL_DJ',
|
||||
action: 'Re-upload Release Order or request port amendment',
|
||||
milestoneCode: 'RELEASE_ORDER_SECURED',
|
||||
blockedReason: meta.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 =
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
const terminalAction =
|
||||
terminalScope === 'contract'
|
||||
? 'Create shipment booking'
|
||||
: 'Proceed to request operation';
|
||||
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
if (!isDone('RELEASE_ORDER_SECURED')) {
|
||||
return {
|
||||
actor: 'GL_DJ',
|
||||
action: 'Upload Release Order and vessel departure date',
|
||||
milestoneCode: 'RELEASE_ORDER_SECURED',
|
||||
};
|
||||
}
|
||||
if (!isDone('DECLARED')) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Upload customs declaration documents',
|
||||
milestoneCode: 'DECLARED',
|
||||
};
|
||||
}
|
||||
if (!isDone(EXPORT_BOUNDARY)) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Confirm export release',
|
||||
milestoneCode: EXPORT_BOUNDARY,
|
||||
};
|
||||
}
|
||||
if (terminalScope === 'booking') {
|
||||
if (!isDone('FREIGHT_PAYMENT_SETTLED')) {
|
||||
return {
|
||||
actor: 'CUSTOMER',
|
||||
action: 'Pay freight charges',
|
||||
milestoneCode: 'FREIGHT_PAYMENT_SETTLED',
|
||||
};
|
||||
}
|
||||
if (!isDone('WAGON_ALLOCATED')) {
|
||||
return {
|
||||
actor: 'OPERATIONS',
|
||||
action: 'Allocate wagon',
|
||||
milestoneCode: 'WAGON_ALLOCATED',
|
||||
};
|
||||
}
|
||||
if (!isDone('EXPORT_TRANSPORT_ISSUED')) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Upload transit permit',
|
||||
milestoneCode: 'EXPORT_TRANSPORT_ISSUED',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER',
|
||||
action: terminalAction,
|
||||
milestoneCode: EXPORT_BOUNDARY,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDone('DECLARED')) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Upload customs declaration documents',
|
||||
milestoneCode: 'DECLARED',
|
||||
};
|
||||
}
|
||||
|
||||
if (meta.dutyRequired === null || meta.dutyRequired === undefined) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Set whether duty/tax applies',
|
||||
milestoneCode: 'DUTY_TAXES_ADVISED',
|
||||
};
|
||||
}
|
||||
|
||||
if (meta.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 (!meta.preClearanceFinalizedAt) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
action: 'Finalize pre-clearance',
|
||||
milestoneCode: 'TRANSIT_PERMIT_UPLOADED',
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDone(IMPORT_BOUNDARY)) {
|
||||
return {
|
||||
actor: 'GL_DJ',
|
||||
action: 'Upload Delivery Order',
|
||||
milestoneCode: IMPORT_BOUNDARY,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER',
|
||||
action: terminalAction,
|
||||
milestoneCode: IMPORT_BOUNDARY,
|
||||
};
|
||||
}
|
||||
|
||||
etPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null {
|
||||
const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'ET');
|
||||
return pending?.milestoneCode ?? null;
|
||||
}
|
||||
|
||||
djPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null {
|
||||
const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'DJ');
|
||||
return pending?.milestoneCode ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ContractDocPhase } from '@edr/types';
|
||||
|
||||
/** Shared clearance metadata for contract cycles and per-booking GENERAL clearance. */
|
||||
export interface ClearanceMetaState {
|
||||
dutyRequired?: boolean | null;
|
||||
vesselDepartureDate?: string | null;
|
||||
roAmendmentRequestedAt?: Date | null;
|
||||
roHoldReason?: string | null;
|
||||
currentPhase?: ContractDocPhase | string | null;
|
||||
preClearanceFinalizedAt?: Date | null;
|
||||
}
|
||||
|
||||
export type ClearanceScope =
|
||||
| { kind: 'contract'; contractId: string }
|
||||
| { kind: 'booking'; bookingId: string };
|
||||
|
||||
export function metaFromBooking(booking: {
|
||||
dutyRequired?: boolean | null;
|
||||
vesselDepartureDate?: string | null;
|
||||
roAmendmentRequestedAt?: Date | null;
|
||||
roHoldReason?: string | null;
|
||||
clearanceCurrentPhase?: string | null;
|
||||
preClearanceFinalizedAt?: Date | null;
|
||||
}): ClearanceMetaState {
|
||||
return {
|
||||
dutyRequired: booking.dutyRequired ?? null,
|
||||
vesselDepartureDate: booking.vesselDepartureDate ?? null,
|
||||
roAmendmentRequestedAt: booking.roAmendmentRequestedAt ?? null,
|
||||
roHoldReason: booking.roHoldReason ?? null,
|
||||
currentPhase: booking.clearanceCurrentPhase ?? null,
|
||||
preClearanceFinalizedAt: booking.preClearanceFinalizedAt ?? null,
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { Contract } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||
|
||||
/** 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 ruleEngineService: RuleEngineService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
@@ -194,9 +196,11 @@ export class ContractBookingService {
|
||||
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
} as never);
|
||||
} else if (generalCustoms) {
|
||||
// Per-booking clearance: seed post-booking milestones on the booking (no
|
||||
// cycle needed) and leave the contract active. The booking now drives its
|
||||
// own clearance via the booking-level pipeline.
|
||||
// Per-booking clearance: seed full milestone timeline on the booking.
|
||||
await this.milestoneService.seedPreBookingMilestonesOnBooking(
|
||||
booking.id,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
await this.milestoneService.seedPostBookingMilestones(
|
||||
booking.id,
|
||||
contract.tradeDirection,
|
||||
@@ -246,10 +250,14 @@ export class ContractBookingService {
|
||||
}
|
||||
return 'GL_ET';
|
||||
}
|
||||
// ONE_TIME customs — UNCHANGED: requires the finalized contract cycle.
|
||||
if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') {
|
||||
// ONE_TIME customs — pre-booking boundary milestone must be complete.
|
||||
const boundaryOk = await this.workflowService.isBoundaryComplete(
|
||||
contract.id,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
if (!boundaryOk) {
|
||||
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';
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
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 { FilesService } from '../files/files.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractsService, PaginatedContracts } from './contracts.service';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
import { contractClearanceCodes } from './contract-clearance.util';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
|
||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||
|
||||
export interface ContractClearanceDocument {
|
||||
fileKey: string;
|
||||
@@ -34,6 +44,39 @@ export interface ContractClearanceView {
|
||||
outputCode: string | null;
|
||||
documents: ContractClearanceDocument[];
|
||||
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;
|
||||
preClearanceFinalized?: boolean;
|
||||
/** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */
|
||||
exportClearanceFinalized?: boolean;
|
||||
linkedBookingId?: string | null;
|
||||
dutyAdvice?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
declarationSerial?: string | null;
|
||||
noticeFile?: { id: string; name: string; url: string } | null;
|
||||
} | null;
|
||||
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -41,13 +84,57 @@ export class ContractClearanceService {
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly contractsService: ContractsService,
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||
) {}
|
||||
|
||||
private isPhasedCustoms(contract: Contract): boolean {
|
||||
return contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME';
|
||||
}
|
||||
|
||||
private assertPhasedCustoms(contract: Contract): void {
|
||||
if (!this.isPhasedCustoms(contract)) {
|
||||
throw new BadRequestException(
|
||||
'Phased clearance (Phase 1) applies to one-time customs contracts.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy finalize() used to set CLEARANCE_READY_FOR_BOOKING without completing
|
||||
* phased milestones. Revert that state so declaration / DO steps can proceed.
|
||||
*/
|
||||
private async reconcilePrematureBookingReady(
|
||||
contractId: string,
|
||||
contract: Contract,
|
||||
bookingReady: boolean,
|
||||
): Promise<Contract> {
|
||||
if (
|
||||
!this.isPhasedCustoms(contract) ||
|
||||
contract.status !== 'CLEARANCE_READY_FOR_BOOKING' ||
|
||||
bookingReady
|
||||
) {
|
||||
return contract;
|
||||
}
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CLEARANCE_UNDER_REVIEW',
|
||||
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
|
||||
} as never);
|
||||
if (cycle?.status === 'CLEARANCE_READY_FOR_BOOKING') {
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
|
||||
}
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/** The pre-booking clearance document grid for a contract (Path B). */
|
||||
async getClearanceView(contractId: string): Promise<ContractClearanceView> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
let contract = await this.contractsService.findById(contractId);
|
||||
const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
|
||||
@@ -109,6 +196,47 @@ export class ContractClearanceService {
|
||||
}
|
||||
|
||||
const allApproved = await this.isClearanceFullyApproved(contract);
|
||||
const milestones = await this.workflowService.listMilestones(contractId);
|
||||
let boundary = await this.workflowService.isBoundaryComplete(
|
||||
contractId,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary);
|
||||
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
|
||||
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
||||
let workflowFiles = buildWorkflowFiles(
|
||||
files,
|
||||
contract.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
if (cycle?.bookingId) {
|
||||
const bookingFiles = await this.filesService.findByResource(
|
||||
cycle.bookingId,
|
||||
'bookings',
|
||||
);
|
||||
const bookingWorkflow = buildWorkflowFiles(
|
||||
bookingFiles,
|
||||
contract.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
const byCode = new Map(workflowFiles.map((f) => [f.code, f]));
|
||||
for (const row of bookingWorkflow) {
|
||||
if (row.file) byCode.set(row.code, row);
|
||||
}
|
||||
workflowFiles = [...byCode.values()];
|
||||
}
|
||||
|
||||
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
|
||||
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
|
||||
const bookingMilestones = await this.workflowService.listMilestonesForBooking(
|
||||
cycle.bookingId,
|
||||
);
|
||||
const booking = await this.bookingsService.findById(cycle.bookingId);
|
||||
if (booking) {
|
||||
nextAction = this.workflowService.computeNextActionForBooking(
|
||||
booking,
|
||||
bookingMilestones,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contractId,
|
||||
@@ -120,6 +248,55 @@ export class ContractClearanceService {
|
||||
outputCode,
|
||||
documents,
|
||||
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,
|
||||
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
|
||||
exportClearanceFinalized: Boolean(cycle?.completedAt),
|
||||
linkedBookingId: cycle?.bookingId ?? null,
|
||||
dutyAdvice,
|
||||
workflowFiles,
|
||||
};
|
||||
}
|
||||
|
||||
private buildDutyAdvice(
|
||||
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
||||
milestones: ClearanceMilestone[],
|
||||
): ContractClearanceView['dutyAdvice'] {
|
||||
const advised = milestones.find(
|
||||
(m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED',
|
||||
);
|
||||
if (!advised?.metadata) return null;
|
||||
const amount = advised.metadata.dutyAmount;
|
||||
const currency = advised.metadata.dutyCurrency;
|
||||
if (typeof amount !== 'number' || typeof currency !== 'string') return null;
|
||||
const notice = files.find((f) => f.code === 'duty_tax_notice');
|
||||
return {
|
||||
amount,
|
||||
currency,
|
||||
declarationSerial:
|
||||
typeof advised.metadata.declarationSerial === 'string'
|
||||
? advised.metadata.declarationSerial
|
||||
: null,
|
||||
noticeFile: notice
|
||||
? { id: notice.id, name: notice.name, url: notice.url }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,6 +331,59 @@ export class ContractClearanceService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Staff may approve/query documents during review, after a query cycle, or post-finalize re-query. */
|
||||
private assertClearanceReviewableStatus(contract: Contract): void {
|
||||
const allowed = [
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
];
|
||||
if (!allowed.includes(contract.status)) {
|
||||
throw new ConflictException(
|
||||
`Cannot review clearance documents on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Finalize when docs are under review or all approved after a partial query cycle. */
|
||||
private assertClearanceFinalizableStatus(contract: Contract): void {
|
||||
const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS'];
|
||||
if (!allowed.includes(contract.status)) {
|
||||
throw new ConflictException(
|
||||
`Cannot finalize clearance on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private assertClearanceOutputUploadableStatus(contract: Contract): void {
|
||||
const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS'];
|
||||
if (!allowed.includes(contract.status)) {
|
||||
throw new ConflictException(
|
||||
`Cannot upload output documents on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async bumpToUnderReviewWhenFullyApproved(contractId: string): Promise<void> {
|
||||
const refreshed = await this.contractsService.findById(contractId);
|
||||
const allApproved = await this.isClearanceFullyApproved(refreshed);
|
||||
if (
|
||||
!allApproved ||
|
||||
(refreshed.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
|
||||
refreshed.status !== 'CLEARANCE_READY_FOR_BOOKING')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CLEARANCE_UNDER_REVIEW',
|
||||
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
|
||||
} as never);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer uploads clearance documents on the contract. When every required
|
||||
* input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET.
|
||||
@@ -209,8 +439,16 @@ export class ContractClearanceService {
|
||||
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
|
||||
} as never);
|
||||
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);
|
||||
await this.workflowService.onDocumentReviewReopened(contractId);
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
@@ -294,25 +532,22 @@ export class ContractClearanceService {
|
||||
note?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
// Reviewing is allowed both while the batch is UNDER_REVIEW and after it has
|
||||
// dropped back to AWAITING_CLEARANCE_DOCUMENTS — querying one document flips
|
||||
// the contract to "awaiting" (the customer must re-upload), but the reviewer
|
||||
// may still be working through the rest of the batch. Restricting to
|
||||
// UNDER_REVIEW only would 409 every review after the first query.
|
||||
if (
|
||||
contract.status !== 'CLEARANCE_UNDER_REVIEW' &&
|
||||
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS'
|
||||
) {
|
||||
throw new ConflictException(
|
||||
`Cannot review clearance documents on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
this.assertClearanceReviewableStatus(contract);
|
||||
if (status === 'QUERIED' && !note?.trim()) {
|
||||
throw new BadRequestException('A note is required when querying a document');
|
||||
}
|
||||
|
||||
const { inputCode, outputCode } = contractClearanceCodes(contract);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (
|
||||
status === 'QUERIED' &&
|
||||
this.isPhasedCustoms(contract) &&
|
||||
cycle?.preClearanceFinalizedAt
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Customer documents cannot be queried after pre-clearance is finalized.',
|
||||
);
|
||||
}
|
||||
const reviews = await this.contractsRepository.findDocumentReviews(
|
||||
contractId,
|
||||
cycle?.id ?? null,
|
||||
@@ -348,6 +583,33 @@ export class ContractClearanceService {
|
||||
if (cycle) {
|
||||
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
|
||||
}
|
||||
if (this.isPhasedCustoms(contract)) {
|
||||
await this.workflowService.onDocumentReviewReopened(contractId);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
currentPhase: ContractDocPhase.GlEtReview,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (status === 'APPROVED') {
|
||||
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);
|
||||
@@ -359,11 +621,7 @@ export class ContractClearanceService {
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||
throw new ConflictException(
|
||||
`Cannot upload output documents on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
this.assertClearanceOutputUploadableStatus(contract);
|
||||
const { outputCode } = contractClearanceCodes(contract);
|
||||
if (!outputCode) {
|
||||
throw new BadRequestException('This contract has no customs output documents');
|
||||
@@ -384,8 +642,10 @@ export class ContractClearanceService {
|
||||
|
||||
/**
|
||||
* GL ET finalizes Path B pre-booking clearance: requires every customer
|
||||
* document APPROVED and required output docs present → CLEARANCE_READY_FOR_BOOKING
|
||||
* (GL then creates the booking). Rejects self-clearance (Path A) contracts.
|
||||
* document APPROVED. For phased customs (ONE_TIME), document review completes
|
||||
* here — booking readiness is set only after delivery order (import) or export
|
||||
* release via the milestone workflow. Non-phased customs still jump straight to
|
||||
* CLEARANCE_READY_FOR_BOOKING. Rejects self-clearance (Path A) contracts.
|
||||
*/
|
||||
async finalize(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
@@ -394,11 +654,7 @@ export class ContractClearanceService {
|
||||
'Self-clearance (Path A) contracts are finalized by Operations, not GL.',
|
||||
);
|
||||
}
|
||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||
throw new ConflictException(
|
||||
`Cannot finalize clearance on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
this.assertClearanceFinalizableStatus(contract);
|
||||
|
||||
const approved = await this.isClearanceFullyApproved(contract);
|
||||
if (!approved) {
|
||||
@@ -407,6 +663,24 @@ export class ContractClearanceService {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.isPhasedCustoms(contract)) {
|
||||
await this.workflowService.onAllDocsApproved(contractId);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
currentPhase:
|
||||
contract.tradeDirection === 'EXPORT'
|
||||
? ContractDocPhase.GlDjCollection
|
||||
: ContractDocPhase.GlEtOutput,
|
||||
});
|
||||
}
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CLEARANCE_UNDER_REVIEW',
|
||||
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
|
||||
} as never);
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
const { outputCode } = contractClearanceCodes(contract);
|
||||
if (outputCode) {
|
||||
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
|
||||
@@ -452,11 +726,7 @@ export class ContractClearanceService {
|
||||
'Operations finalize applies only to self-clearance (non-customs) contracts.',
|
||||
);
|
||||
}
|
||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||
throw new ConflictException(
|
||||
`Cannot finalize clearance on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
this.assertClearanceFinalizableStatus(contract);
|
||||
|
||||
const approved = await this.isClearanceFullyApproved(contract);
|
||||
if (!approved) {
|
||||
@@ -479,19 +749,14 @@ export class ContractClearanceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* GL ET clearance hub: every customs (Path B) contract that still needs
|
||||
* customs clearance — awaiting the customer's documents, under GL review, or
|
||||
* finalized and waiting for the customer to create the booking in the portal.
|
||||
* GL ET clearance hub: every customs (Path B) contract in phased clearance,
|
||||
* including after booking is created.
|
||||
*/
|
||||
async queue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||
return this.contractsRepository.findAllPaginated({
|
||||
page: filter.page ?? 1,
|
||||
pageSize: filter.pageSize ?? 100,
|
||||
statuses: [
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
],
|
||||
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
|
||||
customsClearingEnabled: true,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
@@ -536,4 +801,492 @@ export class ContractClearanceService {
|
||||
sortOrder: filter.sortOrder ?? 'DESC',
|
||||
});
|
||||
}
|
||||
|
||||
// ── Phased clearance actions (ONE_TIME customs, Phase 1) ───────────────────
|
||||
|
||||
/** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */
|
||||
private async ensureDeclarationPrerequisites(
|
||||
contractId: string,
|
||||
contract: Contract,
|
||||
): Promise<void> {
|
||||
const allApproved = await this.isClearanceFullyApproved(contract);
|
||||
if (!allApproved) {
|
||||
throw new BadRequestException(
|
||||
'All required customer documents must be approved before uploading a declaration.',
|
||||
);
|
||||
}
|
||||
const milestones = await this.workflowService.listMilestones(contractId);
|
||||
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
|
||||
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
|
||||
await this.workflowService.onAllDocsApproved(contractId);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadDeclaration(
|
||||
contractId: string,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
await this.ensureDeclarationPrerequisites(contractId, contract);
|
||||
await this.workflowService.assertPriorComplete(
|
||||
contractId,
|
||||
contract.tradeDirection,
|
||||
'UNDER_CUSTOMS_CLEARANCE',
|
||||
);
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No declaration documents uploaded');
|
||||
}
|
||||
|
||||
await persistDeclarationUploads(
|
||||
this.filesService,
|
||||
contractId,
|
||||
'contracts',
|
||||
files,
|
||||
);
|
||||
|
||||
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,
|
||||
attachment?: Express.Multer.File,
|
||||
): 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.');
|
||||
}
|
||||
if (!attachment) {
|
||||
throw new BadRequestException('Duty notice attachment is required when duty applies.');
|
||||
}
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
code: 'duty_tax_notice',
|
||||
file: attachment,
|
||||
});
|
||||
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,
|
||||
files: 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 (files.length === 0) {
|
||||
throw new BadRequestException('No transit permit documents uploaded');
|
||||
}
|
||||
|
||||
await persistTransitPermitUploads(
|
||||
this.filesService,
|
||||
contractId,
|
||||
'contracts',
|
||||
files,
|
||||
);
|
||||
|
||||
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.GlEtPostClearance,
|
||||
});
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
async finalizePreClearance(contractId: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Pre-clearance finalize applies only to import contracts.');
|
||||
}
|
||||
|
||||
await this.workflowService.assertPriorComplete(
|
||||
contractId,
|
||||
'IMPORT',
|
||||
'TRANSIT_PERMIT_UPLOADED',
|
||||
);
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||
if (cycle.preClearanceFinalizedAt) {
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
preClearanceFinalizedAt: new Date(),
|
||||
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.');
|
||||
}
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle?.preClearanceFinalizedAt) {
|
||||
throw new BadRequestException(
|
||||
'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.',
|
||||
);
|
||||
}
|
||||
|
||||
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 finalizes export clearance after post-booking transit permit is uploaded. */
|
||||
async finalizeExportClearance(contractId: string, userId?: string): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException('Export clearance finalize applies only to export contracts.');
|
||||
}
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle?.bookingId) {
|
||||
throw new BadRequestException(
|
||||
'A shipment booking must exist before export clearance can be finalized.',
|
||||
);
|
||||
}
|
||||
if (cycle.completedAt) {
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
const bookingMilestones = await this.workflowService.listMilestonesForBooking(
|
||||
cycle.bookingId,
|
||||
);
|
||||
const transportDone = bookingMilestones.some(
|
||||
(m) => m.milestoneCode === 'EXPORT_TRANSPORT_ISSUED' && m.status === 'COMPLETED',
|
||||
);
|
||||
if (!transportDone) {
|
||||
throw new BadRequestException(
|
||||
'Upload the transit permit before finalizing export clearance.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
completedAt: new Date(),
|
||||
currentPhase: ContractDocPhase.GlEtPostClearance,
|
||||
});
|
||||
|
||||
void userId;
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */
|
||||
async etQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||
const base = await this.contractsRepository.findAllPaginated({
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
|
||||
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);
|
||||
if (belongsOnEtClearanceQueue(milestones)) 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 handed off to or handled by Djibouti GL. */
|
||||
async djQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||
const base = await this.contractsRepository.findAllPaginated({
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
statuses: [...DJ_CONTRACT_QUEUE_STATUSES],
|
||||
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);
|
||||
if (belongsOnDjClearanceQueue(c.tradeDirection, cycle, milestones)) {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,15 +172,11 @@ export class ContractTransitionService {
|
||||
const cargoTypeId =
|
||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null;
|
||||
|
||||
// US-06 routing: bulk always needs director approval; container needs it only
|
||||
// when its cargo type flags it. Resolve the chain via the same approval_rules
|
||||
// source of truth the booking flow uses (no booking row is created here).
|
||||
let requiresDirectorApproval = contract.freightType === 'BULK';
|
||||
// Resolve the chain from the cargo type flag only.
|
||||
let requiresDirectorApproval = false;
|
||||
if (cargoTypeId) {
|
||||
const cargoType = await this.cargoTypesService.findById(cargoTypeId);
|
||||
if (cargoType?.requiresDirectorApproval) {
|
||||
requiresDirectorApproval = true;
|
||||
}
|
||||
requiresDirectorApproval = cargoType?.requiresDirectorApproval ?? false;
|
||||
}
|
||||
|
||||
const chain = await this.approvalRulesService.findChain(requiresDirectorApproval);
|
||||
@@ -345,6 +341,14 @@ export class ContractTransitionService {
|
||||
return { view, html, signatures: view.signatures };
|
||||
}
|
||||
|
||||
/** Lazy-generate (or refresh) the stored contract PDF and stream it for download. */
|
||||
async streamContractPdf(contractId: string) {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
const { view } = await this.documentViewModelBuilder.build(contractId);
|
||||
const record = await this.upsertContractPdf(contractId, contract.reference, view);
|
||||
return this.filesService.streamById(record.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the stored `contract` PDF from the current aggregate (now including
|
||||
* the latest signatures) so the downloaded/viewed file matches the live HTML
|
||||
|
||||
@@ -9,13 +9,16 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UploadedFiles,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
@@ -40,11 +43,13 @@ import { ContractsService } from './contracts.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
import { ContractClearanceService } from './contract-clearance.service';
|
||||
import { BookingClearanceService } from './booking-clearance.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { BookingRequestService } from './booking-request.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
import { CreateContractDto } from './dto/create-contract.dto';
|
||||
import { UpdateContractDto } from './dto/update-contract.dto';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
@@ -70,6 +75,10 @@ import {
|
||||
CompleteMilestoneDto,
|
||||
ReportIncidentDto,
|
||||
} from './dto/gl-operations.dto';
|
||||
import {
|
||||
AdviseContractDutyDto,
|
||||
RoAmendmentDto,
|
||||
} from './dto/phased-clearance.dto';
|
||||
|
||||
@ApiTags('contracts')
|
||||
@Controller('contracts')
|
||||
@@ -85,6 +94,8 @@ export class ContractsController {
|
||||
private readonly glOperationsService: GlOperationsService,
|
||||
private readonly bookingRequestService: BookingRequestService,
|
||||
private readonly signaturesService: SignaturesService,
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
private readonly bookingsService: BookingsService,
|
||||
) {}
|
||||
|
||||
// ── Shipment / booking requests (GENERAL + customs, Path B) ───────────────
|
||||
@@ -417,6 +428,26 @@ export class ContractsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id/contract/document')
|
||||
@ApiOperation({ summary: 'Download contract PDF' })
|
||||
async downloadContractDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
const { stream, record } = await this.transitionService.streamContractPdf(id);
|
||||
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${record.name}"`,
|
||||
);
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
@Post(':id/contract/sign')
|
||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||
signContract(
|
||||
@@ -459,7 +490,10 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/clearance/review')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
])
|
||||
@ApiOperation({ summary: 'GL ET reviews a clearance document (Approve | Query)' })
|
||||
reviewClearanceDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -489,11 +523,164 @@ export class ContractsController {
|
||||
|
||||
@Post(':id/clearance/finalize')
|
||||
@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) {
|
||||
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 documents (multi-file)' })
|
||||
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)
|
||||
@UseInterceptors(FileInterceptor('attachment'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET sets duty/tax requirement and advises amount with notice attachment' })
|
||||
adviseContractDuty(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('dutyRequired') dutyRequiredRaw: string,
|
||||
@Body('amount') amountRaw: string | undefined,
|
||||
@Body('currency') currency: string | undefined,
|
||||
@Body('declarationSerial') declarationSerial: string | undefined,
|
||||
@UploadedFile() attachment: Express.Multer.File | undefined,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1';
|
||||
const dto: AdviseContractDutyDto = {
|
||||
dutyRequired,
|
||||
amount:
|
||||
amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined,
|
||||
currency: currency ?? 'ETB',
|
||||
declarationSerial,
|
||||
};
|
||||
return this.clearanceService.adviseDuty(
|
||||
id,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
attachment,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/finalize-pre-clearance')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance — unlocks Djibouti DO upload' })
|
||||
finalizePreClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.clearanceService.finalizePreClearance(id);
|
||||
}
|
||||
|
||||
@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(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET uploads import transit permit documents (multi-file)' })
|
||||
uploadTransitPermit(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.uploadTransitPermit(id, files ?? [], 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));
|
||||
}
|
||||
|
||||
@Post(':id/clearance/finalize-export-clearance')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({
|
||||
summary: 'GL ET finalizes export clearance after post-booking transit permit upload',
|
||||
})
|
||||
finalizeExportClearance(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Get('clearance/et-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' })
|
||||
etClearanceQueue(@Query() filter: FilterContractDto) {
|
||||
return this.clearanceService.etQueue(filter);
|
||||
}
|
||||
|
||||
@Get('clearance/dj-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' })
|
||||
djClearanceQueue(@Query() filter: FilterContractDto) {
|
||||
return this.clearanceService.djQueue(filter);
|
||||
}
|
||||
|
||||
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
|
||||
|
||||
@Get('clearance/ops-queue')
|
||||
@@ -674,6 +861,18 @@ export class ContractsController {
|
||||
});
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/transport-document')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL ET uploads export transit permit documents (multi-file)' })
|
||||
uploadTransportDocument(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/documents')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@@ -692,11 +891,16 @@ export class ContractsController {
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' })
|
||||
uploadDutySlip(
|
||||
async uploadDutySlip(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.glOperationsService.uploadDutySlip(bookingId, (files ?? [])[0]);
|
||||
const file = (files ?? [])[0];
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking)) {
|
||||
return this.bookingClearanceService.uploadDutySlip(bookingId, file);
|
||||
}
|
||||
return this.glOperationsService.uploadDutySlip(bookingId, file);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/incidents')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||
@@ -18,6 +18,8 @@ import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
import { ContractClearanceService } from './contract-clearance.service';
|
||||
import { BookingClearanceService } from './booking-clearance.service';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
@@ -71,7 +73,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
CompaniesModule,
|
||||
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
||||
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
||||
BookingsModule,
|
||||
forwardRef(() => BookingsModule),
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||
@@ -85,6 +87,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractPricingService,
|
||||
ContractTransitionService,
|
||||
ContractClearanceService,
|
||||
ClearanceWorkflowService,
|
||||
BookingClearanceService,
|
||||
ContractBookingService,
|
||||
ClearanceMilestoneService,
|
||||
GlOperationsService,
|
||||
@@ -103,6 +107,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractPricingService,
|
||||
ContractTransitionService,
|
||||
ContractClearanceService,
|
||||
ClearanceWorkflowService,
|
||||
BookingClearanceService,
|
||||
ContractBookingService,
|
||||
ClearanceMilestoneService,
|
||||
],
|
||||
|
||||
@@ -518,7 +518,17 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
cycleId: string,
|
||||
status: string,
|
||||
fields: Partial<
|
||||
Pick<ContractClearanceCycle, 'bookingId' | 'clearanceReadyAt' | 'completedAt'>
|
||||
Pick<
|
||||
ContractClearanceCycle,
|
||||
| 'bookingId'
|
||||
| 'clearanceReadyAt'
|
||||
| 'completedAt'
|
||||
| 'dutyRequired'
|
||||
| 'vesselDepartureDate'
|
||||
| 'roAmendmentRequestedAt'
|
||||
| 'roHoldReason'
|
||||
| 'currentPhase'
|
||||
>
|
||||
> = {},
|
||||
): Promise<void> {
|
||||
await this.dataSource
|
||||
@@ -526,6 +536,25 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
.update(cycleId, { status, ...fields } as never);
|
||||
}
|
||||
|
||||
async updateCycle(
|
||||
cycleId: string,
|
||||
fields: Partial<
|
||||
Pick<
|
||||
ContractClearanceCycle,
|
||||
| 'dutyRequired'
|
||||
| 'vesselDepartureDate'
|
||||
| 'roAmendmentRequestedAt'
|
||||
| 'roHoldReason'
|
||||
| 'currentPhase'
|
||||
| 'status'
|
||||
| 'preClearanceFinalizedAt'
|
||||
| 'completedAt'
|
||||
>
|
||||
>,
|
||||
): Promise<void> {
|
||||
await this.dataSource.getRepository(ContractClearanceCycle).update(cycleId, fields as never);
|
||||
}
|
||||
|
||||
/** Link the GL-created booking to a clearance cycle. */
|
||||
async linkBooking(cycleId: string, bookingId: string): Promise<void> {
|
||||
await this.dataSource
|
||||
|
||||
@@ -200,9 +200,6 @@ export class ContractsService {
|
||||
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
||||
isHazardous: dto.isHazardous ?? false,
|
||||
isReefer: dto.isReefer ?? false,
|
||||
estimatedShipmentDate: dto.estimatedShipmentDate
|
||||
? new Date(dto.estimatedShipmentDate)
|
||||
: null,
|
||||
contractType: dto.contractType ?? null,
|
||||
status: 'DRAFT',
|
||||
clearanceStatus: 'NOT_APPLICABLE',
|
||||
@@ -347,9 +344,6 @@ export class ContractsService {
|
||||
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? existing.lastMileDeliveryLng,
|
||||
contractType: dto.contractType ?? existing.contractType,
|
||||
};
|
||||
if (dto.estimatedShipmentDate) {
|
||||
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
|
||||
}
|
||||
if (dto.renewalOfId !== undefined) updates.renewalOfId = dto.renewalOfId ?? null;
|
||||
|
||||
// Customs clearing always mirrors the (possibly changed) service type.
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@@ -219,14 +218,6 @@ export class CreateContractDto {
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isReefer?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Non-binding estimate from the wizard (NOT validated against departures)',
|
||||
example: '2026-07-15T00:00:00.000Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
estimatedShipmentDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Contract document type (SPOT, etc.)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -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,25 @@ export class ContractClearanceCycle extends BaseEntity {
|
||||
|
||||
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||
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;
|
||||
|
||||
/** ET GL confirms import pre-clearance complete — unlocks Djibouti DO upload. */
|
||||
@Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true })
|
||||
preClearanceFinalizedAt?: Date | null;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IncidentType,
|
||||
} from './entities/clearance-incident.entity';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { persistExportTransportUploads } from './phased-clearance.util';
|
||||
|
||||
/**
|
||||
* Maps a GL post-booking document `code` to the milestone it auto-completes when
|
||||
@@ -21,6 +22,7 @@ const DOC_CODE_TO_MILESTONE: Record<string, string> = {
|
||||
import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET
|
||||
full_in_interchange: 'OFFLOADED', // export — GL DJ
|
||||
final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET
|
||||
export_transport_document: 'EXPORT_TRANSPORT_ISSUED', // export — GL ET post-allocation
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -158,4 +160,44 @@ export class GlOperationsService {
|
||||
}
|
||||
return { uploaded: files.length, completedMilestones };
|
||||
}
|
||||
|
||||
/**
|
||||
* GL ET uploads export transport document after wagon allocation (export ONE_TIME).
|
||||
*/
|
||||
async uploadTransportDocument(
|
||||
bookingId: string,
|
||||
files: 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 (files.length === 0) {
|
||||
throw new BadRequestException('No transit permit documents uploaded');
|
||||
}
|
||||
|
||||
await persistExportTransportUploads(this.filesService, bookingId, files);
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue } from './phased-clearance.util';
|
||||
|
||||
describe('buildWorkflowFiles', () => {
|
||||
const resourceFiles = [
|
||||
{ code: 'im4', id: 'f-im4', name: 'im4.pdf', url: '/files/im4' },
|
||||
{ code: 'im5', id: 'f-im5', name: 'im5.pdf', url: '/files/im5' },
|
||||
{
|
||||
code: 'transit_permitted',
|
||||
id: 'f-transit',
|
||||
name: 'transit.png',
|
||||
url: '/files/transit',
|
||||
},
|
||||
{
|
||||
code: 'duty_tax_notice',
|
||||
id: 'f-duty',
|
||||
name: 'notice.pdf',
|
||||
url: '/files/duty',
|
||||
},
|
||||
{ code: 'commercial_invoice', id: 'f-inv', name: 'inv.pdf', url: '/files/inv' },
|
||||
];
|
||||
|
||||
it('includes declaration and transit files even when they also appear in GL output document settings', () => {
|
||||
const result = buildWorkflowFiles(resourceFiles, 'IMPORT');
|
||||
|
||||
expect(result.map((f) => f.code)).toEqual(
|
||||
expect.arrayContaining(['im4', 'im5', 'transit_permitted', 'duty_tax_notice']),
|
||||
);
|
||||
});
|
||||
|
||||
it('includes multi-file declaration uploads alongside catalog codes', () => {
|
||||
const result = buildWorkflowFiles(
|
||||
[
|
||||
...resourceFiles,
|
||||
{
|
||||
code: 'declaration_0',
|
||||
id: 'f-dec-0',
|
||||
name: 'decl-a.pdf',
|
||||
url: '/files/decl-a',
|
||||
},
|
||||
{
|
||||
code: 'declaration_1',
|
||||
id: 'f-dec-1',
|
||||
name: 'decl-b.pdf',
|
||||
url: '/files/decl-b',
|
||||
},
|
||||
],
|
||||
'IMPORT',
|
||||
);
|
||||
|
||||
expect(result.map((f) => f.code)).toEqual(
|
||||
expect.arrayContaining(['im4', 'im5', 'declaration_0', 'declaration_1']),
|
||||
);
|
||||
expect(result.find((f) => f.code === 'declaration_0')?.label).toBe(
|
||||
'Declaration document 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes multi-file import transit permit uploads', () => {
|
||||
const result = buildWorkflowFiles(
|
||||
[
|
||||
...resourceFiles,
|
||||
{
|
||||
code: 'transit_permit_0',
|
||||
id: 'f-tp-0',
|
||||
name: 'permit-a.pdf',
|
||||
url: '/files/tp-a',
|
||||
},
|
||||
{
|
||||
code: 'transit_permit_1',
|
||||
id: 'f-tp-1',
|
||||
name: 'permit-b.pdf',
|
||||
url: '/files/tp-b',
|
||||
},
|
||||
],
|
||||
'IMPORT',
|
||||
);
|
||||
|
||||
expect(result.map((f) => f.code)).toEqual(
|
||||
expect.arrayContaining(['transit_permitted', 'transit_permit_0', 'transit_permit_1']),
|
||||
);
|
||||
expect(result.find((f) => f.code === 'transit_permit_0')?.label).toBe('Transit permit 1');
|
||||
});
|
||||
|
||||
it('does not include non-catalog customer document codes', () => {
|
||||
const result = buildWorkflowFiles(resourceFiles, 'IMPORT');
|
||||
|
||||
expect(result.some((f) => f.code === 'commercial_invoice')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('belongsOnDjClearanceQueue', () => {
|
||||
it('keeps import contracts after pre-clearance is finalized (even post-booking)', () => {
|
||||
expect(
|
||||
belongsOnDjClearanceQueue(
|
||||
'IMPORT',
|
||||
{ preClearanceFinalizedAt: new Date('2026-01-01') },
|
||||
[],
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps contracts with completed Djibouti milestones', () => {
|
||||
expect(
|
||||
belongsOnDjClearanceQueue('IMPORT', null, [
|
||||
{ ownerRegion: 'DJ', status: 'COMPLETED' },
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes import contracts still on Ethiopia-side clearance only', () => {
|
||||
expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('belongsOnEtClearanceQueue', () => {
|
||||
it('keeps contracts once phased clearance milestones exist', () => {
|
||||
expect(
|
||||
belongsOnEtClearanceQueue([{ ownerRegion: 'ET', status: 'COMPLETED' }]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes contracts with no clearance milestones', () => {
|
||||
expect(belongsOnEtClearanceQueue([])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
catalogEntriesForTradeDirection,
|
||||
declarationFileLabel,
|
||||
isDeclarationFileCode,
|
||||
isImportTransitPermitFileCode,
|
||||
isExportTransportFileCode,
|
||||
exportTransportFileLabel,
|
||||
transitPermitFileLabel,
|
||||
type ClearanceWorkflowFile,
|
||||
} from '@edr/types';
|
||||
|
||||
/** Require at least one declaration file in the upload batch. */
|
||||
export function assertDeclarationFiles(files: Express.Multer.File[]): void {
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No declaration documents uploaded');
|
||||
}
|
||||
}
|
||||
|
||||
/** Assign stable `declaration_*` codes so multi-file uploads always pass validation. */
|
||||
export function normalizeDeclarationFieldNames(
|
||||
files: Express.Multer.File[],
|
||||
): Express.Multer.File[] {
|
||||
return files.map((file, index) => ({
|
||||
...file,
|
||||
fieldname: `declaration_${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
type DeclarationFileStore = {
|
||||
findByResource(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
): Promise<Array<{ code?: string | null }>>;
|
||||
deleteByCode(resourceId: string, resource: string, code: string): Promise<void>;
|
||||
upload(input: {
|
||||
resourceId: string;
|
||||
resource: string;
|
||||
code: string;
|
||||
file: Express.Multer.File;
|
||||
}): Promise<unknown>;
|
||||
};
|
||||
|
||||
/** Replace all declaration files on a resource with a new multi-file upload batch. */
|
||||
export async function persistDeclarationUploads(
|
||||
store: DeclarationFileStore,
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<void> {
|
||||
const normalized = normalizeDeclarationFieldNames(files);
|
||||
assertDeclarationFiles(normalized);
|
||||
|
||||
const existing = await store.findByResource(resourceId, resource);
|
||||
await Promise.all(
|
||||
existing
|
||||
.filter((f) => f.code && isDeclarationFileCode(f.code))
|
||||
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
normalized.map((file, index) =>
|
||||
store.upload({
|
||||
resourceId,
|
||||
resource,
|
||||
code: `declaration_${index}`,
|
||||
file,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Require at least one transit permit file in the upload batch. */
|
||||
export function assertTransitPermitFiles(files: Express.Multer.File[]): void {
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No transit permit documents uploaded');
|
||||
}
|
||||
}
|
||||
|
||||
/** Assign stable `transit_permit_*` codes for multi-file import transit uploads. */
|
||||
export function normalizeTransitPermitFieldNames(
|
||||
files: Express.Multer.File[],
|
||||
): Express.Multer.File[] {
|
||||
return files.map((file, index) => ({
|
||||
...file,
|
||||
fieldname: `transit_permit_${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Replace all import transit permit files on a resource with a new multi-file batch. */
|
||||
export async function persistTransitPermitUploads(
|
||||
store: DeclarationFileStore,
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<void> {
|
||||
const normalized = normalizeTransitPermitFieldNames(files);
|
||||
assertTransitPermitFiles(normalized);
|
||||
|
||||
const existing = await store.findByResource(resourceId, resource);
|
||||
await Promise.all(
|
||||
existing
|
||||
.filter((f) => f.code && isImportTransitPermitFileCode(f.code))
|
||||
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
normalized.map((file, index) =>
|
||||
store.upload({
|
||||
resourceId,
|
||||
resource,
|
||||
code: `transit_permit_${index}`,
|
||||
file,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Require at least one export transport document in the upload batch. */
|
||||
export function assertExportTransportFiles(files: Express.Multer.File[]): void {
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No transit permit documents uploaded');
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeExportTransportFieldNames(
|
||||
files: Express.Multer.File[],
|
||||
): Express.Multer.File[] {
|
||||
return files.map((file, index) => ({
|
||||
...file,
|
||||
fieldname: `export_transport_document_${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Replace all export transport documents on a booking with a new multi-file batch. */
|
||||
export async function persistExportTransportUploads(
|
||||
store: DeclarationFileStore,
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<void> {
|
||||
const normalized = normalizeExportTransportFieldNames(files);
|
||||
assertExportTransportFiles(normalized);
|
||||
|
||||
const existing = await store.findByResource(bookingId, 'bookings');
|
||||
await Promise.all(
|
||||
existing
|
||||
.filter((f) => f.code && isExportTransportFileCode(f.code))
|
||||
.map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
normalized.map((file, index) =>
|
||||
store.upload({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: `export_transport_document_${index}`,
|
||||
file,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function parseDutyRequiredForm(value: string | boolean | undefined): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === undefined || value === '') return false;
|
||||
return value === 'true' || value === '1';
|
||||
}
|
||||
|
||||
type DjQueueMilestone = {
|
||||
ownerRegion?: string | null;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type DjQueueCycle = {
|
||||
preClearanceFinalizedAt?: Date | null;
|
||||
roHoldReason?: string | null;
|
||||
} | null | undefined;
|
||||
|
||||
/** Whether a customs clearance item belongs on the persistent GL Djibouti list. */
|
||||
export function belongsOnDjClearanceQueue(
|
||||
tradeDirection: string | null | undefined,
|
||||
cycle: DjQueueCycle,
|
||||
milestones: DjQueueMilestone[],
|
||||
extras?: {
|
||||
roHoldReason?: string | null;
|
||||
preClearanceFinalizedAt?: Date | null;
|
||||
},
|
||||
): boolean {
|
||||
const roHold = cycle?.roHoldReason ?? extras?.roHoldReason;
|
||||
if (roHold) return true;
|
||||
|
||||
const hasDjActivity = milestones.some(
|
||||
(m) => m.ownerRegion === 'DJ' && (m.status === 'COMPLETED' || m.status === 'PENDING'),
|
||||
);
|
||||
if (hasDjActivity) return true;
|
||||
|
||||
const preFinalized =
|
||||
cycle?.preClearanceFinalizedAt ?? extras?.preClearanceFinalizedAt ?? null;
|
||||
if (tradeDirection === 'IMPORT' && preFinalized) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Contract statuses for persistent phased customs clearance lists (ET + DJ). */
|
||||
export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
'FULLY_EXECUTED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'CONTRACT_CLOSED',
|
||||
] as const;
|
||||
|
||||
/** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */
|
||||
export function belongsOnEtClearanceQueue(milestones: DjQueueMilestone[]): boolean {
|
||||
return milestones.some((m) => m.status === 'PENDING' || m.status === 'COMPLETED');
|
||||
}
|
||||
|
||||
/** Contract statuses that may appear on the GL Djibouti clearance list (includes post-booking). */
|
||||
export const DJ_CONTRACT_QUEUE_STATUSES = PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES;
|
||||
|
||||
/** Booking statuses for persistent phased customs clearance lists (ET + DJ). */
|
||||
export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY',
|
||||
'FULLY_EXECUTED',
|
||||
'OPERATION_REQUEST_PENDING',
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
'ROAD_DISPATCH_PENDING',
|
||||
'IN_TRANSIT',
|
||||
'PAID',
|
||||
'COMPLETED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'CONTRACT_CLOSED',
|
||||
] as const;
|
||||
|
||||
/** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */
|
||||
export const DJ_BOOKING_QUEUE_STATUSES = PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES;
|
||||
|
||||
/** Build labeled phased-customs file rows from resource files. */
|
||||
export function buildWorkflowFiles(
|
||||
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
||||
tradeDirection: string,
|
||||
): ClearanceWorkflowFile[] {
|
||||
const fileByCode = new Map(
|
||||
files.filter((f) => f.code).map((f) => [f.code as string, f]),
|
||||
);
|
||||
const out: ClearanceWorkflowFile[] = [];
|
||||
const included = new Set<string>();
|
||||
|
||||
for (const entry of catalogEntriesForTradeDirection(tradeDirection)) {
|
||||
const file = fileByCode.get(entry.code) ?? null;
|
||||
if (!file) continue;
|
||||
included.add(entry.code);
|
||||
out.push({
|
||||
code: entry.code,
|
||||
label: entry.label,
|
||||
uploadedBy: entry.uploadedBy,
|
||||
category: entry.category,
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
});
|
||||
}
|
||||
|
||||
const extraDeclarations = files
|
||||
.filter((f) => f.code && isDeclarationFileCode(f.code) && !included.has(f.code))
|
||||
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
||||
|
||||
extraDeclarations.forEach((file, index) => {
|
||||
if (!file.code) return;
|
||||
included.add(file.code);
|
||||
out.push({
|
||||
code: file.code,
|
||||
label: declarationFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_et',
|
||||
category: 'declaration',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
});
|
||||
});
|
||||
|
||||
if (tradeDirection === 'IMPORT') {
|
||||
const extraTransit = files
|
||||
.filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code))
|
||||
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
||||
|
||||
extraTransit.forEach((file, index) => {
|
||||
if (!file.code) return;
|
||||
included.add(file.code);
|
||||
out.push({
|
||||
code: file.code,
|
||||
label: transitPermitFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_et',
|
||||
category: 'transit',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
const extraExportTransport = files
|
||||
.filter((f) => f.code && isExportTransportFileCode(f.code) && !included.has(f.code))
|
||||
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
||||
|
||||
extraExportTransport.forEach((file, index) => {
|
||||
if (!file.code) return;
|
||||
included.add(file.code);
|
||||
out.push({
|
||||
code: file.code,
|
||||
label: exportTransportFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_et',
|
||||
category: 'transit',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -61,6 +61,14 @@ export class FilesService {
|
||||
return this.upload(input);
|
||||
}
|
||||
|
||||
async deleteByCode(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
code: string,
|
||||
): Promise<void> {
|
||||
await this.filesRepository.deleteByCode(resourceId, resource, code);
|
||||
}
|
||||
|
||||
async uploadMany(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
|
||||
@@ -4,22 +4,22 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto";
|
||||
import { PaymentService } from "./payment.service";
|
||||
|
||||
/**
|
||||
* Consumer side of the payment microservice's outbox relay.
|
||||
* Only the payment service may call this (shared SERVICE_AUTH_TOKEN).
|
||||
* WARNING: currently unauthenticated — anyone who can reach the API can mark
|
||||
* payments as paid. Re-add ServiceAuthGuard before exposing beyond a trusted network.
|
||||
* Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless.
|
||||
* Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available;
|
||||
* this HTTP endpoint remains as a transport-agnostic fallback.
|
||||
*/
|
||||
@ApiTags("Internal Payments")
|
||||
@UseGuards(ServiceAuthGuard)
|
||||
@Public()
|
||||
@Controller("internal/payments")
|
||||
export class InternalPaymentController {
|
||||
constructor(private readonly paymentService: PaymentService) { }
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { BillingModule } from "../billing/billing.module";
|
||||
import { FirstMileModule } from "../first-mile/first-mile.module";
|
||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
|
||||
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
|
||||
import { PaymentEntity } from "./entities/payment.entity";
|
||||
@@ -57,6 +59,8 @@ function rabbitMQImport(): DynamicModule[] {
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
ConfigModule,
|
||||
forwardRef(() => BillingModule),
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
FirstMileModule,
|
||||
TypeOrmModule.forFeature([
|
||||
PaymentEntity,
|
||||
PaymentWebhookEventEntity,
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { RouteStatus } from '../entities/route.entity';
|
||||
|
||||
export class CreateRouteMilestoneDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
yardId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Km from the previous stop (0 for origin)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
distanceKm?: number;
|
||||
}
|
||||
|
||||
export class CreateRouteDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ type: [CreateRouteMilestoneDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(2)
|
||||
@@ -21,8 +33,8 @@ export class CreateRouteDto {
|
||||
@Type(() => CreateRouteMilestoneDto)
|
||||
milestones!: CreateRouteMilestoneDto[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
@IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'])
|
||||
status?: RouteStatus;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
import { IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import { RouteStatus } from '../entities/route.entity';
|
||||
|
||||
export class FilterRoutesDto {
|
||||
@ApiPropertyOptional()
|
||||
@ApiPropertyOptional({ description: 'Search origin/destination yard codes or names' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
@IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'])
|
||||
status?: RouteStatus;
|
||||
}
|
||||
|
||||
@@ -23,4 +23,8 @@ export class RouteMilestone extends BaseEntity {
|
||||
|
||||
@Column({ name: 'sequence_no', type: 'int' })
|
||||
sequenceNo!: number;
|
||||
|
||||
/** Kilometres from the previous stop (0 for origin). */
|
||||
@Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
distanceKm?: number | null;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { RouteMilestone } from './route-milestone.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'routes' })
|
||||
@Index(['name'])
|
||||
@Index(['isActive'])
|
||||
export class Route extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 120, unique: true })
|
||||
name!: string;
|
||||
export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'routes' })
|
||||
@Index(['status'])
|
||||
export class Route extends BaseEntity {
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
||||
originYardId!: string;
|
||||
|
||||
@@ -25,9 +23,24 @@ export class Route extends BaseEntity {
|
||||
@JoinColumn({ name: 'destination_yard_id' })
|
||||
destinationYard?: Yard;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
@Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' })
|
||||
status!: RouteStatus;
|
||||
|
||||
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
|
||||
milestones?: RouteMilestone[];
|
||||
}
|
||||
|
||||
export function formatRouteLabel(route: {
|
||||
originYard?: { code?: string; name?: string } | null;
|
||||
destinationYard?: { code?: string; name?: string } | null;
|
||||
}): string {
|
||||
const origin = route.originYard?.code ?? route.originYard?.name ?? 'Origin';
|
||||
const dest = route.destinationYard?.code ?? route.destinationYard?.name ?? 'Destination';
|
||||
return `${origin} → ${dest}`;
|
||||
}
|
||||
|
||||
export function totalRouteDistanceKm(
|
||||
milestones: Array<{ distanceKm?: number | string | null }>,
|
||||
): number {
|
||||
return milestones.reduce((sum, m) => sum + Number(m.distanceKm ?? 0), 0);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource, ILike } from 'typeorm';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { CreateRouteDto } from './dto/create-route.dto';
|
||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||
import { RouteMilestone } from './entities/route-milestone.entity';
|
||||
import { Route } from './entities/route.entity';
|
||||
import { formatRouteLabel, Route } from './entities/route.entity';
|
||||
import { RoutesRepository } from './routes.repository';
|
||||
|
||||
@Injectable()
|
||||
@@ -16,11 +16,10 @@ export class RoutesService {
|
||||
private readonly routesRepository: RoutesRepository,
|
||||
) {}
|
||||
|
||||
findAll(filter: FilterRoutesDto): Promise<Route[]> {
|
||||
return this.routesRepository.findAll({
|
||||
async findAll(filter: FilterRoutesDto): Promise<Route[]> {
|
||||
const routes = await this.routesRepository.findAll({
|
||||
where: {
|
||||
...(filter.search ? { name: ILike(`%${filter.search.trim()}%`) } : {}),
|
||||
...(filter.isActive !== undefined ? { isActive: filter.isActive } : {}),
|
||||
...(filter.status ? { status: filter.status } : {}),
|
||||
},
|
||||
relations: {
|
||||
originYard: true,
|
||||
@@ -28,10 +27,33 @@ export class RoutesService {
|
||||
milestones: { yard: true },
|
||||
},
|
||||
order: {
|
||||
name: 'ASC',
|
||||
milestones: { sequenceNo: 'ASC' },
|
||||
},
|
||||
});
|
||||
|
||||
const sorted = [...routes].sort((a, b) =>
|
||||
formatRouteLabel(a).localeCompare(formatRouteLabel(b)),
|
||||
);
|
||||
|
||||
const query = filter.search?.trim().toLowerCase();
|
||||
if (!query) return sorted;
|
||||
|
||||
return sorted.filter((route) => {
|
||||
const haystack = [
|
||||
formatRouteLabel(route),
|
||||
route.originYard?.label,
|
||||
route.originYard?.code,
|
||||
route.destinationYard?.label,
|
||||
route.destinationYard?.code,
|
||||
...(route.milestones ?? []).map(
|
||||
(m) => m.yard?.label ?? m.yard?.code ?? m.yardId,
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Route> {
|
||||
@@ -53,16 +75,14 @@ export class RoutesService {
|
||||
}
|
||||
|
||||
async create(dto: CreateRouteDto): Promise<Route> {
|
||||
await this.validateRouteName(dto.name);
|
||||
const validated = await this.validateMilestones(dto.milestones);
|
||||
|
||||
const route = await this.dataSource.transaction(async (manager) => {
|
||||
const savedRoute = await manager.getRepository(Route).save(
|
||||
manager.getRepository(Route).create({
|
||||
name: dto.name.trim(),
|
||||
originYardId: validated.originYardId,
|
||||
destinationYardId: validated.destinationYardId,
|
||||
isActive: dto.isActive ?? true,
|
||||
status: dto.status ?? 'AVAILABLE',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -72,6 +92,7 @@ export class RoutesService {
|
||||
routeId: savedRoute.id,
|
||||
yardId: milestone.yardId,
|
||||
sequenceNo: milestone.sequenceNo,
|
||||
distanceKm: milestone.distanceKm,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -85,20 +106,16 @@ export class RoutesService {
|
||||
async update(id: string, dto: UpdateRouteDto): Promise<Route> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
if (dto.name && dto.name.trim() !== existing.name) {
|
||||
await this.validateRouteName(dto.name, id);
|
||||
}
|
||||
|
||||
const milestoneInput = dto.milestones
|
||||
? await this.validateMilestones(dto.milestones)
|
||||
: null;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(Route).update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
|
||||
destinationYardId: milestoneInput?.destinationYardId ?? existing.destinationYardId,
|
||||
isActive: dto.isActive ?? existing.isActive,
|
||||
destinationYardId:
|
||||
milestoneInput?.destinationYardId ?? existing.destinationYardId,
|
||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||
});
|
||||
|
||||
if (milestoneInput) {
|
||||
@@ -109,6 +126,7 @@ export class RoutesService {
|
||||
routeId: id,
|
||||
yardId: milestone.yardId,
|
||||
sequenceNo: milestone.sequenceNo,
|
||||
distanceKm: milestone.distanceKm,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -120,7 +138,9 @@ export class RoutesService {
|
||||
|
||||
async deactivate(id: string): Promise<Route> {
|
||||
await this.findById(id);
|
||||
const updated = await this.routesRepository.update(id, { isActive: false });
|
||||
const updated = await this.routesRepository.update(id, {
|
||||
status: 'STOP_WORKING',
|
||||
} as never);
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Route ${id} not found`);
|
||||
@@ -129,27 +149,32 @@ export class RoutesService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
private async validateRouteName(name: string, routeId?: string) {
|
||||
const trimmedName = name.trim();
|
||||
const [existing] = await this.routesRepository.findAll({ where: { name: trimmedName } });
|
||||
|
||||
if (existing && existing.id !== routeId) {
|
||||
throw new ConflictException(`Route name ${trimmedName} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateMilestones(milestones: Array<{ yardId: string }>) {
|
||||
private async validateMilestones(
|
||||
milestones: Array<{ yardId: string; distanceKm?: number }>,
|
||||
) {
|
||||
if (milestones.length < 2) {
|
||||
throw new BadRequestException('A route requires at least two yards');
|
||||
}
|
||||
|
||||
const normalized = milestones.map((milestone, index) => ({
|
||||
yardId: milestone.yardId,
|
||||
sequenceNo: index + 1,
|
||||
}));
|
||||
const normalized = milestones.map((milestone, index) => {
|
||||
const distanceKm =
|
||||
index === 0 ? 0 : milestone.distanceKm != null ? milestone.distanceKm : null;
|
||||
if (index > 0 && (distanceKm == null || distanceKm < 0)) {
|
||||
throw new BadRequestException(
|
||||
`Enter segment KM for stop ${index + 1} (from previous yard).`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
yardId: milestone.yardId,
|
||||
sequenceNo: index + 1,
|
||||
distanceKm,
|
||||
};
|
||||
});
|
||||
|
||||
const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))];
|
||||
const yards = await this.dataSource.getRepository(Yard).find({ where: uniqueYardIds.map((id) => ({ id })) });
|
||||
const yards = await this.dataSource
|
||||
.getRepository(Yard)
|
||||
.find({ where: uniqueYardIds.map((id) => ({ id })) });
|
||||
const yardIds = new Set(yards.map((yard) => yard.id));
|
||||
|
||||
for (const milestone of normalized) {
|
||||
|
||||
@@ -21,11 +21,6 @@ export class CreateCargoTypeDto {
|
||||
@IsUUID()
|
||||
parentGroupId?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showFreeTextBox?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -17,9 +17,6 @@ export class CargoType extends BaseEntity {
|
||||
@Column({ name: 'parent_group_id', type: 'uuid', nullable: true })
|
||||
parentGroupId?: string | null;
|
||||
|
||||
@Column({ name: 'show_free_text_box', type: 'boolean', default: false })
|
||||
showFreeTextBox!: boolean;
|
||||
|
||||
/**
|
||||
* How this cargo's quantity is measured: PER_TON (bulk) or PER_ITEM
|
||||
* (break-bulk). Nullable for container/legacy cargo, which is counted by
|
||||
|
||||
@@ -331,16 +331,14 @@ export class RuleEngineService {
|
||||
): Promise<BookingApprovalStep[]> {
|
||||
await this.ensureDefaultApprovalRules();
|
||||
|
||||
let requiresDirectorApproval = options.freightType === 'BULK';
|
||||
let requiresDirectorApproval = false;
|
||||
|
||||
if (options.cargoTypeId) {
|
||||
const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId);
|
||||
if (!cargoType) {
|
||||
throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`);
|
||||
}
|
||||
if (cargoType.requiresDirectorApproval) {
|
||||
requiresDirectorApproval = true;
|
||||
}
|
||||
requiresDirectorApproval = cargoType.requiresDirectorApproval;
|
||||
}
|
||||
|
||||
const chain = await this.approvalRulesRepo.findChainForCargo(
|
||||
|
||||
@@ -79,7 +79,6 @@ export class CargoTypesService {
|
||||
code,
|
||||
cargoTypeName: dto.cargoTypeName,
|
||||
parentGroupId: dto.parentGroupId ?? null,
|
||||
showFreeTextBox: dto.showFreeTextBox ?? false,
|
||||
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
||||
isActive: dto.isActive ?? true,
|
||||
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||
|
||||
@@ -4,24 +4,28 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { Cron, SchedulerRegistry } from "@nestjs/schedule";
|
||||
import { DataSource } from "typeorm";
|
||||
import { Freight } from "@edr/types";
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { formatRouteLabel } from '../routes/entities/route.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import { Freight } from "@edr/types";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { BookingsRepository } from "../bookings/bookings.repository";
|
||||
import { Locomotive } from "../locomotives/entities/locomotive.entity";
|
||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||
import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity";
|
||||
import { TrainSchedulesRepository } from "../train-schedules/train-schedules.repository";
|
||||
import { TrainScheduleBookingsRepository } from "../train-schedules/train-schedule-bookings.repository";
|
||||
import { TrainSchedulingGlobalRules } from "./entities/train-scheduling-global-rules.entity";
|
||||
import { BookingNotifierService } from "./booking-notifier.service";
|
||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||
import { eatDay, groupBookingsIntoBoardWindows } from "./batch-window.util";
|
||||
|
||||
|
||||
import {
|
||||
BATCH_CRON,
|
||||
BATCH_TIMEZONE,
|
||||
@@ -34,8 +38,9 @@ import {
|
||||
bookingTrainLengthMeters,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
wagonTypeDimensionsFromEntity,
|
||||
} from "./train-capacity.util";
|
||||
import { WagonType } from "../wagon-types/entities/wagon-type.entity";
|
||||
} from './train-capacity.util';
|
||||
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. */
|
||||
interface Capacity {
|
||||
@@ -182,7 +187,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private readonly scheduler: SchedulerRegistry,
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly billing: BillingService,
|
||||
) { }
|
||||
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
|
||||
) {}
|
||||
|
||||
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
@@ -578,7 +586,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
routeName: s.route?.name ?? null,
|
||||
routeName: s.route ? formatRouteLabel(s.route) : null,
|
||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||
destination:
|
||||
s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||
@@ -662,7 +670,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
routeName: s.route?.name ?? null,
|
||||
routeName: s.route ? formatRouteLabel(s.route) : null,
|
||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||
destination:
|
||||
s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||
@@ -1102,6 +1110,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
});
|
||||
this.notifier.secured(booking, reason);
|
||||
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).
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,7 @@ import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { ContractsModule } from '../contracts/contracts.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -51,6 +52,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
||||
TrainSchedulesModule,
|
||||
forwardRef(() => WarehousesModule),
|
||||
RuleEngineModule,
|
||||
forwardRef(() => ContractsModule),
|
||||
],
|
||||
controllers: [TrainSchedulingController],
|
||||
providers: [
|
||||
|
||||
@@ -21,7 +21,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||
import { Route } from '../routes/entities/route.entity';
|
||||
import { formatRouteLabel, Route } from '../routes/entities/route.entity';
|
||||
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||
@@ -304,7 +304,7 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
|
||||
const route = await this.getActiveRoute(dto.routeId);
|
||||
const route = await this.getSchedulableRoute(dto.routeId);
|
||||
|
||||
const locomotiveIds = [...new Set(dto.locomotiveIds)];
|
||||
if (locomotiveIds.length < 2) {
|
||||
@@ -949,7 +949,7 @@ export class TrainSchedulingService {
|
||||
generatedAt: generatedAt.toISOString(),
|
||||
trainScheduleId: schedule.id,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
route: schedule.route?.name ?? null,
|
||||
route: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||
totalBookings: schedule.scheduleBookings?.length ?? 0,
|
||||
@@ -2605,13 +2605,17 @@ export class TrainSchedulingService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
private async getActiveRoute(routeId: string) {
|
||||
private async getSchedulableRoute(routeId: string) {
|
||||
const route = await this.dataSource.getRepository(Route).findOne({
|
||||
where: { id: routeId },
|
||||
relations: { originYard: true, destinationYard: true },
|
||||
});
|
||||
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
|
||||
if (!route.isActive) throw new BadRequestException(`Route ${route.name} is inactive`);
|
||||
if (route.status !== 'AVAILABLE') {
|
||||
throw new BadRequestException(
|
||||
`Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`,
|
||||
);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
@@ -2656,7 +2660,7 @@ export class TrainSchedulingService {
|
||||
id: schedule.id,
|
||||
scheduleDate: schedule.scheduledDepartureDate,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
routeName: schedule.route?.name ?? null,
|
||||
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination:
|
||||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||
@@ -2691,7 +2695,7 @@ export class TrainSchedulingService {
|
||||
|
||||
/** AVAILABLE locomotives at the route's origin yard. */
|
||||
async getAvailableLocomotivesForRoute(routeId: string): Promise<Locomotive[]> {
|
||||
const route = await this.getActiveRoute(routeId);
|
||||
const route = await this.getSchedulableRoute(routeId);
|
||||
|
||||
const locomotives = await this.locomotivesRepository.findAll({
|
||||
where: { status: 'AVAILABLE', currentYardId: route.originYardId },
|
||||
@@ -2933,7 +2937,9 @@ export class TrainSchedulingService {
|
||||
freightType: this.resolveScheduleFreightType(schedule),
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
direction: schedule.direction ?? null,
|
||||
route: schedule.route ? { id: schedule.route.id, name: schedule.route.name } : null,
|
||||
route: schedule.route
|
||||
? { id: schedule.route.id, name: formatRouteLabel(schedule.route) }
|
||||
: null,
|
||||
scheduledDepartureDate: schedule.scheduledDepartureDate,
|
||||
scheduledArrivalDate: schedule.scheduledArrivalDate,
|
||||
actualDepartureAt: schedule.actualDepartureAt ?? null,
|
||||
|
||||
@@ -468,15 +468,15 @@ export class DemoBookingsSeeder {
|
||||
const djibouti = yardByCode.get("DJIBOUTI");
|
||||
const addis = yardByCode.get("ADDIS_ABABA");
|
||||
if (djibouti && addis) {
|
||||
const routeName = "Djibouti → Addis Ababa";
|
||||
let route = await manager.getRepository(Route).findOneBy({ name: routeName });
|
||||
let route = await manager.getRepository(Route).findOne({
|
||||
where: { originYardId: djibouti.id, destinationYardId: addis.id },
|
||||
});
|
||||
if (!route) {
|
||||
route = await manager.getRepository(Route).save(
|
||||
manager.getRepository(Route).create({
|
||||
name: routeName,
|
||||
originYardId: djibouti.id,
|
||||
destinationYardId: addis.id,
|
||||
isActive: true,
|
||||
status: 'AVAILABLE',
|
||||
}),
|
||||
);
|
||||
await manager.getRepository(RouteMilestone).save([
|
||||
@@ -484,11 +484,13 @@ export class DemoBookingsSeeder {
|
||||
routeId: route.id,
|
||||
yardId: djibouti.id,
|
||||
sequenceNo: 1,
|
||||
distanceKm: 0,
|
||||
}),
|
||||
manager.getRepository(RouteMilestone).create({
|
||||
routeId: route.id,
|
||||
yardId: addis.id,
|
||||
sequenceNo: 2,
|
||||
distanceKm: 780,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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-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-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 }> = {
|
||||
@@ -149,6 +152,9 @@ export const FREIGHT_PERMS = {
|
||||
finalizeClearance: 'edr_freight_app:contracts:finalize_clearance',
|
||||
createBooking: 'edr_freight_app:contracts:create_booking',
|
||||
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: {
|
||||
view: 'edr_freight_app:train_scheduling:view',
|
||||
@@ -237,6 +243,8 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.finalizeClearance,
|
||||
FREIGHT_PERMS.contracts.createBooking,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDutyAdvise,
|
||||
FREIGHT_PERMS.bookings.clearanceView,
|
||||
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||
FREIGHT_PERMS.bookings.uploadClearanceOutput,
|
||||
@@ -247,6 +255,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
// damage reports. Read-only on the contract; no booking creation.
|
||||
glDjibouti: [
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
FREIGHT_PERMS.bookings.clearanceView,
|
||||
FREIGHT_PERMS.bookings.uploadClearanceOutput,
|
||||
FREIGHT_PERMS.bookings.operations,
|
||||
|
||||
@@ -286,15 +286,15 @@ export class PricingDataSeeder {
|
||||
|
||||
const routeRepo = manager.getRepository(Route);
|
||||
const milestoneRepo = manager.getRepository(RouteMilestone);
|
||||
const routeName = "Addis Ababa → Dire Dawa";
|
||||
let route = await routeRepo.findOneBy({ name: routeName });
|
||||
let route = await routeRepo.findOne({
|
||||
where: { originYardId: addis.id, destinationYardId: direDawa.id },
|
||||
});
|
||||
if (!route) {
|
||||
route = await routeRepo.save(
|
||||
routeRepo.create({
|
||||
name: routeName,
|
||||
originYardId: addis.id,
|
||||
destinationYardId: direDawa.id,
|
||||
isActive: true,
|
||||
status: 'AVAILABLE',
|
||||
}),
|
||||
);
|
||||
await milestoneRepo.save([
|
||||
@@ -302,11 +302,13 @@ export class PricingDataSeeder {
|
||||
routeId: route.id,
|
||||
yardId: addis.id,
|
||||
sequenceNo: 1,
|
||||
distanceKm: 0,
|
||||
}),
|
||||
milestoneRepo.create({
|
||||
routeId: route.id,
|
||||
yardId: direDawa.id,
|
||||
sequenceNo: 2,
|
||||
distanceKm: 445,
|
||||
}),
|
||||
]);
|
||||
this.logger.log("Seeded domestic route Addis Ababa → Dire Dawa");
|
||||
|
||||
@@ -15,13 +15,22 @@ import {
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Ship,
|
||||
SlidersHorizontal,
|
||||
Train,
|
||||
Truck,
|
||||
Users,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Navigate,
|
||||
Outlet,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
} from "react-router-dom";
|
||||
|
||||
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
@@ -36,8 +45,12 @@ import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPa
|
||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
|
||||
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
||||
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
|
||||
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
|
||||
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
|
||||
import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
|
||||
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
||||
import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage";
|
||||
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||
import CustomersPage from "./pages/customers/CustomersPage";
|
||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||
@@ -74,6 +87,7 @@ import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetail
|
||||
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
||||
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||
import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage";
|
||||
import FirstMilePage from "./pages/operations/FirstMilePage";
|
||||
import LastMilePage from "./pages/operations/LastMilePage";
|
||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
@@ -140,7 +154,22 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Document Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.contracts.clearanceReview,
|
||||
permission: [
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
],
|
||||
},
|
||||
// {
|
||||
// label: "Shipment Requests",
|
||||
// href: "/dashboard/shipment-requests",
|
||||
// icon: <Send />,
|
||||
// permission: FREIGHT_PERMS.contracts.createBooking,
|
||||
// },
|
||||
{
|
||||
label: "GL Djibouti Clearance",
|
||||
href: "/dashboard/gl-djibouti/clearance",
|
||||
icon: <Ship />,
|
||||
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
},
|
||||
{
|
||||
label: "Train Schedules",
|
||||
@@ -386,6 +415,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Boxes />,
|
||||
children: [
|
||||
...getCategorySidebarChildren("configuration"),
|
||||
{
|
||||
label: "Contract validity",
|
||||
href: "/dashboard/configuration/contract-validity-periods",
|
||||
},
|
||||
// {
|
||||
// label: "Train scheduling rules",
|
||||
// href: "/dashboard/configuration/train-scheduling-rules",
|
||||
@@ -502,7 +535,11 @@ const App = () => {
|
||||
/>
|
||||
<Route
|
||||
path="clearance/:id"
|
||||
element={<Navigate to="/dashboard/contracts/clearance" replace />}
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.bookings.clearanceView}>
|
||||
<DocumentClearanceDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Contracts (Path A/B) */}
|
||||
@@ -530,11 +567,40 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="bookings/:bookingId/clearance"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.bookings.clearanceView}>
|
||||
<DocumentClearanceDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="shipment-requests"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
|
||||
<ShipmentRequestsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="shipment-requests/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
|
||||
<ShipmentRequestDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* GL (Path B) contract clearance review hub */}
|
||||
<Route
|
||||
path="contracts/clearance"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
]}
|
||||
>
|
||||
<ContractClearanceListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -542,11 +608,34 @@ const App = () => {
|
||||
<Route
|
||||
path="contracts/clearance/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
]}
|
||||
>
|
||||
<ContractClearanceDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="gl-ethiopia/clearance" element={<LegacyGlEthiopiaClearanceRedirect />} />
|
||||
<Route path="gl-ethiopia/clearance/:id" element={<LegacyGlEthiopiaClearanceRedirect />} />
|
||||
<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 />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* Path A ops queue out of scope for now → fold into the GL hub. */}
|
||||
<Route
|
||||
path="contracts/ops-clearance"
|
||||
@@ -562,11 +651,7 @@ const App = () => {
|
||||
/>
|
||||
<Route
|
||||
path="bookings/:id/milestones"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
|
||||
<BookingMilestonesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
element={<BookingMilestonesRedirect />}
|
||||
/>
|
||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
@@ -902,6 +987,14 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="configuration/contract-validity-periods"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<ContractValidityPeriodsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
||||
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
|
||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
||||
@@ -930,4 +1023,21 @@ const App = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Redirect removed milestones page to document clearance. */
|
||||
function BookingMilestonesRedirect() {
|
||||
const { id } = useParams();
|
||||
return (
|
||||
<Navigate to={`/dashboard/bookings/${id}/clearance`} replace />
|
||||
);
|
||||
}
|
||||
|
||||
/** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */
|
||||
function LegacyGlEthiopiaClearanceRedirect() {
|
||||
const { id } = useParams();
|
||||
if (id) {
|
||||
return <Navigate to={`/dashboard/contracts/clearance/${id}`} replace />;
|
||||
}
|
||||
return <Navigate to="/dashboard/contracts/clearance" replace />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -65,7 +65,6 @@ export function BookingActionsMenu({
|
||||
};
|
||||
|
||||
const hasMenu = listRowHasActions(row, user);
|
||||
const primary = actions.find((a) => a.primary) ?? actions[0];
|
||||
|
||||
if (!hasMenu && variant === "table") {
|
||||
return (
|
||||
@@ -117,19 +116,6 @@ export function BookingActionsMenu({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{variant === "table" && primary && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
visibleFrom="lg"
|
||||
leftSection={<primary.icon size={14} />}
|
||||
disabled={mutations.isPending}
|
||||
onClick={() => handleAction(primary)}
|
||||
>
|
||||
{primary.shortLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Menu position="bottom-end" width={220} withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
|
||||
@@ -41,6 +41,12 @@ export interface ClearanceReviewSectionProps {
|
||||
onChanged?: () => void;
|
||||
/** Hide the inline progress summary (e.g. when the parent renders its own). */
|
||||
hideSummary?: boolean;
|
||||
/** Lock approve actions after document review phase completes. */
|
||||
approvalsLocked?: boolean;
|
||||
/** Block new queries after pre-clearance finalization. */
|
||||
queriesLocked?: boolean;
|
||||
/** Read-only audit view — no approve/query actions. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_META: Record<
|
||||
@@ -64,6 +70,9 @@ export function ClearanceReviewSection({
|
||||
bookingId,
|
||||
onChanged,
|
||||
hideSummary,
|
||||
approvalsLocked = false,
|
||||
queriesLocked = false,
|
||||
readOnly = false,
|
||||
}: ClearanceReviewSectionProps) {
|
||||
const qc = useQueryClient();
|
||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||
@@ -147,6 +156,11 @@ export function ClearanceReviewSection({
|
||||
return { total, approved, queried, pending, pct };
|
||||
}, [customerDocs]);
|
||||
|
||||
const hasDocsAwaitingApproval = customerDocs.some(
|
||||
(d) => d.file && d.reviewStatus !== "APPROVED",
|
||||
);
|
||||
const effectiveApprovalsLocked = approvalsLocked && !hasDocsAwaitingApproval;
|
||||
|
||||
if (isLoading || !clearance) {
|
||||
return (
|
||||
<Group justify="center" py="xl" gap={10}>
|
||||
@@ -194,6 +208,9 @@ export function ClearanceReviewSection({
|
||||
<DocReviewCard
|
||||
key={`${doc.settingCode}:${doc.fileKey}`}
|
||||
doc={doc}
|
||||
approvalsLocked={effectiveApprovalsLocked}
|
||||
queriesLocked={queriesLocked}
|
||||
readOnly={readOnly}
|
||||
note={queryNotes[doc.fileKey] ?? ""}
|
||||
queryOpen={openQuery[doc.fileKey] ?? false}
|
||||
onToggleQuery={(open) =>
|
||||
@@ -397,6 +414,9 @@ function StatPill({
|
||||
|
||||
function DocReviewCard({
|
||||
doc,
|
||||
approvalsLocked,
|
||||
queriesLocked,
|
||||
readOnly,
|
||||
note,
|
||||
queryOpen,
|
||||
onToggleQuery,
|
||||
@@ -407,6 +427,9 @@ function DocReviewCard({
|
||||
busy,
|
||||
}: {
|
||||
doc: Freight.ClearanceDocument;
|
||||
approvalsLocked: boolean;
|
||||
queriesLocked: boolean;
|
||||
readOnly: boolean;
|
||||
note: string;
|
||||
queryOpen: boolean;
|
||||
onToggleQuery: (open: boolean) => void;
|
||||
@@ -419,6 +442,7 @@ function DocReviewCard({
|
||||
const status = doc.reviewStatus ?? "PENDING";
|
||||
const meta = STATUS_META[status];
|
||||
const hasFile = !!doc.file;
|
||||
const isApproved = status === "APPROVED";
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -499,31 +523,35 @@ function DocReviewCard({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasFile && (
|
||||
{hasFile && !readOnly && (
|
||||
<Box mt="sm">
|
||||
{!queryOpen ? (
|
||||
<Group justify="flex-end" gap={8}>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<MessageSquareWarning size={14} />}
|
||||
disabled={busy}
|
||||
onClick={() => onToggleQuery(true)}
|
||||
>
|
||||
Open query
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
disabled={busy}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
{!queriesLocked && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<MessageSquareWarning size={14} />}
|
||||
disabled={busy}
|
||||
onClick={() => onToggleQuery(true)}
|
||||
>
|
||||
Open query
|
||||
</Button>
|
||||
)}
|
||||
{!isApproved && !approvalsLocked && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
disabled={busy}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
) : (
|
||||
<Box
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { AlertTriangle, FileText, ShieldAlert } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard";
|
||||
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
|
||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||
|
||||
export interface ClearanceOpsTabsProps {
|
||||
bookingId: string | undefined;
|
||||
milestones?: Freight.IClearanceMilestone[];
|
||||
/** When false, only the clearance tab content is rendered (no tab bar). */
|
||||
showOpsTabs?: boolean;
|
||||
clearanceTab: ReactNode;
|
||||
/** Phased customs workflow files — enables the Uploaded documents tab. */
|
||||
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||
showWorkflowFilesTab?: boolean;
|
||||
tradeDirection?: string;
|
||||
onViewFile?: (file: { name: string; url: string }) => void;
|
||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||
}
|
||||
|
||||
function findMilestone(
|
||||
milestones: Freight.IClearanceMilestone[] | undefined,
|
||||
code: string,
|
||||
): Freight.IClearanceMilestone | undefined {
|
||||
return milestones?.find((m) => m.milestoneCode === code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Document Clearance detail layout: primary clearance workflow plus optional
|
||||
* uploaded documents, post-booking risk assignment, and incident reporting tabs.
|
||||
*/
|
||||
export function ClearanceOpsTabs({
|
||||
bookingId,
|
||||
milestones,
|
||||
showOpsTabs = true,
|
||||
clearanceTab,
|
||||
workflowFiles = [],
|
||||
showWorkflowFilesTab = false,
|
||||
tradeDirection = "IMPORT",
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
}: ClearanceOpsTabsProps) {
|
||||
const riskMs = findMilestone(milestones, "RISK_ASSIGNED");
|
||||
const hasOps = Boolean(bookingId);
|
||||
const isExport = tradeDirection === "EXPORT";
|
||||
const uploadedDocCount = workflowFiles.filter((f) => {
|
||||
if (!f.file) return false;
|
||||
if (isExport) return f.category !== "duty";
|
||||
return true;
|
||||
}).length;
|
||||
const showDocuments = showWorkflowFilesTab && Boolean(onViewFile);
|
||||
const hasTabs = (showOpsTabs && hasOps) || showDocuments;
|
||||
|
||||
if (!hasTabs) {
|
||||
return <>{clearanceTab}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="clearance" keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="clearance">Clearance</Tabs.Tab>
|
||||
{showDocuments ? (
|
||||
<Tabs.Tab
|
||||
value="documents"
|
||||
leftSection={<FileText size={14} />}
|
||||
rightSection={
|
||||
uploadedDocCount > 0 ? (
|
||||
<Badge size="xs" variant="light" color="edr-green" circle>
|
||||
{uploadedDocCount}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Uploaded documents
|
||||
</Tabs.Tab>
|
||||
) : null}
|
||||
{showOpsTabs && riskMs ? (
|
||||
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
|
||||
Risk assignment
|
||||
</Tabs.Tab>
|
||||
) : null}
|
||||
{showOpsTabs && bookingId ? (
|
||||
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
|
||||
Incidents
|
||||
</Tabs.Tab>
|
||||
) : null}
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="clearance">{clearanceTab}</Tabs.Panel>
|
||||
|
||||
{showDocuments ? (
|
||||
<Tabs.Panel value="documents">
|
||||
<ClearanceUploadedDocumentsPanel
|
||||
files={workflowFiles}
|
||||
tradeDirection={tradeDirection}
|
||||
onView={onViewFile!}
|
||||
onDownload={onDownloadFile}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
) : null}
|
||||
|
||||
{showOpsTabs && riskMs && bookingId ? (
|
||||
<Tabs.Panel value="risk">
|
||||
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">
|
||||
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
|
||||
</SectionCard>
|
||||
</Tabs.Panel>
|
||||
) : null}
|
||||
|
||||
{showOpsTabs && bookingId ? (
|
||||
<Tabs.Panel value="incidents">
|
||||
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
Log container or seal issues discovered during clearance handling.
|
||||
</Text>
|
||||
<IncidentReportCard bookingId={bookingId} />
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Tabs.Panel>
|
||||
) : null}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -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 IMPORT_PHASES = [
|
||||
"CUSTOMER_INTAKE",
|
||||
"GL_ET_REVIEW",
|
||||
"GL_ET_OUTPUT",
|
||||
"CUSTOMER_DUTY",
|
||||
"GL_ET_POST_CLEARANCE",
|
||||
"GL_DJ_COLLECTION",
|
||||
] as const;
|
||||
|
||||
const PHASE_LABELS: Record<string, string> = {
|
||||
CUSTOMER_INTAKE: "Customer docs",
|
||||
GL_ET_REVIEW: "GL ET review",
|
||||
GL_DJ_COLLECTION: "GL Djibouti DO",
|
||||
GL_ET_OUTPUT: "Declaration",
|
||||
CUSTOMER_DUTY: "Duty / customer pays",
|
||||
GL_ET_POST_CLEARANCE: "Transit & finalize",
|
||||
GL_DJ_LOADING: "Loading",
|
||||
POST_TRANSIT: "Transit",
|
||||
};
|
||||
|
||||
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 | Freight.ClearanceView | 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,205 @@
|
||||
import { useMemo } from "react";
|
||||
import { Badge, Box, Stack, Tabs, Text, ThemeIcon } from "@mantine/core";
|
||||
import { FileText, Receipt, Ship, Truck } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { PhasedUploadedFileRow } from "@/components/contracts/PhasedUploadedFileRow";
|
||||
|
||||
type TabValue = Freight.ClearanceWorkflowFileCategory;
|
||||
|
||||
type TabConfig = {
|
||||
value: TabValue;
|
||||
label: string;
|
||||
icon: typeof FileText;
|
||||
emptyHint: string;
|
||||
};
|
||||
|
||||
function tabConfigForTradeDirection(tradeDirection: string): TabConfig[] {
|
||||
if (tradeDirection === "EXPORT") {
|
||||
return [
|
||||
{
|
||||
value: "declaration",
|
||||
label: "Declaration",
|
||||
icon: FileText,
|
||||
emptyHint: "No declaration uploaded yet.",
|
||||
},
|
||||
{
|
||||
value: "djibouti",
|
||||
label: "Release order",
|
||||
icon: Ship,
|
||||
emptyHint: "No release order uploaded yet.",
|
||||
},
|
||||
{
|
||||
value: "transit",
|
||||
label: "Transit Permit",
|
||||
icon: Truck,
|
||||
emptyHint: "No transit permit uploaded yet.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
value: "declaration",
|
||||
label: "Declaration",
|
||||
icon: FileText,
|
||||
emptyHint: "No declaration uploaded yet.",
|
||||
},
|
||||
{
|
||||
value: "duty",
|
||||
label: "Duty notice",
|
||||
icon: Receipt,
|
||||
emptyHint: "No duty notice or payment slip uploaded yet.",
|
||||
},
|
||||
{
|
||||
value: "transit",
|
||||
label: "Transit permit",
|
||||
icon: Truck,
|
||||
emptyHint: "No transit permit uploaded yet.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function subtitleForTradeDirection(tradeDirection: string): string {
|
||||
return tradeDirection === "EXPORT"
|
||||
? "Declaration, release order, and transit permit files for this clearance."
|
||||
: "Declaration, duty notice, and transit permit files for this clearance.";
|
||||
}
|
||||
|
||||
function footerHintForTradeDirection(tradeDirection: string): string {
|
||||
return tradeDirection === "EXPORT"
|
||||
? "Files appear here once GL uploads the declaration and release order, and after booking when the transit permit is uploaded."
|
||||
: "Files appear here once GL Ethiopia uploads declaration, duty notice, or transit permit documents.";
|
||||
}
|
||||
|
||||
export interface ClearanceUploadedDocumentsPanelProps {
|
||||
files: Freight.ClearanceWorkflowFile[];
|
||||
tradeDirection?: string;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
onDownload?: (file: { id: string; name: string }) => void;
|
||||
}
|
||||
|
||||
export function ClearanceUploadedDocumentsPanel({
|
||||
files,
|
||||
tradeDirection = "IMPORT",
|
||||
onView,
|
||||
onDownload,
|
||||
}: ClearanceUploadedDocumentsPanelProps) {
|
||||
const tabConfig = useMemo(
|
||||
() => tabConfigForTradeDirection(tradeDirection),
|
||||
[tradeDirection],
|
||||
);
|
||||
const isExport = tradeDirection === "EXPORT";
|
||||
|
||||
const visibleFiles = useMemo(
|
||||
() =>
|
||||
isExport ? files.filter((f) => f.category !== "duty") : files,
|
||||
[files, isExport],
|
||||
);
|
||||
|
||||
const uploadedCount = visibleFiles.filter((f) => f.file).length;
|
||||
|
||||
const defaultTab =
|
||||
tabConfig.find((tab) =>
|
||||
visibleFiles.some((f) => f.category === tab.value && f.file),
|
||||
)?.value ?? tabConfig[0]?.value ?? "declaration";
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={FileText}
|
||||
title="Uploaded customs documents"
|
||||
subtitle={subtitleForTradeDirection(tradeDirection)}
|
||||
accent="edr-green"
|
||||
>
|
||||
<Tabs defaultValue={defaultTab} keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
{tabConfig.map((tab) => {
|
||||
const count = visibleFiles.filter(
|
||||
(f) => f.category === tab.value && f.file,
|
||||
).length;
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
leftSection={<Icon size={14} />}
|
||||
rightSection={
|
||||
count > 0 ? (
|
||||
<Badge size="xs" variant="light" color="edr-green" circle>
|
||||
{count}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
|
||||
{tabConfig.map((tab) => {
|
||||
const items = visibleFiles.filter(
|
||||
(f) => f.category === tab.value && f.file,
|
||||
);
|
||||
const Icon = tab.icon;
|
||||
|
||||
return (
|
||||
<Tabs.Panel key={tab.value} value={tab.value}>
|
||||
{items.length > 0 ? (
|
||||
<Stack gap="sm">
|
||||
{items.map((item) => (
|
||||
<PhasedUploadedFileRow
|
||||
key={item.code}
|
||||
label={item.label}
|
||||
file={item.file!}
|
||||
onView={onView}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<EmptyTabState icon={Icon} hint={tab.emptyHint} />
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
|
||||
{uploadedCount === 0 ? (
|
||||
<Text size="xs" c="dimmed" mt="md">
|
||||
{footerHintForTradeDirection(tradeDirection)}
|
||||
</Text>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyTabState({
|
||||
icon: Icon,
|
||||
hint,
|
||||
}: {
|
||||
icon: typeof FileText;
|
||||
hint: string;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
py={40}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: "1px dashed var(--mantine-color-gray-4)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Stack gap={8} align="center">
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={44}>
|
||||
<Icon size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed" maw={320}>
|
||||
{hint}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Download, Eye, FileText } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
|
||||
const CATEGORY_LABELS: Record<
|
||||
Freight.ClearanceWorkflowFileCategory,
|
||||
string
|
||||
> = {
|
||||
declaration: "Declaration",
|
||||
duty: "Duty & taxes",
|
||||
transit: "Transit",
|
||||
djibouti: "Djibouti",
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [
|
||||
"declaration",
|
||||
"duty",
|
||||
"transit",
|
||||
"djibouti",
|
||||
];
|
||||
|
||||
const OWNER_LABELS: Record<Freight.ClearanceWorkflowFileOwner, string> = {
|
||||
customer: "Customer",
|
||||
gl_et: "GL Ethiopia",
|
||||
gl_dj: "GL Djibouti",
|
||||
};
|
||||
|
||||
export interface ClearanceWorkflowFilesPanelProps {
|
||||
files: Freight.ClearanceWorkflowFile[];
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
onDownload?: (file: { id: string; name: string }) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function ClearanceWorkflowFilesPanel({
|
||||
files,
|
||||
onView,
|
||||
onDownload,
|
||||
title = "Customs workflow documents",
|
||||
}: ClearanceWorkflowFilesPanelProps) {
|
||||
if (files.length === 0) return null;
|
||||
|
||||
const grouped = CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
label: CATEGORY_LABELS[category],
|
||||
items: files.filter((f) => f.category === category),
|
||||
})).filter((g) => g.items.length > 0);
|
||||
|
||||
return (
|
||||
<SectionCard icon={FileText} title={title} accent="edr-green">
|
||||
<Stack gap="md">
|
||||
{grouped.map((group) => (
|
||||
<Box key={group.category}>
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb={8}>
|
||||
{group.label}
|
||||
</Text>
|
||||
<Stack gap={8}>
|
||||
{group.items.map((item) => (
|
||||
<WorkflowFileRow
|
||||
key={item.code}
|
||||
item={item}
|
||||
onView={onView}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowFileRow({
|
||||
item,
|
||||
onView,
|
||||
onDownload,
|
||||
}: {
|
||||
item: Freight.ClearanceWorkflowFile;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
onDownload?: (file: { id: string; name: string }) => void;
|
||||
}) {
|
||||
const file = item.file;
|
||||
if (!file) return null;
|
||||
|
||||
const viewUrl = fileViewUrl(file.id);
|
||||
const canPreview = isViewable({ name: file.name, url: viewUrl });
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Group justify="space-between" wrap="nowrap" align="center">
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={36}>
|
||||
<FileText size={17} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap" mt={2}>
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm" tt="none">
|
||||
{OWNER_LABELS[item.uploadedBy]}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{file.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{canPreview ? (
|
||||
<Tooltip label="Preview">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() => onView({ name: file.name, url: viewUrl })}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{onDownload ? (
|
||||
<Tooltip label="Download">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<Download size={13} />}
|
||||
onClick={() => onDownload({ id: file.id, name: file.name })}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, ShieldCheck } from "lucide-react";
|
||||
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
Badge,
|
||||
Button,
|
||||
Box,
|
||||
Modal,
|
||||
} from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||
@@ -19,6 +27,10 @@ export function ContractApprovalStepsCard({
|
||||
contract,
|
||||
mutations,
|
||||
}: ContractApprovalStepsCardProps) {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingStep, setPendingStep] =
|
||||
useState<Freight.IContractApprovalStep | null>(null);
|
||||
|
||||
const steps = useMemo(
|
||||
() =>
|
||||
[...(contract.approvalSteps ?? [])].sort(
|
||||
@@ -30,6 +42,24 @@ export function ContractApprovalStepsCard({
|
||||
const nextPending = steps.find((s) => s.status === "PENDING");
|
||||
const summary = formatContractApprovalProgress(contract.status, steps);
|
||||
|
||||
const openApprove = (step: Freight.IContractApprovalStep) => {
|
||||
setPendingStep(step);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const closeApprove = () => {
|
||||
setConfirmOpen(false);
|
||||
setPendingStep(null);
|
||||
};
|
||||
|
||||
const runApprove = () => {
|
||||
if (!pendingStep) return;
|
||||
mutations.approveStep.mutate(
|
||||
{ stepId: pendingStep.id, requiredRole: pendingStep.requiredRole },
|
||||
{ onSuccess: () => closeApprove() },
|
||||
);
|
||||
};
|
||||
|
||||
const subtitle =
|
||||
summary.detail ||
|
||||
(nextPending
|
||||
@@ -39,54 +69,87 @@ export function ContractApprovalStepsCard({
|
||||
: "Accept submission to begin");
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={ShieldCheck}
|
||||
title="Approval chain"
|
||||
extra={
|
||||
<Badge color="edr-green" variant="light" radius="sm">
|
||||
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{subtitle}
|
||||
</Text>
|
||||
|
||||
{steps.length === 0 ? (
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
ta="center"
|
||||
py="lg"
|
||||
px="md"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px dashed var(--mantine-color-gray-3)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
Use <strong>Accept for approval</strong> in staff actions to
|
||||
instantiate steps.
|
||||
<>
|
||||
<SectionCard
|
||||
icon={ShieldCheck}
|
||||
title="Approval chain"
|
||||
extra={
|
||||
<Badge color="edr-green" variant="light" radius="sm">
|
||||
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{steps.map((step) => (
|
||||
<StepRow
|
||||
key={step.id}
|
||||
step={step}
|
||||
isNext={nextPending?.id === step.id}
|
||||
isPending={mutations.approveStep.isPending}
|
||||
onApprove={() =>
|
||||
mutations.approveStep.mutate({
|
||||
stepId: step.id,
|
||||
requiredRole: step.requiredRole,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{steps.length === 0 ? (
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
ta="center"
|
||||
py="lg"
|
||||
px="md"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px dashed var(--mantine-color-gray-3)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
Use <strong>Accept for approval</strong> in staff actions to
|
||||
instantiate steps.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{steps.map((step) => (
|
||||
<StepRow
|
||||
key={step.id}
|
||||
step={step}
|
||||
isNext={nextPending?.id === step.id}
|
||||
isPending={mutations.approveStep.isPending}
|
||||
onApprove={() => openApprove(step)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<Modal
|
||||
opened={confirmOpen}
|
||||
onClose={closeApprove}
|
||||
title="Approve this step?"
|
||||
radius="md"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
You are about to approve the{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{pendingStep?.requiredRole}
|
||||
</Text>{" "}
|
||||
step for contract{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{contract.reference}
|
||||
</Text>
|
||||
. This action cannot be undone from this screen.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={closeApprove}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Check size={16} />}
|
||||
loading={mutations.approveStep.isPending}
|
||||
onClick={runApprove}
|
||||
>
|
||||
Confirm approval
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,21 @@ export interface ContractClearanceReviewSectionProps {
|
||||
* by whom, when) but hide all approve / query / finalize actions.
|
||||
*/
|
||||
readOnly?: boolean;
|
||||
/**
|
||||
* Document approvals are locked (e.g. after all docs approved in phased flow)
|
||||
* but queries remain available until {@link queriesLocked} or {@link readOnly}.
|
||||
*/
|
||||
approvalsLocked?: boolean;
|
||||
/**
|
||||
* Pre-clearance finalized — block opening new queries on customer documents.
|
||||
*/
|
||||
queriesLocked?: boolean;
|
||||
/**
|
||||
* ONE_TIME customs contracts use the phased milestone workflow. Hides the
|
||||
* legacy "Finalize clearance" shortcut; booking readiness follows delivery
|
||||
* order (import) or export release.
|
||||
*/
|
||||
phasedCustoms?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_META: Record<
|
||||
@@ -89,6 +104,9 @@ export function ContractClearanceReviewSection({
|
||||
hideSummary,
|
||||
selfClear = false,
|
||||
readOnly = false,
|
||||
phasedCustoms = false,
|
||||
approvalsLocked = false,
|
||||
queriesLocked = false,
|
||||
}: ContractClearanceReviewSectionProps) {
|
||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
||||
@@ -137,6 +155,11 @@ export function ContractClearanceReviewSection({
|
||||
.filter((d) => d.file && d.reviewStatus !== "APPROVED")
|
||||
.map((d) => d.fileKey);
|
||||
|
||||
const hasDocsAwaitingApproval = customerDocs.some(
|
||||
(d) => d.file && d.reviewStatus !== "APPROVED",
|
||||
);
|
||||
const effectiveApprovalsLocked = approvalsLocked && !hasDocsAwaitingApproval;
|
||||
|
||||
if (isLoading || !clearance) {
|
||||
return (
|
||||
<Group justify="center" py="xl" gap={10}>
|
||||
@@ -170,14 +193,16 @@ export function ContractClearanceReviewSection({
|
||||
subtitle={
|
||||
readOnly
|
||||
? `Reviewed by the ${reviewerTeam} team.`
|
||||
: "Approve each document, or open a query to tell the customer what to fix."
|
||||
: effectiveApprovalsLocked
|
||||
? "Documents are approved — you can still open a query if something needs fixing."
|
||||
: "Approve each document, or open a query to tell the customer what to fix."
|
||||
}
|
||||
extra={
|
||||
<Group gap={10} wrap="nowrap" align="center">
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
{stats.approved}/{stats.total} approved
|
||||
</Text>
|
||||
{!readOnly && approvableKeys.length > 0 && (
|
||||
{!readOnly && !effectiveApprovalsLocked && approvableKeys.length > 0 && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
@@ -221,6 +246,8 @@ export function ContractClearanceReviewSection({
|
||||
doc={doc}
|
||||
reviewerTeam={reviewerTeam}
|
||||
readOnly={readOnly}
|
||||
approvalsLocked={effectiveApprovalsLocked}
|
||||
queriesLocked={queriesLocked}
|
||||
note={queryNotes[doc.fileKey] ?? ""}
|
||||
queryOpen={openQuery[doc.fileKey] ?? false}
|
||||
onToggleQuery={(open) =>
|
||||
@@ -241,7 +268,7 @@ export function ContractClearanceReviewSection({
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
{glDocs.length > 0 && (
|
||||
{glDocs.length > 0 && !phasedCustoms && (
|
||||
<SectionCard
|
||||
icon={Upload}
|
||||
title="GL output documents"
|
||||
@@ -372,8 +399,42 @@ export function ContractClearanceReviewSection({
|
||||
<CheckCircle2 size={15} />
|
||||
</ThemeIcon>
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
Clearance was finalized by the {reviewerTeam} team. This is a
|
||||
read-only record of the approved documents.
|
||||
{phasedCustoms
|
||||
? "Document review is complete. Continue customs milestones in the action panel."
|
||||
: `Clearance was finalized by the ${reviewerTeam} team. This is a read-only record of the approved documents.`}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : phasedCustoms && effectiveApprovalsLocked ? (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={28}>
|
||||
<CheckCircle2 size={15} />
|
||||
</ThemeIcon>
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
Document review is complete. Use the action panel for declaration, duty, and
|
||||
transit steps
|
||||
{queriesLocked
|
||||
? ". Pre-clearance is finalized — customer documents can no longer be queried."
|
||||
: " — or open a query above if a customer document needs correction."}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : phasedCustoms ? (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={clearance.allApproved ? "edr-green" : "gray"}
|
||||
radius="md"
|
||||
size={28}
|
||||
>
|
||||
<FileCheck2 size={15} />
|
||||
</ThemeIcon>
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
{clearance.allApproved
|
||||
? "All required documents are approved. Continue declaration, duty, and transit in the action panel."
|
||||
: "Approve every required document to unlock the customs milestone steps."}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
@@ -450,6 +511,8 @@ function DocReviewCard({
|
||||
doc,
|
||||
reviewerTeam,
|
||||
readOnly,
|
||||
approvalsLocked,
|
||||
queriesLocked,
|
||||
note,
|
||||
queryOpen,
|
||||
onToggleQuery,
|
||||
@@ -462,6 +525,8 @@ function DocReviewCard({
|
||||
doc: Freight.ContractClearanceDocument;
|
||||
reviewerTeam: string;
|
||||
readOnly: boolean;
|
||||
approvalsLocked: boolean;
|
||||
queriesLocked: boolean;
|
||||
note: string;
|
||||
queryOpen: boolean;
|
||||
onToggleQuery: (open: boolean) => void;
|
||||
@@ -583,31 +648,35 @@ function DocReviewCard({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!readOnly && hasFile && !isApproved && (
|
||||
{!readOnly && hasFile && (
|
||||
<Box mt="sm">
|
||||
{!queryOpen ? (
|
||||
<Group justify="flex-end" gap={8}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<MessageSquareWarning size={15} />}
|
||||
disabled={busy}
|
||||
onClick={() => onToggleQuery(true)}
|
||||
>
|
||||
Open query
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={15} />}
|
||||
disabled={busy}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
{!queriesLocked && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<MessageSquareWarning size={15} />}
|
||||
disabled={busy}
|
||||
onClick={() => onToggleQuery(true)}
|
||||
>
|
||||
Open query
|
||||
</Button>
|
||||
)}
|
||||
{!isApproved && !approvalsLocked && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={15} />}
|
||||
disabled={busy}
|
||||
onClick={onApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
) : (
|
||||
<Box
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Button, Group, Modal, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
|
||||
export interface ContractSignSuccessModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
reference: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
}
|
||||
|
||||
export function ContractSignSuccessModal({
|
||||
opened,
|
||||
onClose,
|
||||
reference,
|
||||
message = "The contract has been signed and recorded.",
|
||||
confirmLabel = "Back to contract request",
|
||||
}: ContractSignSuccessModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Contract signed successfully"
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md" align="center" ta="center">
|
||||
<ThemeIcon size={56} radius="xl" color="edr-green" variant="light">
|
||||
<CheckCircle2 size={28} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600}>{reference}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{message}
|
||||
</Text>
|
||||
<Group justify="center" mt="xs">
|
||||
<Button color="edr-green" onClick={onClose}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { Ship, Upload } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
export type GlClearanceUploadKind = "do" | "ro";
|
||||
|
||||
export interface GlClearanceUploadModalProps {
|
||||
opened: boolean;
|
||||
kind: GlClearanceUploadKind | null;
|
||||
onClose: () => void;
|
||||
entityId: string;
|
||||
isBooking: boolean;
|
||||
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||
vesselDepartureDate?: string | null;
|
||||
onSuccess?: () => void;
|
||||
onPreview?: (file: { name: string; url: string }) => void;
|
||||
}
|
||||
|
||||
export function GlClearanceUploadModal({
|
||||
opened,
|
||||
kind,
|
||||
onClose,
|
||||
entityId,
|
||||
isBooking,
|
||||
workflowFiles = [],
|
||||
vesselDepartureDate,
|
||||
onSuccess,
|
||||
onPreview,
|
||||
}: GlClearanceUploadModalProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [vesselDate, setVesselDate] = useState<Date | null>(
|
||||
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isDo = kind === "do";
|
||||
const isRo = kind === "ro";
|
||||
const replaceMode = isDo
|
||||
? Boolean(findWorkflowFile(workflowFiles, "delivery_order"))
|
||||
: Boolean(findWorkflowFile(workflowFiles, "release_order"));
|
||||
|
||||
const close = () => {
|
||||
setFile(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!file || !kind) return;
|
||||
if (isRo && !vesselDate) {
|
||||
toast.error("Vessel departure date is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isDo) {
|
||||
if (isBooking) {
|
||||
await bookingsService.uploadDeliveryOrder(entityId, file);
|
||||
} else {
|
||||
await contractsService.uploadDeliveryOrder(entityId, file);
|
||||
}
|
||||
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
|
||||
} else {
|
||||
const iso = vesselDate!.toISOString().slice(0, 10);
|
||||
const result = isBooking
|
||||
? await bookingsService.uploadReleaseOrder(entityId, file, iso)
|
||||
: await contractsService.uploadReleaseOrder(entityId, file, iso);
|
||||
if (result.hold) {
|
||||
toast.error(result.holdReason ?? "Vessel date too soon");
|
||||
} else {
|
||||
toast.success(replaceMode ? "Release Order updated" : "Release Order uploaded");
|
||||
}
|
||||
}
|
||||
setFile(null);
|
||||
onSuccess?.();
|
||||
close();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened && kind != null}
|
||||
onClose={close}
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<Ship size={18} />
|
||||
<Text fw={700}>{isDo ? "Upload Delivery Order" : "Upload Release Order"}</Text>
|
||||
</Group>
|
||||
}
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{isDo
|
||||
? "Upload the Djibouti Delivery Order (DO) for this import shipment."
|
||||
: "Upload the Release Order and confirm the vessel departure date."}
|
||||
</Text>
|
||||
|
||||
{isRo ? (
|
||||
<DateInput
|
||||
label="Vessel departure date"
|
||||
value={vesselDate}
|
||||
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<PhasedFileDropzone
|
||||
label={isDo ? "Delivery Order file" : "Release Order file"}
|
||||
description="PDF or image."
|
||||
value={file}
|
||||
onChange={setFile}
|
||||
replaceMode={replaceMode}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={close} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={!file || (isRo && !vesselDate)}
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{replaceMode
|
||||
? isDo
|
||||
? "Replace DO"
|
||||
: "Replace RO"
|
||||
: isDo
|
||||
? "Upload DO"
|
||||
: "Upload RO"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { FileText, Upload } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { PhasedUploadedFileRow, findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
||||
|
||||
export interface PhasedDocumentUploadFieldProps {
|
||||
fields: Array<{ key: string; label: string }>;
|
||||
files: Record<string, File | null>;
|
||||
onChange: (key: string, file: File | null) => void;
|
||||
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||
helperText: string;
|
||||
replaceMode?: boolean;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
submitLabel?: string;
|
||||
onSubmit: () => void;
|
||||
onViewFile?: (file: { name: string; url: string }) => void;
|
||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||
}
|
||||
|
||||
/** Consistent phased customs document upload with drag-and-drop, preview, and uploaded rows. */
|
||||
export function PhasedDocumentUploadField({
|
||||
fields,
|
||||
files,
|
||||
onChange,
|
||||
workflowFiles = [],
|
||||
helperText,
|
||||
replaceMode = false,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
submitLabel,
|
||||
onSubmit,
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
}: PhasedDocumentUploadFieldProps) {
|
||||
const hasStaged = Object.values(files).some(Boolean);
|
||||
const uploaded = fields
|
||||
.map((f) => ({ ...f, file: findWorkflowFile(workflowFiles, f.key) }))
|
||||
.filter((f) => f.file);
|
||||
const multiField = fields.length > 1;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{uploaded.length > 0 ? (
|
||||
<Stack gap={8}>
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
|
||||
Current file{uploaded.length > 1 ? "s" : ""}
|
||||
</Text>
|
||||
{uploaded.map((row) => (
|
||||
<PhasedUploadedFileRow
|
||||
key={row.key}
|
||||
label={row.label}
|
||||
file={row.file!}
|
||||
onView={onViewFile}
|
||||
onDownload={onDownloadFile}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
|
||||
<Group gap={8} mb="sm" wrap="nowrap">
|
||||
<Box
|
||||
c="edr-green"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "var(--mantine-color-edr-green-1)",
|
||||
}}
|
||||
>
|
||||
<FileText size={16} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="sm" fw={700}>
|
||||
{replaceMode ? "Replace document" : "Upload document"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{helperText}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Stack gap="md">
|
||||
{fields.map((f) => (
|
||||
<PhasedFileDropzone
|
||||
key={f.key}
|
||||
label={multiField ? f.label : "Choose file"}
|
||||
description={
|
||||
multiField
|
||||
? uploaded.some((u) => u.key === f.key)
|
||||
? "Drop a new file to replace the current one."
|
||||
: `Upload ${f.label} (optional if another declaration type is provided).`
|
||||
: undefined
|
||||
}
|
||||
value={files[f.key] ?? null}
|
||||
onChange={(file) => onChange(f.key, file)}
|
||||
replaceMode={replaceMode || uploaded.some((u) => u.key === f.key)}
|
||||
onPreview={onViewFile}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={disabled || !hasStaged}
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={onSubmit}
|
||||
fullWidth
|
||||
>
|
||||
{submitLabel ?? (replaceMode ? "Replace document" : "Upload document")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { Eye, FileText, Trash2, UploadCloud } from "lucide-react";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
export interface PhasedFileDropzoneProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: File | null;
|
||||
onChange: (file: File | null) => void;
|
||||
accept?: string;
|
||||
replaceMode?: boolean;
|
||||
onPreview?: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function isImageFile(file: File): boolean {
|
||||
if (file.type.startsWith("image/")) return true;
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||
return ["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg"].includes(ext);
|
||||
}
|
||||
|
||||
export function PhasedFileDropzone({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
accept = "application/pdf,image/*",
|
||||
replaceMode = false,
|
||||
onPreview,
|
||||
disabled = false,
|
||||
}: PhasedFileDropzoneProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const previewUrl = useMemo(
|
||||
() => (value ? URL.createObjectURL(value) : null),
|
||||
[value],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
const pickFile = (file: File | null) => {
|
||||
if (disabled) return;
|
||||
onChange(file);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
if (disabled) return;
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) pickFile(file);
|
||||
};
|
||||
|
||||
if (value && previewUrl) {
|
||||
const canPreview = onPreview && isViewable({ name: value.name, url: previewUrl, mimeType: value.type });
|
||||
const image = isImageFile(value);
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: "1px solid var(--mantine-color-edr-green-4)",
|
||||
background:
|
||||
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #fff 70%)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" align="center">
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{image ? (
|
||||
<UnstyledButton
|
||||
onClick={() =>
|
||||
canPreview && onPreview?.({ name: value.name, url: previewUrl, mimeType: value.type })
|
||||
}
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
flexShrink: 0,
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
border: "1px solid var(--mantine-color-gray-3)",
|
||||
cursor: canPreview ? "pointer" : "default",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt=""
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
) : (
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
|
||||
<FileText size={22} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={700} c="edr-green" tt="uppercase">
|
||||
Ready to upload
|
||||
</Text>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{value.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatBytes(value.size)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{canPreview ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() =>
|
||||
onPreview({ name: value.name, url: previewUrl, mimeType: value.type })
|
||||
}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
) : null}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
radius="md"
|
||||
aria-label="Remove file"
|
||||
onClick={() => pickFile(null)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
<Box
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (!disabled) setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !disabled && inputRef.current?.click()}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: `2px dashed ${
|
||||
dragOver
|
||||
? "var(--mantine-color-edr-green-5)"
|
||||
: "var(--mantine-color-gray-4)"
|
||||
}`,
|
||||
background: dragOver
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: "var(--mantine-color-gray-0)",
|
||||
padding: "28px 20px",
|
||||
textAlign: "center",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
transition: "border-color 120ms ease, background 120ms ease",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
hidden
|
||||
disabled={disabled}
|
||||
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<Stack gap={8} align="center">
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={dragOver ? "edr-green" : "gray"}
|
||||
radius="xl"
|
||||
size={48}
|
||||
>
|
||||
<UploadCloud size={24} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{dragOver
|
||||
? "Drop to upload"
|
||||
: replaceMode
|
||||
? "Drag & drop to replace"
|
||||
: "Drag & drop your file here"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
or <span style={{ color: "var(--mantine-color-edr-green-7)", fontWeight: 600 }}>browse</span>{" "}
|
||||
— PDF or image
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PhasedMultiFileDropzoneProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: File[];
|
||||
onChange: (files: File[]) => void;
|
||||
accept?: string;
|
||||
replaceMode?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function PhasedMultiFileDropzone({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
accept = "application/pdf,image/*",
|
||||
replaceMode = false,
|
||||
disabled = false,
|
||||
}: PhasedMultiFileDropzoneProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const addFiles = (incoming: FileList | File[]) => {
|
||||
if (disabled) return;
|
||||
const next = [...value];
|
||||
for (const file of Array.from(incoming)) {
|
||||
if (!next.some((f) => f.name === file.name && f.size === file.size)) {
|
||||
next.push(file);
|
||||
}
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const removeAt = (index: number) => {
|
||||
if (disabled) return;
|
||||
onChange(value.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{value.length > 0 ? (
|
||||
<Stack gap={8}>
|
||||
{value.map((file, index) => (
|
||||
<Box
|
||||
key={`${file.name}-${file.size}-${index}`}
|
||||
p="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: "1px solid var(--mantine-color-edr-green-4)",
|
||||
background:
|
||||
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #fff 70%)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" align="center">
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={44}>
|
||||
<FileText size={18} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={700} c="edr-green" tt="uppercase">
|
||||
Ready to upload
|
||||
</Text>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatBytes(file.size)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
radius="md"
|
||||
aria-label="Remove file"
|
||||
onClick={() => removeAt(index)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Box
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (!disabled) setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !disabled && inputRef.current?.click()}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: `2px dashed ${
|
||||
dragOver
|
||||
? "var(--mantine-color-edr-green-5)"
|
||||
: "var(--mantine-color-gray-4)"
|
||||
}`,
|
||||
background: dragOver
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: "var(--mantine-color-gray-0)",
|
||||
padding: "28px 20px",
|
||||
textAlign: "center",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
transition: "border-color 120ms ease, background 120ms ease",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple
|
||||
hidden
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) addFiles(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Stack gap={8} align="center">
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={dragOver ? "edr-green" : "gray"}
|
||||
radius="xl"
|
||||
size={48}
|
||||
>
|
||||
<UploadCloud size={24} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{dragOver
|
||||
? "Drop to add files"
|
||||
: replaceMode
|
||||
? "Drag & drop to replace declaration files"
|
||||
: "Drag & drop declaration files here"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
or{" "}
|
||||
<span style={{ color: "var(--mantine-color-edr-green-7)", fontWeight: 600 }}>
|
||||
browse
|
||||
</span>{" "}
|
||||
— select one or more PDF or image files
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Badge, Box, Button, Group, Paper, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
||||
import { Download, Eye, FileText } from "lucide-react";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
|
||||
export interface PhasedUploadedFileRowProps {
|
||||
label: string;
|
||||
file: { id: string; name: string };
|
||||
onView?: (file: { name: string; url: string }) => void;
|
||||
onDownload?: (file: { id: string; name: string }) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/** Inline preview row for a phased customs upload (declaration, transit permit, DO, etc.). */
|
||||
export function PhasedUploadedFileRow({
|
||||
label,
|
||||
file,
|
||||
onView,
|
||||
onDownload,
|
||||
compact = false,
|
||||
}: PhasedUploadedFileRowProps) {
|
||||
const viewUrl = fileViewUrl(file.id);
|
||||
const canPreview = isViewable({ name: file.name, url: viewUrl });
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p={compact ? "xs" : "sm"}
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-edr-green-3)",
|
||||
background:
|
||||
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #FFFFFF 75%)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" align="center">
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={compact ? 32 : 36}>
|
||||
<FileText size={compact ? 15 : 17} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap" mt={2}>
|
||||
<Badge size="xs" variant="light" color="edr-green" radius="sm">
|
||||
Uploaded
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{file.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{canPreview && onView ? (
|
||||
<Tooltip label="Preview">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() => onView({ name: file.name, url: viewUrl })}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{onDownload ? (
|
||||
<Tooltip label="Download">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<Download size={13} />}
|
||||
onClick={() => onDownload({ id: file.id, name: file.name })}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function findWorkflowFile(
|
||||
files: Array<{ code: string; file: { id: string; name: string } | null }> | undefined,
|
||||
code: string,
|
||||
): { id: string; name: string } | null {
|
||||
return files?.find((f) => f.code === code)?.file ?? null;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Paper, Stack, Text } from "@mantine/core";
|
||||
import { Upload } from "lucide-react";
|
||||
|
||||
import { PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { PhasedUploadedFileRow } from "@/components/contracts/PhasedUploadedFileRow";
|
||||
|
||||
export type TransitPermitUploadedRow = {
|
||||
code: string;
|
||||
label: string;
|
||||
file: { id: string; name: string };
|
||||
};
|
||||
|
||||
export interface TransitPermitMultiUploadProps {
|
||||
title?: string;
|
||||
uploaded?: TransitPermitUploadedRow[];
|
||||
replaceMode?: boolean;
|
||||
submitLabel?: string;
|
||||
fileFieldPrefix: string;
|
||||
disabled?: boolean;
|
||||
onSubmit: (files: Record<string, File>) => Promise<void>;
|
||||
onViewFile?: (file: { name: string; url: string }) => void;
|
||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||
}
|
||||
|
||||
/** Multi-file transit permit upload — import pre-booking and export post-booking. */
|
||||
export function TransitPermitMultiUpload({
|
||||
title = "Transit Permit",
|
||||
uploaded = [],
|
||||
replaceMode = false,
|
||||
submitLabel,
|
||||
fileFieldPrefix,
|
||||
disabled = false,
|
||||
onSubmit,
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
}: TransitPermitMultiUploadProps) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const label = submitLabel ?? (replaceMode ? "Replace transit permit" : "Upload transit permit");
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={700}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{uploaded.length > 0 ? (
|
||||
<Stack gap={8}>
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
|
||||
Current file{uploaded.length > 1 ? "s" : ""}
|
||||
</Text>
|
||||
{uploaded.map((row) => (
|
||||
<PhasedUploadedFileRow
|
||||
key={row.code}
|
||||
label={row.label}
|
||||
file={row.file}
|
||||
onView={onViewFile}
|
||||
onDownload={onDownloadFile}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
|
||||
<PhasedMultiFileDropzone
|
||||
label="Transit permit documents"
|
||||
description={
|
||||
replaceMode
|
||||
? "Replace transit permit files — upload one or more documents (PDF or image)."
|
||||
: "Upload one or more transit permit documents (PDF or image)."
|
||||
}
|
||||
value={files}
|
||||
onChange={setFiles}
|
||||
replaceMode={replaceMode}
|
||||
disabled={disabled || loading}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={disabled || files.length === 0}
|
||||
leftSection={<Upload size={16} />}
|
||||
fullWidth
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = Object.fromEntries(
|
||||
files.map((file, index) => [`${fileFieldPrefix}_${index}`, file]),
|
||||
) as Record<string, File>;
|
||||
await onSubmit(payload);
|
||||
setFiles([]);
|
||||
} catch {
|
||||
// Caller shows toast for upload errors.
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Building2,
|
||||
Download,
|
||||
Eye,
|
||||
FileCheck,
|
||||
FileText,
|
||||
Globe,
|
||||
Hash,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
ShieldCheck,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { clearanceWorkflowFileLabel } from "@edr/types";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
|
||||
import { customersService } from "@/services/customers.service";
|
||||
|
||||
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
|
||||
|
||||
// ── shared bits ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface InfoRowProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Icon size={15} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRows({ rows }: { rows: InfoRowProps[] }) {
|
||||
const visible = rows.filter((r) => r.value);
|
||||
if (visible.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
No details available.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
{visible.map((row, i) => (
|
||||
<div key={row.label}>
|
||||
{i > 0 && <Divider color="var(--mantine-color-gray-2)" />}
|
||||
<InfoRow {...row} />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Customer tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Customer info for the contract. The contract detail payload only carries a
|
||||
* `companyId`, so we fetch the full company record to surface contact + manager
|
||||
* details (mirrors the booking-request customer card).
|
||||
*/
|
||||
export function ContractCustomerCard({
|
||||
contract,
|
||||
}: {
|
||||
contract: Freight.IContract;
|
||||
}) {
|
||||
const companyId = contract.companyId ?? undefined;
|
||||
|
||||
const { data: company, isLoading } = useQuery({
|
||||
queryKey: ["companies", "byId", companyId],
|
||||
queryFn: () => customersService.getById(companyId!),
|
||||
enabled: Boolean(companyId) && !contract.isGovernment,
|
||||
});
|
||||
|
||||
// Government contracts carry an institution name instead of a company.
|
||||
if (contract.isGovernment) {
|
||||
return (
|
||||
<SectionCard icon={Building2} title="Customer" accent="blue">
|
||||
<InfoRows
|
||||
rows={[
|
||||
{
|
||||
icon: Building2,
|
||||
label: "Government",
|
||||
value: contract.governmentInstitution ?? "Government",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SectionCard icon={Building2} title="Customer" accent="blue">
|
||||
<Group gap="sm" py="sm">
|
||||
<Loader size="sm" color="gray" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading customer…
|
||||
</Text>
|
||||
</Group>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (!company) {
|
||||
return (
|
||||
<SectionCard icon={Building2} title="Customer" accent="blue">
|
||||
<Text size="sm" c="dimmed">
|
||||
No customer linked to this contract.
|
||||
</Text>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<SectionCard
|
||||
icon={Building2}
|
||||
title="Customer"
|
||||
subtitle={company.name}
|
||||
accent="blue"
|
||||
>
|
||||
<InfoRows
|
||||
rows={[
|
||||
{ icon: FileCheck, label: "TIN", value: company.tin },
|
||||
{ icon: Hash, label: "VAT number", value: company.vatNumber },
|
||||
{ icon: ShieldCheck, label: "FAN number", value: company.fanNumber },
|
||||
{ icon: Globe, label: "Country", value: company.country },
|
||||
{ icon: Mail, label: "Email", value: company.email },
|
||||
{ icon: Phone, label: "Phone", value: company.phone },
|
||||
{ icon: MapPin, label: "Address", value: company.address },
|
||||
{ icon: Globe, label: "Website", value: company.website },
|
||||
]}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={User} title="Contact person" accent="teal">
|
||||
<InfoRows
|
||||
rows={[
|
||||
{ icon: User, label: "Name", value: company.contactPersonName },
|
||||
{ icon: Phone, label: "Phone", value: company.contactPersonPhone },
|
||||
]}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={User} title="General manager" accent="grape">
|
||||
<InfoRows
|
||||
rows={[
|
||||
{ icon: User, label: "Name", value: company.generalManagerName },
|
||||
{ icon: Mail, label: "Email", value: company.generalManagerEmail },
|
||||
{ icon: Phone, label: "Phone", value: company.generalManagerPhone },
|
||||
]}
|
||||
/>
|
||||
</SectionCard>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Documents tab ────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBytes(bytes?: number | null): string {
|
||||
if (!bytes || bytes <= 0) return "—";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
/** Human label for a file's `code` (e.g. "business_license" → "Business license"). */
|
||||
function codeLabel(code?: string | null): string | null {
|
||||
if (!code) return null;
|
||||
return (
|
||||
clearanceWorkflowFileLabel(code) ??
|
||||
code.replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
export interface ContractDocumentsCardProps {
|
||||
files: ContractFile[];
|
||||
/** Open the file inline in a viewer modal. */
|
||||
onView?: (file: ContractFile) => void;
|
||||
/** Download the file to disk. */
|
||||
onDownload?: (file: ContractFile) => void;
|
||||
}
|
||||
|
||||
/** Rich list of the contract's attached documents: type, size, view + download. */
|
||||
export function ContractDocumentsCard({
|
||||
files,
|
||||
onView,
|
||||
onDownload,
|
||||
}: ContractDocumentsCardProps) {
|
||||
return (
|
||||
<SectionCard
|
||||
icon={FileText}
|
||||
title="Documents"
|
||||
accent="indigo"
|
||||
extra={
|
||||
<Badge color="gray" variant="light" radius="sm">
|
||||
{files.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
{files.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No documents attached to this contract.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{files.map((file) => {
|
||||
const label = codeLabel(file.code);
|
||||
return (
|
||||
<Group
|
||||
key={file.id}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
p="xs"
|
||||
style={detailStyles.fileRow}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background =
|
||||
"var(--mantine-color-gray-0)";
|
||||
e.currentTarget.style.borderColor =
|
||||
"var(--freight-brand-border)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
e.currentTarget.style.borderColor =
|
||||
"var(--mantine-color-gray-2)";
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon size={36} radius="md" variant="light" color="indigo">
|
||||
<FileText size={17} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{label ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="gray"
|
||||
radius="sm"
|
||||
size="xs"
|
||||
tt="none"
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatBytes(file.size)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{onView ? (
|
||||
<Tooltip label="View" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="indigo"
|
||||
radius="md"
|
||||
onClick={() => onView(file)}
|
||||
aria-label={`View ${file.name}`}
|
||||
>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{onDownload ? (
|
||||
<Tooltip label="Download" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
onClick={() => onDownload(file)}
|
||||
aria-label={`Download ${file.name}`}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Building2,
|
||||
FileCheck,
|
||||
FileText,
|
||||
Mail,
|
||||
MapPin,
|
||||
Package,
|
||||
Phone,
|
||||
Ship,
|
||||
Truck,
|
||||
User,
|
||||
Warehouse,
|
||||
} from "lucide-react";
|
||||
import { Badge, Box, Divider, Group, Stack, Text } from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
|
||||
type ReqContract = NonNullable<Freight.IBookingRequest["contract"]>;
|
||||
|
||||
interface InfoRowProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
function InfoRow({ icon: Icon, label, value }: InfoRowProps) {
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" py={6} gap="md">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Icon size={15} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={600} ta="right" style={{ minWidth: 0 }} truncate>
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRows({ rows }: { rows: InfoRowProps[] }) {
|
||||
const visible = rows.filter((r) => r.value);
|
||||
if (visible.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
No details available.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
{visible.map((row, i) => (
|
||||
<div key={row.label}>
|
||||
{i > 0 && <Divider color="var(--mantine-color-gray-2)" />}
|
||||
<InfoRow {...row} />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer (company) on the request's contract. */
|
||||
export function RequestCustomerCard({ contract }: { contract?: ReqContract | null }) {
|
||||
const company = contract?.company;
|
||||
if (!company) {
|
||||
return (
|
||||
<SectionCard icon={Building2} title="Customer" accent="blue">
|
||||
<Text size="sm" c="dimmed">
|
||||
No customer linked to this request.
|
||||
</Text>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SectionCard
|
||||
icon={Building2}
|
||||
title="Customer"
|
||||
subtitle={company.name ?? undefined}
|
||||
accent="blue"
|
||||
>
|
||||
<InfoRows
|
||||
rows={[
|
||||
{ icon: FileCheck, label: "TIN", value: company.tin },
|
||||
{ icon: Mail, label: "Email", value: company.email },
|
||||
{ icon: Phone, label: "Phone", value: company.phone },
|
||||
{ icon: MapPin, label: "Address", value: company.address },
|
||||
{ icon: User, label: "Contact", value: company.contactPersonName },
|
||||
{ icon: Phone, label: "Contact phone", value: company.contactPersonPhone },
|
||||
]}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
const fmtDate = (iso?: string | null) =>
|
||||
iso
|
||||
? new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
const titleCase = (s?: string | null) =>
|
||||
s ? s.charAt(0) + s.slice(1).toLowerCase() : "—";
|
||||
|
||||
/** Contract identity + commercial terms. */
|
||||
export function RequestContractSummaryCard({
|
||||
contract,
|
||||
}: {
|
||||
contract?: ReqContract | null;
|
||||
}) {
|
||||
if (!contract) return null;
|
||||
return (
|
||||
<SectionCard
|
||||
icon={FileText}
|
||||
title="Contract"
|
||||
subtitle={contract.reference}
|
||||
accent="grape"
|
||||
>
|
||||
<InfoRows
|
||||
rows={[
|
||||
{
|
||||
icon: FileText,
|
||||
label: "Kind",
|
||||
value: contract.contractKind === "GENERAL" ? "General" : "One-time",
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
label: "Cargo",
|
||||
value: contract.freightType === "CONTAINER" ? "Container" : "Bulk",
|
||||
},
|
||||
{ icon: Ship, label: "Trade", value: titleCase(contract.tradeDirection) },
|
||||
{ icon: FileCheck, label: "Currency", value: contract.paymentCurrency },
|
||||
{
|
||||
icon: FileCheck,
|
||||
label: "Customs",
|
||||
value: contract.customsClearingEnabled
|
||||
? "Included (Global Logistics)"
|
||||
: "Not included",
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
label: "Valid until",
|
||||
value: contract.contractValidUntil
|
||||
? fmtDate(contract.contractValidUntil)
|
||||
: "Not active yet",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** Routes + cargo scope of the contract. */
|
||||
export function RequestRouteCargoCard({
|
||||
contract,
|
||||
}: {
|
||||
contract?: ReqContract | null;
|
||||
}) {
|
||||
const routes = contract?.routes ?? [];
|
||||
const cargo = contract?.cargoScope ?? [];
|
||||
const isContainer = contract?.freightType === "CONTAINER";
|
||||
return (
|
||||
<SectionCard icon={MapPin} title="Route & cargo" accent="teal">
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" fw={600} mb={6} tt="uppercase">
|
||||
Routes
|
||||
</Text>
|
||||
{routes.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No routes recorded.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
{routes.map((r) => (
|
||||
<Group key={r.id} gap={8} wrap="nowrap">
|
||||
<MapPin size={14} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{r.originYard?.label ?? r.originYardId} →{" "}
|
||||
{r.destinationYard?.label ?? r.destinationYardId}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" fw={600} mb={6} tt="uppercase">
|
||||
Cargo scope
|
||||
</Text>
|
||||
{cargo.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No cargo scope recorded.
|
||||
</Text>
|
||||
) : (
|
||||
<Group gap={6} wrap="wrap">
|
||||
{cargo.map((c) => (
|
||||
<Badge
|
||||
key={c.id}
|
||||
variant="light"
|
||||
color="teal"
|
||||
radius="sm"
|
||||
leftSection={<Package size={11} />}
|
||||
>
|
||||
{c.containerSize ??
|
||||
c.cargoFreeText ??
|
||||
(isContainer ? "Container" : "Bulk commodity")}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** Service type — what the contracted service bundles (rail-only vs logistics/customs). */
|
||||
export function RequestServiceTypeCard({
|
||||
contract,
|
||||
}: {
|
||||
contract?: ReqContract | null;
|
||||
}) {
|
||||
const st = contract?.serviceType;
|
||||
if (!st) return null;
|
||||
|
||||
const firstMile = st.includesFirstMile ?? false;
|
||||
const lastMile = st.includesLastMile ?? false;
|
||||
const customs = st.includesCustoms ?? false;
|
||||
const railOnly = !firstMile && !lastMile && !customs;
|
||||
|
||||
const chips: Array<{ label: string; color: string; icon: LucideIcon }> = [];
|
||||
if (railOnly) chips.push({ label: "Rail only", color: "blue", icon: Ship });
|
||||
if (firstMile)
|
||||
chips.push({ label: "First-mile pickup", color: "teal", icon: Truck });
|
||||
if (lastMile)
|
||||
chips.push({ label: "Last-mile delivery", color: "teal", icon: Warehouse });
|
||||
if (customs)
|
||||
chips.push({ label: "Customs clearance (GL)", color: "grape", icon: FileCheck });
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={Ship}
|
||||
title="Service"
|
||||
subtitle={st.serviceName}
|
||||
accent="indigo"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group gap={6} wrap="wrap">
|
||||
{chips.map((c) => (
|
||||
<Badge
|
||||
key={c.label}
|
||||
variant="light"
|
||||
color={c.color}
|
||||
radius="sm"
|
||||
leftSection={<c.icon size={11} />}
|
||||
>
|
||||
{c.label}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{st.description ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{st.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { AssignStationCard } from "./AssignStationCard";
|
||||
import { AssignRiskCard } from "./AssignRiskCard";
|
||||
import { AdviseDutyCard } from "./AdviseDutyCard";
|
||||
import { GlDocumentUploadCard } from "./GlDocumentUploadCard";
|
||||
import { TransportDocumentCard } from "./TransportDocumentCard";
|
||||
import { IncidentReportCard } from "./IncidentReportCard";
|
||||
|
||||
export interface GlActionsPanelProps {
|
||||
@@ -39,6 +40,16 @@ export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
|
||||
() => findMilestone(milestones, "DUTY_TAXES_ADVISED"),
|
||||
[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 (
|
||||
<SectionCard icon={Flag} title="Global Logistics actions" accent="edr-green">
|
||||
@@ -56,6 +67,8 @@ export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
|
||||
|
||||
<GlDocumentUploadCard bookingId={bookingId} />
|
||||
|
||||
{showTransport ? <TransportDocumentCard bookingId={bookingId} /> : null}
|
||||
|
||||
{riskMs ? (
|
||||
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
|
||||
) : 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="Transit permit"
|
||||
subtitle="Upload after wagon allocation (GL Ethiopia)"
|
||||
done={false}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<FileInput
|
||||
label="Transit permit"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Alert, Badge, Group, Stack, Text } from "@mantine/core";
|
||||
import { Boxes } from "lucide-react";
|
||||
|
||||
import { useContractCapacity } from "@/hooks/contracts/useContracts";
|
||||
|
||||
export function ContractCapacityNotice({
|
||||
contractId,
|
||||
isContainer,
|
||||
}: {
|
||||
contractId: string;
|
||||
isContainer: boolean;
|
||||
}) {
|
||||
const { data: lines = [] } = useContractCapacity(contractId);
|
||||
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
const allFull = lines.every((l) => l.remaining === 0);
|
||||
const unit = isContainer ? "" : " tons";
|
||||
|
||||
return (
|
||||
<Alert
|
||||
color={allFull ? "red" : "edr-green"}
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<Boxes size={16} />}
|
||||
title={allFull ? "Contract capacity reached" : "Remaining contract capacity"}
|
||||
>
|
||||
{allFull ? (
|
||||
<Text fz={13}>
|
||||
This contract has been fully booked. No further shipments can be created
|
||||
against it.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6} mt={4}>
|
||||
{lines.map((l, i) => (
|
||||
<Group key={i} justify="space-between" wrap="nowrap">
|
||||
<Text fz={13}>{l.containerSize ?? "Bulk"}</Text>
|
||||
<Badge
|
||||
color={l.remaining === 0 ? "red" : "edr-green"}
|
||||
variant="light"
|
||||
radius="sm"
|
||||
>
|
||||
{l.remaining}
|
||||
{unit} of {l.cap} left
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Box, Group, Paper, Text, Title } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const INK = "#10202F";
|
||||
const MUTED = "#6B7C8E";
|
||||
const BORDER = "#E6ECF2";
|
||||
const GREEN_DARK = "#0A6F4D";
|
||||
|
||||
export const fieldStyles = {
|
||||
label: { fontWeight: 600, fontSize: 13, color: INK, marginBottom: 6 },
|
||||
input: {
|
||||
borderRadius: 12,
|
||||
minHeight: 46,
|
||||
height: 46,
|
||||
fontSize: 14,
|
||||
borderColor: BORDER,
|
||||
},
|
||||
};
|
||||
|
||||
export function StepLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text
|
||||
fz={11}
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c={MUTED}
|
||||
style={{ letterSpacing: "0.07em" }}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepCard({
|
||||
children,
|
||||
eyebrow,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
eyebrow?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper
|
||||
radius={20}
|
||||
p={{ base: "lg", sm: 28 }}
|
||||
withBorder
|
||||
bg="white"
|
||||
style={{ borderColor: BORDER, boxShadow: "0 2px 14px rgba(16,24,40,0.04)" }}
|
||||
>
|
||||
{eyebrow}
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function StepHeader({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={14} align="flex-start" wrap="nowrap" mb={22}>
|
||||
{icon ? (
|
||||
<Box
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 13,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "linear-gradient(135deg, #ECF6F1, #E4F3EC)",
|
||||
color: GREEN_DARK,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
) : null}
|
||||
<Box>
|
||||
<Title order={3} fz={20} fw={800} c={INK} style={{ letterSpacing: "-0.01em" }}>
|
||||
{title}
|
||||
</Title>
|
||||
<Text size="sm" c={MUTED} mt={4} style={{ lineHeight: 1.5 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -184,6 +184,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
subtitle: "Manage dropdown options used across the platform",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/configuration/contract-validity-periods",
|
||||
meta: {
|
||||
title: "Contract validity periods",
|
||||
subtitle: "Validity options staff choose when accepting a submitted contract",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/configuration/train-scheduling-rules",
|
||||
meta: {
|
||||
|
||||
@@ -307,6 +307,14 @@ const RuleEngineFormDialog = ({
|
||||
size="md"
|
||||
radius="md"
|
||||
styles={inputStyles}
|
||||
rightSection={
|
||||
field.suffix ? (
|
||||
<Text size="sm" c="dimmed" fw={600} pr={4}>
|
||||
{field.suffix}
|
||||
</Text>
|
||||
) : undefined
|
||||
}
|
||||
rightSectionWidth={field.suffix ? 52 : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -94,6 +94,15 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
|
||||
return <Text size="sm">{Number.isNaN(num) ? String(value) : num.toLocaleString()}</Text>;
|
||||
}
|
||||
|
||||
if (format === "currency") {
|
||||
const num = Number(value);
|
||||
return (
|
||||
<Text size="sm" fw={500}>
|
||||
{Number.isNaN(num) ? String(value) : `USD ${num.toLocaleString()}`}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (format === "date") {
|
||||
const d = new Date(String(value));
|
||||
if (Number.isNaN(d.getTime())) return <Text size="sm">{String(value)}</Text>;
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { formatRouteLabel } from "@/services/routes.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
@@ -138,7 +139,9 @@ export function AllocateBookingWizard({
|
||||
const schedulesQuery = useQuery(
|
||||
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
|
||||
);
|
||||
const routesQuery = useQuery(api.routes.list.queryOptions());
|
||||
const routesQuery = useQuery(
|
||||
api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }),
|
||||
);
|
||||
const locomotivesQuery = useQuery(
|
||||
api.trainScheduling.availableLocomotives.queryOptions({
|
||||
input: {
|
||||
@@ -244,7 +247,7 @@ export function AllocateBookingWizard({
|
||||
}, [containerUnits, containerSlots, savedPlacementsFromSchedule]);
|
||||
|
||||
const activeRoutes = useMemo(
|
||||
() => (routesQuery.data ?? []).filter((r) => r.isActive),
|
||||
() => routesQuery.data ?? [],
|
||||
[routesQuery.data],
|
||||
);
|
||||
|
||||
@@ -522,7 +525,7 @@ export function AllocateBookingWizard({
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<Select
|
||||
label="Route"
|
||||
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
|
||||
data={activeRoutes.map((r) => ({ value: r.id, label: formatRouteLabel(r) }))}
|
||||
value={routeId || null}
|
||||
onChange={(v) => setRouteId(v ?? "")}
|
||||
searchable
|
||||
|
||||
@@ -44,6 +44,8 @@ export const QUERY_KEYS = {
|
||||
listSummary: (filter?: BookingListFilter) =>
|
||||
["bookings", "list-summary", filter ?? {}] as const,
|
||||
byId: (id: string) => ["bookings", "detail", id] as const,
|
||||
clearanceQueue: (region?: string) =>
|
||||
["bookings", "clearance-queue", region ?? "ET"] as const,
|
||||
},
|
||||
|
||||
CONTRACTS: {
|
||||
|
||||
@@ -123,6 +123,18 @@ export const URL_CONSTANTS = {
|
||||
COMPLETE: (id: string) => `/bookings/${id}/operations/complete`,
|
||||
CANCEL: (id: string) => `/bookings/${id}/cancel`,
|
||||
CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`,
|
||||
CLEARANCE: (id: string) => `/bookings/${id}/clearance`,
|
||||
CLEARANCE_DECLARATION: (id: string) => `/bookings/${id}/clearance/declaration`,
|
||||
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
|
||||
CLEARANCE_FINALIZE_PRE: (id: string) =>
|
||||
`/bookings/${id}/clearance/finalize-pre-clearance`,
|
||||
CLEARANCE_TRANSIT_PERMIT: (id: string) => `/bookings/${id}/clearance/transit-permit`,
|
||||
CLEARANCE_DELIVERY_ORDER: (id: string) => `/bookings/${id}/clearance/delivery-order`,
|
||||
CLEARANCE_RELEASE_ORDER: (id: string) => `/bookings/${id}/clearance/release-order`,
|
||||
CLEARANCE_RO_AMENDMENT: (id: string) => `/bookings/${id}/clearance/ro-amendment`,
|
||||
CLEARANCE_EXPORT_RELEASE: (id: string) => `/bookings/${id}/clearance/export-release`,
|
||||
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
|
||||
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
|
||||
},
|
||||
|
||||
CONTRACTS: {
|
||||
@@ -137,6 +149,7 @@ export const URL_CONSTANTS = {
|
||||
`/contracts/${id}/approval-steps/${stepId}/approve`,
|
||||
CONTRACT_GENERATE: (id: string) => `/contracts/${id}/contract/generate`,
|
||||
CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`,
|
||||
CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`,
|
||||
CONTRACT_SIGN: (id: string) => `/contracts/${id}/contract/sign`,
|
||||
CLEARANCE_QUEUE: "/contracts/clearance/queue",
|
||||
CLEARANCE: (id: string) => `/contracts/${id}/clearance`,
|
||||
@@ -144,6 +157,25 @@ export const URL_CONSTANTS = {
|
||||
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
|
||||
`/contracts/${id}/clearance/output-documents`,
|
||||
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_FINALIZE_PRE: (id: string) =>
|
||||
`/contracts/${id}/clearance/finalize-pre-clearance`,
|
||||
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_FINALIZE_EXPORT: (id: string) =>
|
||||
`/contracts/${id}/clearance/finalize-export-clearance`,
|
||||
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.
|
||||
OPS_CLEARANCE_QUEUE: "/contracts/clearance/ops-queue",
|
||||
OPS_CLEARANCE_REVIEW: (id: string) =>
|
||||
@@ -177,6 +209,8 @@ export const URL_CONSTANTS = {
|
||||
`/contracts/bookings/${bookingId}/station-assign`,
|
||||
BOOKING_GL_DOCUMENTS: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/documents`,
|
||||
BOOKING_TRANSPORT_DOCUMENT: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/transport-document`,
|
||||
BOOKING_INCIDENTS: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/incidents`,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
export interface ShipmentListRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
contractId: string;
|
||||
contractReference: string;
|
||||
scheduledDate?: string | null;
|
||||
summary: string;
|
||||
status: Freight.BookingRequestStatus;
|
||||
createdBookingId?: string | null;
|
||||
}
|
||||
|
||||
export type ShipmentRowAction =
|
||||
| {
|
||||
kind: "navigate";
|
||||
label: string;
|
||||
to: (row: ShipmentListRow) => string;
|
||||
variant: "filled" | "light" | "default";
|
||||
}
|
||||
| {
|
||||
kind: "reject";
|
||||
label: string;
|
||||
variant: "light";
|
||||
};
|
||||
|
||||
/** Primary staff action for a shipment request list row. */
|
||||
export function getShipmentStaffRowAction(
|
||||
row: Pick<
|
||||
ShipmentListRow,
|
||||
"status" | "contractId" | "id" | "createdBookingId"
|
||||
>,
|
||||
): ShipmentRowAction {
|
||||
if (row.status === "PENDING") {
|
||||
return {
|
||||
kind: "navigate",
|
||||
label: "Accept",
|
||||
to: (r) =>
|
||||
`/dashboard/contracts/${r.contractId}/create-booking?requestId=${r.id}`,
|
||||
variant: "filled",
|
||||
};
|
||||
}
|
||||
if (row.status === "ACCEPTED" && row.createdBookingId) {
|
||||
return {
|
||||
kind: "navigate",
|
||||
label: "View clearance",
|
||||
to: (r) => `/dashboard/clearance/${r.createdBookingId}`,
|
||||
variant: "light",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "navigate",
|
||||
label: "Review",
|
||||
to: (r) => `/dashboard/shipment-requests/${r.id}`,
|
||||
variant: "default",
|
||||
};
|
||||
}
|
||||
|
||||
export function getShipmentRejectAction(
|
||||
row: Pick<ShipmentListRow, "status">,
|
||||
): ShipmentRowAction | null {
|
||||
if (row.status !== "PENDING") return null;
|
||||
return { kind: "reject", label: "Reject", variant: "light" };
|
||||
}
|
||||
@@ -188,3 +188,19 @@ export function useBookingMutations(bookingId: string) {
|
||||
downloadContract: () => bookingsService.downloadContract(bookingId),
|
||||
};
|
||||
}
|
||||
|
||||
export function useBookingEtClearanceQueue(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.BOOKINGS.clearanceQueue("ET"),
|
||||
queryFn: () => bookingsService.getEtClearanceQueue(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBookingDjClearanceQueue(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.BOOKINGS.clearanceQueue("DJ"),
|
||||
queryFn: () => bookingsService.getDjClearanceQueue(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
export function useOpsClearanceQueue(enabled = true) {
|
||||
return useQuery({
|
||||
@@ -248,7 +264,10 @@ export function useContractClearanceMutations(
|
||||
);
|
||||
refresh();
|
||||
},
|
||||
onError: () => toast.error("Could not update document"),
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Could not update document",
|
||||
),
|
||||
});
|
||||
|
||||
// Approve every still-pending customer document in one click. There is no
|
||||
|
||||
@@ -33,7 +33,9 @@ export const FREIGHT_PERMS = {
|
||||
clearanceReview: "edr_freight_app:contracts:clearance_review",
|
||||
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
|
||||
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: {
|
||||
view: "edr_freight_app:train_scheduling:view",
|
||||
|
||||
@@ -294,16 +294,18 @@ export default function BookingRequestDetailPage() {
|
||||
booking={booking}
|
||||
mutations={mutations}
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="default"
|
||||
leftSection={<Milestone size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/bookings/${booking.id}/milestones`)
|
||||
}
|
||||
>
|
||||
View clearance milestones
|
||||
</Button>
|
||||
{booking.customsClearingEnabled && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="default"
|
||||
leftSection={<Milestone size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/bookings/${booking.id}/clearance`)
|
||||
}
|
||||
>
|
||||
View document clearance
|
||||
</Button>
|
||||
)}
|
||||
{showContractButton && (
|
||||
<Button
|
||||
fullWidth
|
||||
|
||||
@@ -25,27 +25,39 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail";
|
||||
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
|
||||
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { useBookingMilestones } from "@/hooks/contracts/useContracts";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
||||
|
||||
export default function DocumentClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const params = useParams<{ id?: string; bookingId?: string }>();
|
||||
const id = params.id ?? params.bookingId;
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const { data: booking } = useBookingDetail(id);
|
||||
const {
|
||||
data: clearance,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["clearance", id],
|
||||
queryFn: () => bookingsService.getClearance(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const { data: bookingMilestones } = useBookingMilestones(id);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const docs = (clearance?.documents ?? []).filter(
|
||||
(d) => d.uploadedBy === "customer",
|
||||
@@ -59,6 +71,20 @@ export default function DocumentClearanceDetailPage() {
|
||||
}, [clearance]);
|
||||
|
||||
const reference = booking?.reference ?? "Clearance";
|
||||
const isPhasedGeneral =
|
||||
Boolean(booking?.customsClearingEnabled) &&
|
||||
booking?.contractKind === "GENERAL" &&
|
||||
Boolean(clearance?.phase);
|
||||
|
||||
const docsPhaseComplete =
|
||||
clearance?.milestones?.some(
|
||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||
) ?? false;
|
||||
const queriesLocked = Boolean(
|
||||
(clearance as Freight.ContractClearanceView | undefined)?.preClearanceFinalized,
|
||||
);
|
||||
const workflowFiles =
|
||||
(clearance as Freight.ContractClearanceView | undefined)?.workflowFiles ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -76,9 +102,9 @@ export default function DocumentClearanceDetailPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Clearance not found"
|
||||
backTo="/dashboard/clearance"
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
breadcrumbs={[
|
||||
{ label: "Document Clearance", href: "/dashboard/clearance" },
|
||||
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
|
||||
{ label: "Not found" },
|
||||
]}
|
||||
/>
|
||||
@@ -94,9 +120,9 @@ export default function DocumentClearanceDetailPage() {
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={reference}
|
||||
backTo="/dashboard/clearance"
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
breadcrumbs={[
|
||||
{ label: "Document Clearance", href: "/dashboard/clearance" },
|
||||
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
|
||||
{ label: reference },
|
||||
]}
|
||||
meta={
|
||||
@@ -124,60 +150,103 @@ export default function DocumentClearanceDetailPage() {
|
||||
|
||||
<ClearanceHero booking={booking} clearance={clearance} stats={stats} />
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — document review (shared with the Marketing booking detail) */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<ClearanceReviewSection bookingId={id!} hideSummary />
|
||||
</Grid.Col>
|
||||
{isPhasedGeneral ? (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<ClearancePhaseStepper
|
||||
clearance={clearance as Freight.ContractClearanceView}
|
||||
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||
/>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{/* RIGHT — sticky progress gauge */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard
|
||||
icon={PackageCheck}
|
||||
title="Review progress"
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
{stats.pct}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
approved
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
<ClearanceOpsTabs
|
||||
bookingId={id}
|
||||
milestones={bookingMilestones}
|
||||
showOpsTabs={Boolean(id)}
|
||||
showWorkflowFilesTab={isPhasedGeneral}
|
||||
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||
workflowFiles={workflowFiles}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
clearanceTab={
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 7 : 8 }}>
|
||||
<ClearanceReviewSection
|
||||
bookingId={id!}
|
||||
hideSummary
|
||||
approvalsLocked={isPhasedGeneral && docsPhaseComplete}
|
||||
queriesLocked={queriesLocked}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
|
||||
{isPhasedGeneral ? (
|
||||
<PhasedClearanceActionPanel
|
||||
bookingId={id!}
|
||||
clearance={clearance}
|
||||
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||
workflowFiles={workflowFiles}
|
||||
roleMode="ET"
|
||||
onChanged={() => void refetch()}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
/>
|
||||
<Group gap="lg" justify="center">
|
||||
<ProgressStat
|
||||
color="edr-green"
|
||||
label="Approved"
|
||||
value={stats.approved}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="red"
|
||||
label="Queried"
|
||||
value={stats.queried}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="gray"
|
||||
label="Pending"
|
||||
value={stats.pending}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
) : (
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard
|
||||
icon={PackageCheck}
|
||||
title="Review progress"
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
{stats.pct}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
approved
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Group gap="lg" justify="center">
|
||||
<ProgressStat
|
||||
color="edr-green"
|
||||
label="Approved"
|
||||
value={stats.approved}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="red"
|
||||
label="Queried"
|
||||
value={stats.queried}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="gray"
|
||||
label="Pending"
|
||||
value={stats.pending}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
)}
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
}
|
||||
/>
|
||||
|
||||
{isPhasedGeneral && clearance.milestones && clearance.milestones.length > 0 ? (
|
||||
<ClearanceMilestoneTimeline milestones={clearance.milestones} />
|
||||
) : null}
|
||||
</Stack>
|
||||
{viewer}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ interface RefCargoChild {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
show_free_text_box?: boolean;
|
||||
}
|
||||
interface RefCargoGroup {
|
||||
id: string;
|
||||
@@ -307,20 +306,18 @@ export default function NewBookingPage() {
|
||||
[refData?.containers],
|
||||
);
|
||||
|
||||
const { cargoData, freeTextById } = useMemo(() => {
|
||||
const { cargoData } = useMemo(() => {
|
||||
const groups = refData?.cargo_type ?? [];
|
||||
const freeText = new Map<string, boolean>();
|
||||
const data = groups.map((g) => {
|
||||
if (g.children?.length) {
|
||||
g.children.forEach((c) => freeText.set(c.id, Boolean(c.show_free_text_box)));
|
||||
return { group: g.name, items: g.children.map((c) => ({ value: c.id, label: c.name })) };
|
||||
}
|
||||
return { value: g.id, label: g.name };
|
||||
});
|
||||
return { cargoData: data, freeTextById: freeText };
|
||||
return { cargoData: data };
|
||||
}, [refData?.cargo_type]);
|
||||
|
||||
const showFreeText = cargoTypeId ? freeTextById.get(cargoTypeId) : false;
|
||||
const showFreeText = freightType === "BULK" && Boolean(cargoTypeId);
|
||||
|
||||
// ---- derived totals ----
|
||||
const totalContainers = lines.reduce((s, l) => s + (l.quantity || 0), 0);
|
||||
@@ -376,7 +373,7 @@ export default function NewBookingPage() {
|
||||
lastMileDeliveryAddress: lastMileDeliveryAddress.trim() || undefined,
|
||||
cargoTotalWeightVgm,
|
||||
cargoTypeId: freightType === "BULK" ? cargoTypeId : undefined,
|
||||
cargoFreeText: freightType === "BULK" && showFreeText ? cargoFreeText.trim() || undefined : undefined,
|
||||
cargoFreeText: freightType === "BULK" ? cargoFreeText.trim() || undefined : undefined,
|
||||
containers:
|
||||
freightType === "CONTAINER"
|
||||
? lines.map((l) => ({
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { CalendarClock, Pencil } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { api } from "@/services/api";
|
||||
import ManageDropdownOptionsDialog from "@/pages/dropdown_settings/ManageDropdownOptionsDialog";
|
||||
|
||||
const CONTRACT_VALIDITY_PERIODS_CODE = "contract_validity_periods";
|
||||
|
||||
/**
|
||||
* Admin UI for contract validity options used when staff accepts a submitted
|
||||
* contract (SUBMITTED → PENDING_APPROVAL). Backed by dropdown_settings.
|
||||
*/
|
||||
export default function ContractValidityPeriodsPage() {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
const { data: setting, isLoading, isError } = useQuery(
|
||||
api.dropdownSettings.getByCode.queryOptions({
|
||||
input: { code: CONTRACT_VALIDITY_PERIODS_CODE },
|
||||
}),
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
[...(setting?.children ?? [])].sort(
|
||||
(a, b) => (a.order ?? 0) - (b.order ?? 0),
|
||||
),
|
||||
[setting],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Configuration", href: "/dashboard/configuration" },
|
||||
{ label: "Contract validity periods" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Stack gap="lg" mt="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={4}>
|
||||
<Title order={2}>Contract validity periods</Title>
|
||||
<Text size="sm" c="dimmed" maw={560}>
|
||||
Options shown when line staff accepts a submitted contract. Each
|
||||
value is the number of days the contract stays valid from the
|
||||
accept date.
|
||||
</Text>
|
||||
</Stack>
|
||||
{setting && (
|
||||
<Button
|
||||
leftSection={<Pencil size={16} />}
|
||||
color="edr-green"
|
||||
onClick={() => setEditOpen(true)}
|
||||
>
|
||||
Edit options
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
) : isError || !setting ? (
|
||||
<Text c="dimmed">
|
||||
Could not load contract validity settings. Ensure{" "}
|
||||
<Text span ff="monospace" size="sm">
|
||||
{CONTRACT_VALIDITY_PERIODS_CODE}
|
||||
</Text>{" "}
|
||||
is seeded in dropdown settings.
|
||||
</Text>
|
||||
) : options.length === 0 ? (
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<CalendarClock size={32} color="var(--mantine-color-gray-5)" />
|
||||
<Text c="dimmed">No validity periods configured yet.</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
onClick={() => setEditOpen(true)}
|
||||
>
|
||||
Add options
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Label</Table.Th>
|
||||
<Table.Th>Days (value)</Table.Th>
|
||||
<Table.Th>Order</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{options.map((opt) => (
|
||||
<Table.Tr key={opt.id}>
|
||||
<Table.Td>{opt.label}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text ff="monospace" size="sm">
|
||||
{opt.value}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{opt.order ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={opt.disabled ? "gray" : "edr-green"}
|
||||
variant="light"
|
||||
>
|
||||
{opt.disabled ? "Disabled" : "Active"}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
{setting ? (
|
||||
<ManageDropdownOptionsDialog
|
||||
setting={setting}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
) : null}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -23,23 +23,33 @@ import {
|
||||
PackageCheck,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
|
||||
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 { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import {
|
||||
useBookingMilestones,
|
||||
useContractDetail,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
|
||||
export default function ContractClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const { data: contract } = useContractDetail(id);
|
||||
const {
|
||||
data: clearance,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
||||
queryFn: () => contractsService.getClearance(id!),
|
||||
@@ -59,9 +69,40 @@ export default function ContractClearanceDetailPage() {
|
||||
}, [clearance]);
|
||||
|
||||
const reference = contract?.reference ?? "Clearance";
|
||||
// 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.
|
||||
const ready = clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
|
||||
const phasedCustoms =
|
||||
contract?.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled);
|
||||
const docsPhaseComplete =
|
||||
clearance?.milestones?.some(
|
||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||
) ?? false;
|
||||
const ready = clearance?.bookingReady === true;
|
||||
const docReviewLocked = phasedCustoms
|
||||
? docsPhaseComplete
|
||||
: clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
|
||||
const shipmentLocked = Boolean(
|
||||
contract?.status &&
|
||||
[
|
||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
"FULLY_EXECUTED",
|
||||
"CONTRACT_ACTIVE",
|
||||
"CONTRACT_CLOSED",
|
||||
"EXPIRED",
|
||||
].includes(contract.status),
|
||||
);
|
||||
const linkedBookingId = useMemo(() => {
|
||||
const cycle = contract?.clearanceCycles?.find(
|
||||
(c) => c.cycleNumber === (contract?.clearanceCycleNumber ?? 1),
|
||||
);
|
||||
return cycle?.bookingId ?? undefined;
|
||||
}, [contract]);
|
||||
const bookingAlreadyCreated = Boolean(linkedBookingId) || shipmentLocked;
|
||||
const canCreateBooking = ready && !bookingAlreadyCreated;
|
||||
const reviewReadOnly = shipmentLocked;
|
||||
const queriesLocked = Boolean(clearance?.preClearanceFinalized);
|
||||
const bookingHref = `/dashboard/contracts/${id}/create-booking`;
|
||||
|
||||
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
|
||||
useBookingMilestones(linkedBookingId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -95,6 +136,8 @@ export default function ContractClearanceDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const workflowFiles = clearance.workflowFiles ?? [];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
@@ -109,14 +152,23 @@ export default function ContractClearanceDetailPage() {
|
||||
{ label: reference },
|
||||
]}
|
||||
meta={
|
||||
ready ? (
|
||||
bookingAlreadyCreated ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="sm"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
>
|
||||
Booking created
|
||||
</Badge>
|
||||
) : ready ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
>
|
||||
Ready — customer books
|
||||
Ready — create booking
|
||||
</Badge>
|
||||
) : clearance.allApproved ? (
|
||||
<Badge
|
||||
@@ -142,75 +194,139 @@ export default function ContractClearanceDetailPage() {
|
||||
|
||||
<ClearanceHero contract={contract} stats={stats} />
|
||||
|
||||
{ready ? (
|
||||
{bookingAlreadyCreated ? (
|
||||
<Alert
|
||||
color="blue"
|
||||
radius="md"
|
||||
icon={<PackageCheck size={16} />}
|
||||
title="Shipment booking created"
|
||||
>
|
||||
GL Ethiopia has created the shipment booking for this contract.
|
||||
{linkedBookingId ? (
|
||||
<>
|
||||
{" "}
|
||||
<Text
|
||||
component={Link}
|
||||
to={`/dashboard/bookings/${linkedBookingId}/clearance`}
|
||||
inherit
|
||||
fw={600}
|
||||
c="blue.7"
|
||||
>
|
||||
View booking →
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Alert>
|
||||
) : canCreateBooking ? (
|
||||
<Alert
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
icon={<PackageCheck size={16} />}
|
||||
title="Clearance finalized"
|
||||
title="Clearance complete"
|
||||
>
|
||||
Customs clearance is complete. The customer can now create the
|
||||
shipment booking from the portal — no further action is needed here.
|
||||
Pre-booking clearance is complete. GL Ethiopia can create the shipment booking.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly={ready}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<ClearanceOpsTabs
|
||||
bookingId={linkedBookingId}
|
||||
milestones={bookingMilestones}
|
||||
showOpsTabs={Boolean(linkedBookingId)}
|
||||
showWorkflowFilesTab={phasedCustoms}
|
||||
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
|
||||
workflowFiles={workflowFiles}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
clearanceTab={
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly={reviewReadOnly}
|
||||
approvalsLocked={phasedCustoms && docReviewLocked}
|
||||
queriesLocked={queriesLocked}
|
||||
phasedCustoms={phasedCustoms}
|
||||
onChanged={() => {
|
||||
void refetch();
|
||||
void refetchBookingMilestones();
|
||||
}}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard
|
||||
icon={PackageCheck}
|
||||
title="Review progress"
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
{stats.pct}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
approved
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Group gap="lg" justify="center">
|
||||
<ProgressStat
|
||||
color="edr-green"
|
||||
label="Approved"
|
||||
value={stats.approved}
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<Stack gap="md">
|
||||
{phasedCustoms ? (
|
||||
<PhasedClearanceActionPanel
|
||||
contractId={id!}
|
||||
bookingId={linkedBookingId}
|
||||
bookingMilestones={bookingMilestones ?? []}
|
||||
clearance={clearance}
|
||||
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
|
||||
workflowFiles={workflowFiles}
|
||||
roleMode="ET"
|
||||
onChanged={() => {
|
||||
void refetch();
|
||||
void refetchBookingMilestones();
|
||||
}}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
bookingCreateHref={canCreateBooking ? bookingHref : undefined}
|
||||
bookingCreated={bookingAlreadyCreated}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="red"
|
||||
label="Queried"
|
||||
value={stats.queried}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="gray"
|
||||
label="Pending"
|
||||
value={stats.pending}
|
||||
/>
|
||||
</Group>
|
||||
) : (
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard
|
||||
icon={PackageCheck}
|
||||
title="Review progress"
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
{stats.pct}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
approved
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Group gap="lg" justify="center">
|
||||
<ProgressStat
|
||||
color="edr-green"
|
||||
label="Approved"
|
||||
value={stats.approved}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="red"
|
||||
label="Queried"
|
||||
value={stats.queried}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="gray"
|
||||
label="Pending"
|
||||
value={stats.pending}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
{viewer}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Flag,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
PackageCheck,
|
||||
@@ -43,9 +44,15 @@ import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import {
|
||||
useContractClearanceQueue,
|
||||
useEtClearanceQueue,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
type QueueTab = "all" | "et";
|
||||
|
||||
interface ClearanceRow {
|
||||
id: string;
|
||||
@@ -59,8 +66,10 @@ interface ClearanceRow {
|
||||
serviceTypeName: string;
|
||||
customs: boolean;
|
||||
status: string;
|
||||
/** true once GL has finalized clearance — customer now books in the portal. */
|
||||
/** true once GL has finalized clearance — customer may book in the portal. */
|
||||
ready: boolean;
|
||||
/** true once GL Ethiopia created the shipment booking. */
|
||||
bookingCreated: boolean;
|
||||
}
|
||||
|
||||
function yardLabel(
|
||||
@@ -93,6 +102,7 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled,
|
||||
status: contract.status,
|
||||
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
|
||||
bookingCreated: contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,6 +144,21 @@ function DirectionIcon({ direction }: { direction: string }) {
|
||||
}
|
||||
|
||||
function StatusBadge({ row }: { row: ClearanceRow }) {
|
||||
if (row.bookingCreated) {
|
||||
return (
|
||||
<Tooltip label="GL Ethiopia created the shipment booking" withArrow>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="sm"
|
||||
leftSection={<PackagePlus size={12} />}
|
||||
>
|
||||
Booking created
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (row.ready) {
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -160,19 +185,61 @@ function StatusBadge({ row }: { row: ClearanceRow }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Document Clearance hub. Lists every customs (Path B) contract that still needs
|
||||
* customs clearance — awaiting documents, under GL review, or finalized and
|
||||
* waiting for the customer to create the booking in the portal. A single list,
|
||||
* no queue/history/direction tabs.
|
||||
* Document Clearance hub. Lists every customs (Path B) contract in phased clearance,
|
||||
* including after booking is created — stays visible for reference and follow-up.
|
||||
*/
|
||||
export default function ContractClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
||||
|
||||
const defaultQueue: QueueTab = canReview ? "all" : "et";
|
||||
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
|
||||
const [query, setQuery] = useState("");
|
||||
const [view, setView] = useState<ViewMode>("table");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } =
|
||||
useContractClearanceQueue(true);
|
||||
const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } =
|
||||
useContractClearanceQueue(queueTab === "all");
|
||||
const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } =
|
||||
useEtClearanceQueue(queueTab === "et");
|
||||
|
||||
const data = queueTab === "et" ? etData : allData;
|
||||
const isLoading = queueTab === "et" ? etLoading : allLoading;
|
||||
const isError = queueTab === "et" ? etError : allError;
|
||||
const isFetching = queueTab === "et" ? etFetching : allFetching;
|
||||
const refetch = () => {
|
||||
if (queueTab === "et") void refetchEt();
|
||||
else void refetchAll();
|
||||
};
|
||||
|
||||
const queueTabOptions = useMemo(() => {
|
||||
const opts: { value: QueueTab; label: ReactNode }[] = [];
|
||||
if (canReview) {
|
||||
opts.push({
|
||||
value: "all",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ShieldCheck size={15} />
|
||||
<Box visibleFrom="sm">All</Box>
|
||||
</Group>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (canEt) {
|
||||
opts.push({
|
||||
value: "et",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Flag size={15} />
|
||||
<Box visibleFrom="sm">ET queue</Box>
|
||||
</Group>
|
||||
),
|
||||
});
|
||||
}
|
||||
return opts;
|
||||
}, [canReview, canEt]);
|
||||
|
||||
const allRows = useMemo(
|
||||
() => (data?.items ?? []).map(toClearanceRow),
|
||||
@@ -183,7 +250,8 @@ export default function ContractClearanceListPage() {
|
||||
() => ({
|
||||
all: allRows.length,
|
||||
ready: allRows.filter((r) => r.ready).length,
|
||||
review: allRows.filter((r) => !r.ready).length,
|
||||
booked: allRows.filter((r) => r.bookingCreated).length,
|
||||
review: allRows.filter((r) => !r.ready && !r.bookingCreated).length,
|
||||
}),
|
||||
[allRows],
|
||||
);
|
||||
@@ -329,7 +397,7 @@ export default function ContractClearanceListPage() {
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Document Clearance"
|
||||
subtitle="Review pre-booking customs documents on contracts and finalize clearance. Once finalized, the customer creates the shipment booking in the portal."
|
||||
subtitle="All customs contracts in phased clearance — stays visible after booking is created."
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
@@ -337,7 +405,7 @@ export default function ContractClearanceListPage() {
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={13} />}
|
||||
>
|
||||
{counts.all} need clearance
|
||||
{counts.all} in clearance
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
@@ -358,7 +426,7 @@ export default function ContractClearanceListPage() {
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Need clearance",
|
||||
label: "In clearance",
|
||||
value: counts.all,
|
||||
icon: Inbox,
|
||||
color: "edr-green",
|
||||
@@ -370,8 +438,8 @@ export default function ContractClearanceListPage() {
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Ready — customer books",
|
||||
value: counts.ready,
|
||||
label: "Ready / booked",
|
||||
value: counts.ready + counts.booked,
|
||||
icon: PackageCheck,
|
||||
color: "edr-green",
|
||||
},
|
||||
@@ -380,6 +448,20 @@ export default function ContractClearanceListPage() {
|
||||
|
||||
<Card p={0} withBorder shadow="sm" radius="lg">
|
||||
<Stack gap={0}>
|
||||
{queueTabOptions.length > 1 ? (
|
||||
<Box px="md" pt="md">
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={queueTab}
|
||||
onChange={(v) => {
|
||||
setQueueTab(v as QueueTab);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
data={queueTabOptions}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box px="md" pt="md" pb="sm">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
@@ -6,14 +7,19 @@ import {
|
||||
Building2,
|
||||
Calendar,
|
||||
CalendarClock,
|
||||
Download,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Files,
|
||||
Flame,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Route as RouteIcon,
|
||||
ShieldCheck,
|
||||
Snowflake,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
@@ -31,6 +37,8 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import "@/components/overview/overview.css";
|
||||
import { PageContainer } from "@/components/page";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
@@ -41,11 +49,22 @@ import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflow
|
||||
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import {
|
||||
ContractCustomerCard,
|
||||
ContractDocumentsCard,
|
||||
} from "@/components/contracts/detail/ContractDetailTabCards";
|
||||
import { getContractStatusMeta } from "@/features/contracts/contract-status.config";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import {
|
||||
useContractDetail,
|
||||
useContractMutations,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
// Clearance phase — staff can still ACT (approve / query / finalize).
|
||||
const CLEARANCE_ACTIVE_STATUSES = [
|
||||
@@ -94,7 +113,8 @@ export default function ContractRequestDetailPage() {
|
||||
} = useContractDetail(id);
|
||||
const mutations = useContractMutations(id ?? "");
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = searchParams.get("tab") === "clearance" ? "clearance" : "details";
|
||||
const { view, viewer } = useFileViewer();
|
||||
const requestedTab = searchParams.get("tab");
|
||||
const setTab = (tab: string) =>
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
@@ -106,6 +126,48 @@ export default function ContractRequestDetailPage() {
|
||||
{ replace: true },
|
||||
);
|
||||
|
||||
const handleViewFile = (file: NonNullable<Freight.IContract["files"]>[number]) =>
|
||||
view({
|
||||
name: file.name,
|
||||
url: file.signedUrl ?? file.url,
|
||||
mimeType: file.mimeType,
|
||||
});
|
||||
|
||||
const handleDownloadFile = async (
|
||||
file: NonNullable<Freight.IContract["files"]>[number],
|
||||
) => {
|
||||
try {
|
||||
await downloadBookingFile(file.id, file.name);
|
||||
} catch {
|
||||
toast.error("Could not download file.");
|
||||
}
|
||||
};
|
||||
|
||||
const showClearanceTabQuery = Boolean(
|
||||
contract && CLEARANCE_REVIEW_STATUSES.includes(contract.status),
|
||||
);
|
||||
const { data: clearanceView } = useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
||||
queryFn: () => contractsService.getClearance(id!),
|
||||
enabled: Boolean(id) && showClearanceTabQuery,
|
||||
});
|
||||
|
||||
const downloadContractPdf = async () => {
|
||||
if (!contract?.id) return;
|
||||
try {
|
||||
const blob = await contractsService.downloadContractDocument(contract.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
const contractPdf = contract.files?.find((f) => f.code === "contract");
|
||||
a.download = contractPdf?.name ?? `contract-${contract.reference}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
toast.error("Could not download contract PDF.");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -172,13 +234,36 @@ export default function ContractRequestDetailPage() {
|
||||
contract.status === "APPROVED_PENDING_SIGNATURE";
|
||||
|
||||
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
|
||||
const phasedCustoms =
|
||||
contract.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled);
|
||||
const docsPhaseComplete =
|
||||
clearanceView?.milestones?.some(
|
||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||
) ?? false;
|
||||
const clearanceApprovalsLocked = phasedCustoms && docsPhaseComplete;
|
||||
// Once clearance is finalized the tab is informational only — no approve/query.
|
||||
const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
|
||||
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
|
||||
const selfClear = !contract.customsClearingEnabled;
|
||||
// If the tab param points at clearance but the contract isn't in a clearance
|
||||
// phase, fall back to details so we never show an empty tab.
|
||||
const currentTab = activeTab === "clearance" && showClearanceTab ? "clearance" : "details";
|
||||
const files = contract.files ?? [];
|
||||
const contractPdf = files.find((f) => f.code === "contract");
|
||||
const hasContractDocument = Boolean(
|
||||
contractPdf || contract.contractGeneratedAt,
|
||||
);
|
||||
const canViewSign =
|
||||
(contract.status === "CONTRACT_READY" ||
|
||||
contract.status === "SIGNED_CUSTOMER") &&
|
||||
Boolean(contract.contractGeneratedAt);
|
||||
// Resolve the active tab from the URL, falling back to details when the
|
||||
// requested tab isn't available for this contract (e.g. clearance pre-phase).
|
||||
const currentTab =
|
||||
requestedTab === "documents"
|
||||
? "documents"
|
||||
: requestedTab === "customer"
|
||||
? "customer"
|
||||
: requestedTab === "clearance" && showClearanceTab
|
||||
? "clearance"
|
||||
: "details";
|
||||
|
||||
const customerLabel = contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
@@ -254,6 +339,48 @@ export default function ContractRequestDetailPage() {
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
{hasContractDocument && (
|
||||
<Group gap="sm" mt="sm">
|
||||
{canViewSign && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<FileSignature size={15} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/contract-requests/${contract.id}/view`)
|
||||
}
|
||||
>
|
||||
View & sign contract
|
||||
</Button>
|
||||
)}
|
||||
{contractPdf && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<FileText size={15} />}
|
||||
onClick={() =>
|
||||
handleViewFile({
|
||||
...contractPdf,
|
||||
url: fileViewUrl(contractPdf.id),
|
||||
})
|
||||
}
|
||||
>
|
||||
View contract
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<Download size={15} />}
|
||||
onClick={() => void downloadContractPdf()}
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -264,38 +391,83 @@ export default function ContractRequestDetailPage() {
|
||||
description={statusMeta.description}
|
||||
/>
|
||||
|
||||
{showClearanceTab && (
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onChange={(v) => setTab(v ?? "details")}
|
||||
variant="pills"
|
||||
color="edr-green"
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="details" leftSection={<FileText size={16} />}>
|
||||
Details
|
||||
</Tabs.Tab>
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onChange={(v) => setTab(v ?? "details")}
|
||||
variant="pills"
|
||||
color="edr-green"
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="details" leftSection={<LayoutGrid size={16} />}>
|
||||
Details
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="documents"
|
||||
leftSection={<Files size={16} />}
|
||||
rightSection={
|
||||
files.length > 0 ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
{files.length}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
Documents
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="customer" leftSection={<Users size={16} />}>
|
||||
Customer
|
||||
</Tabs.Tab>
|
||||
{showClearanceTab && (
|
||||
<Tabs.Tab
|
||||
value="clearance"
|
||||
leftSection={<ShieldCheck size={16} />}
|
||||
>
|
||||
Clearance Review
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
)}
|
||||
)}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
{currentTab === "clearance" ? (
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
selfClear={selfClear}
|
||||
readOnly={clearanceReadOnly}
|
||||
onChanged={() => refetch()}
|
||||
/>
|
||||
<Stack gap="lg">
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
selfClear={selfClear}
|
||||
readOnly={clearanceReadOnly}
|
||||
phasedCustoms={phasedCustoms}
|
||||
approvalsLocked={clearanceApprovalsLocked}
|
||||
onChanged={() => refetch()}
|
||||
/>
|
||||
{(clearanceView?.workflowFiles?.length ?? 0) > 0 ? (
|
||||
<ClearanceWorkflowFilesPanel
|
||||
files={clearanceView!.workflowFiles!}
|
||||
onView={view}
|
||||
onDownload={(f) => void handleDownloadFile({ id: f.id, name: f.name } as never)}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : currentTab === "documents" ? (
|
||||
<Stack gap="lg">
|
||||
<ContractDocumentsCard
|
||||
files={files}
|
||||
onView={handleViewFile}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
{(clearanceView?.workflowFiles?.length ?? 0) > 0 ? (
|
||||
<ClearanceWorkflowFilesPanel
|
||||
files={clearanceView!.workflowFiles!}
|
||||
title="Customs workflow documents"
|
||||
onView={view}
|
||||
onDownload={(f) => void handleDownloadFile({ id: f.id, name: f.name } as never)}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : currentTab === "customer" ? (
|
||||
<ContractCustomerCard contract={contract} />
|
||||
) : (
|
||||
<Stack gap="lg">
|
||||
<SectionCard icon={RouteIcon} title="Routes">
|
||||
@@ -453,6 +625,8 @@ export default function ContractRequestDetailPage() {
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
{viewer}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user