mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
changes
This commit is contained in:
@@ -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,
|
id: child.id,
|
||||||
name: child.cargoTypeName,
|
name: child.cargoTypeName,
|
||||||
code: child.code,
|
code: child.code,
|
||||||
show_free_text_box: child.showFreeTextBox,
|
|
||||||
unit_of_measure: child.unitOfMeasure ?? null,
|
unit_of_measure: child.unitOfMeasure ?? null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
|||||||
{} as never, // fileUploadSettingsService
|
{} as never, // fileUploadSettingsService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, ruleEngineService };
|
return { service, bookingsRepository, ruleEngineService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
|||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository };
|
return { service, bookingsRepository };
|
||||||
}
|
}
|
||||||
@@ -128,6 +130,8 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
|||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository };
|
return { service, bookingsRepository };
|
||||||
}
|
}
|
||||||
@@ -196,6 +200,8 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
|||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, filesService };
|
return { service, bookingsRepository, filesService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
{} as never, // fileUploadSettingsService
|
{} as never, // fileUploadSettingsService
|
||||||
bookingBatchService as never,
|
bookingBatchService as never,
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, bookingBatchService };
|
return { service, bookingsRepository, bookingBatchService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
|||||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
|
import { BookingClearanceService } from '../contracts/booking-clearance.service';
|
||||||
|
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
|
||||||
|
import { ContractDocPhase } from '@edr/types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingTransitionService {
|
export class BookingTransitionService {
|
||||||
@@ -42,8 +45,16 @@ export class BookingTransitionService {
|
|||||||
private readonly bookingBatchService: BookingBatchService,
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
@Inject(forwardRef(() => BookingsService))
|
@Inject(forwardRef(() => BookingsService))
|
||||||
private readonly bookingsService: BookingsService,
|
private readonly bookingsService: BookingsService,
|
||||||
|
@Inject(forwardRef(() => BookingClearanceService))
|
||||||
|
private readonly bookingClearanceService: BookingClearanceService,
|
||||||
|
@Inject(forwardRef(() => ClearanceWorkflowService))
|
||||||
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private isPhasedGeneralCustoms(booking: Booking): boolean {
|
||||||
|
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
|
||||||
|
}
|
||||||
|
|
||||||
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
|
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
|
||||||
@@ -520,8 +531,19 @@ export class BookingTransitionService {
|
|||||||
note: string | null;
|
note: string | null;
|
||||||
}>;
|
}>;
|
||||||
allApproved: boolean;
|
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);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
if (this.isPhasedGeneralCustoms(booking)) {
|
||||||
|
return this.bookingClearanceService.getClearanceView(bookingId);
|
||||||
|
}
|
||||||
const { inputCode, outputCode, includesCustoms } =
|
const { inputCode, outputCode, includesCustoms } =
|
||||||
clearanceCodesForBooking(booking);
|
clearanceCodesForBooking(booking);
|
||||||
|
|
||||||
@@ -671,6 +693,17 @@ export class BookingTransitionService {
|
|||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'DOCUMENTS_UNDER_REVIEW',
|
status: 'DOCUMENTS_UNDER_REVIEW',
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
if (this.isPhasedGeneralCustoms(booking)) {
|
||||||
|
await this.workflowService.onCustomerDocsUploadedForBooking(
|
||||||
|
bookingId,
|
||||||
|
booking.tradeDirection ?? 'IMPORT',
|
||||||
|
);
|
||||||
|
await this.bookingsRepository.update(bookingId, {
|
||||||
|
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||||
|
} as never);
|
||||||
|
}
|
||||||
|
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,8 +779,30 @@ export class BookingTransitionService {
|
|||||||
'CHANGES_REQUESTED',
|
'CHANGES_REQUESTED',
|
||||||
staffId,
|
staffId,
|
||||||
);
|
);
|
||||||
|
if (this.isPhasedGeneralCustoms(booking) && booking.preClearanceFinalizedAt) {
|
||||||
|
await this.bookingsRepository.update(bookingId, {
|
||||||
|
preClearanceFinalizedAt: null,
|
||||||
|
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
|
||||||
|
} 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.). */
|
/** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */
|
||||||
@@ -781,6 +836,11 @@ export class BookingTransitionService {
|
|||||||
*/
|
*/
|
||||||
async finalizeClearance(bookingId: string): Promise<Booking> {
|
async finalizeClearance(bookingId: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
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']);
|
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
||||||
|
|
||||||
const approved = await this.isClearanceFullyApproved(booking);
|
const approved = await this.isClearanceFullyApproved(booking);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Request,
|
Request,
|
||||||
Res,
|
Res,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
|
UploadedFile,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -19,7 +20,7 @@ import { CurrentUser } from '@edr/api-common';
|
|||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
import { BookingStaff } from '../../common/booking-guards';
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiBody,
|
ApiBody,
|
||||||
@@ -33,6 +34,11 @@ import type { Response } from 'express';
|
|||||||
import { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { BookingTransitionService } from './booking-transition.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 { BookingReferenceDataService } from './booking-reference-data.service';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
||||||
@@ -72,6 +78,7 @@ export class BookingsController {
|
|||||||
private readonly pricingService: BookingPricingService,
|
private readonly pricingService: BookingPricingService,
|
||||||
private readonly transitionService: BookingTransitionService,
|
private readonly transitionService: BookingTransitionService,
|
||||||
private readonly contractService: BookingContractService,
|
private readonly contractService: BookingContractService,
|
||||||
|
private readonly bookingClearanceService: BookingClearanceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -344,6 +351,20 @@ export class BookingsController {
|
|||||||
|
|
||||||
// ── Document clearance (post counter-sign) ────────────────────────────────
|
// ── Document clearance (post counter-sign) ────────────────────────────────
|
||||||
|
|
||||||
|
@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')
|
@Get(':id/clearance')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Document-clearance grid (required docs + upload + GL review status)',
|
summary: 'Document-clearance grid (required docs + upload + GL review status)',
|
||||||
@@ -451,6 +472,160 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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(FileInterceptor('file'))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
async uploadBookingTransitPermit(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingClearanceService.uploadTransitPermit(
|
||||||
|
id,
|
||||||
|
file,
|
||||||
|
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')
|
@Post(':id/staff/request-changes')
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
|
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
|
||||||
@ApiOperation({ summary: 'Staff return booking for customer updates' })
|
@ApiOperation({ summary: 'Staff return booking for customer updates' })
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { ContractRendererService } from '../../contracts/contract-renderer.servi
|
|||||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||||
|
import { ContractsModule } from '../contracts/contracts.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -54,6 +55,8 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
BillingModule,
|
BillingModule,
|
||||||
forwardRef(() => FirstMileModule),
|
forwardRef(() => FirstMileModule),
|
||||||
forwardRef(() => TrainSchedulingModule),
|
forwardRef(() => TrainSchedulingModule),
|
||||||
|
forwardRef(() => ContractsModule),
|
||||||
|
forwardRef(() => ContractsModule),
|
||||||
FilesModule,
|
FilesModule,
|
||||||
MinioModule,
|
MinioModule,
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
|
|||||||
@@ -490,6 +490,15 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
} as never);
|
} 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. */
|
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
|
||||||
async findQueue(options: {
|
async findQueue(options: {
|
||||||
status: string | string[];
|
status: string | string[];
|
||||||
|
|||||||
@@ -72,9 +72,6 @@ export class BookingReferenceCargoTypeChildDto {
|
|||||||
@ApiProperty({ example: 'BULK_COFFEE' })
|
@ApiProperty({ example: 'BULK_COFFEE' })
|
||||||
code!: string;
|
code!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
show_free_text_box!: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false })
|
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false })
|
||||||
unit_of_measure?: CargoUnitOfMeasure | null;
|
unit_of_measure?: CargoUnitOfMeasure | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -447,6 +447,25 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'gl_station_yard_id', type: 'uuid', nullable: true })
|
@Column({ name: 'gl_station_yard_id', type: 'uuid', nullable: true })
|
||||||
glStationYardId?: string | null;
|
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. */
|
/** GL staff user bound to this shipment by the station manager. */
|
||||||
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
|
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
|
||||||
glAssignedStaffId?: string | null;
|
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,632 @@
|
|||||||
|
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 { assertDeclarationFiles, buildWorkflowFiles } 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 documentFileKeys = new Set(documents.map((d) => d.fileKey));
|
||||||
|
const workflowFiles = buildWorkflowFiles(
|
||||||
|
files,
|
||||||
|
booking.tradeDirection ?? 'IMPORT',
|
||||||
|
documentFileKeys,
|
||||||
|
);
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
assertDeclarationFiles(files, tradeDirection);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
await this.filesService.upsertByCode({
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: file.fieldname,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
file: 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 (!file) throw new BadRequestException('No transit permit uploaded');
|
||||||
|
|
||||||
|
await this.filesService.upsertByCode({
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: 'transit_permitted',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
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([
|
||||||
|
'AWAITING_DOCUMENTS',
|
||||||
|
'DOCUMENTS_UNDER_REVIEW',
|
||||||
|
'CLEARANCE_READY',
|
||||||
|
]);
|
||||||
|
const filtered: Booking[] = [];
|
||||||
|
for (const b of candidates) {
|
||||||
|
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
|
||||||
|
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||||
|
const next = this.workflowService.computeNextActionForBooking(b, milestones);
|
||||||
|
if (next?.actor === 'GL_ET') filtered.push(b);
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
async djQueue(): Promise<Booking[]> {
|
||||||
|
const candidates = await this.bookingsRepository.findByStatuses([
|
||||||
|
'AWAITING_DOCUMENTS',
|
||||||
|
'DOCUMENTS_UNDER_REVIEW',
|
||||||
|
'CLEARANCE_READY',
|
||||||
|
]);
|
||||||
|
const filtered: Booking[] = [];
|
||||||
|
for (const b of candidates) {
|
||||||
|
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
|
||||||
|
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||||
|
const pending = this.workflowService.djPendingMilestoneCodes(milestones);
|
||||||
|
if (b.roHoldReason || pending || this.workflowService.computeNextActionForBooking(b, milestones)?.actor === 'GL_DJ') {
|
||||||
|
filtered.push(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import {
|
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. */
|
/** Seed the post-booking milestones onto a freshly created booking. */
|
||||||
async seedPostBookingMilestones(
|
async seedPostBookingMilestones(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
@@ -94,7 +103,7 @@ export class ClearanceMilestoneService {
|
|||||||
throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
|
throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
|
||||||
}
|
}
|
||||||
if (milestone.status === 'COMPLETED') {
|
if (milestone.status === 'COMPLETED') {
|
||||||
throw new BadRequestException(`Milestone ${code} is already completed.`);
|
return milestone;
|
||||||
}
|
}
|
||||||
milestone.status = 'COMPLETED';
|
milestone.status = 'COMPLETED';
|
||||||
milestone.triggeredAt = new Date();
|
milestone.triggeredAt = new Date();
|
||||||
@@ -178,7 +187,7 @@ export class ClearanceMilestoneService {
|
|||||||
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
|
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
|
||||||
}
|
}
|
||||||
if (milestone.status === 'COMPLETED') {
|
if (milestone.status === 'COMPLETED') {
|
||||||
throw new BadRequestException(`Milestone ${code} is already completed.`);
|
return milestone;
|
||||||
}
|
}
|
||||||
milestone.status = 'COMPLETED';
|
milestone.status = 'COMPLETED';
|
||||||
milestone.triggeredAt = new Date();
|
milestone.triggeredAt = new Date();
|
||||||
@@ -199,6 +208,27 @@ export class ClearanceMilestoneService {
|
|||||||
return this.repo.save(milestone);
|
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.). */
|
/** Complete a contract milestone with structured metadata (duty advice, etc.). */
|
||||||
async completeWithMetadataForContract(
|
async completeWithMetadataForContract(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
@@ -212,7 +242,7 @@ export class ClearanceMilestoneService {
|
|||||||
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
|
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
|
||||||
}
|
}
|
||||||
if (milestone.status === 'COMPLETED') {
|
if (milestone.status === 'COMPLETED') {
|
||||||
throw new BadRequestException(`Milestone ${code} is already completed.`);
|
return milestone;
|
||||||
}
|
}
|
||||||
milestone.status = 'COMPLETED';
|
milestone.status = 'COMPLETED';
|
||||||
milestone.triggeredAt = new Date();
|
milestone.triggeredAt = new Date();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
|
|||||||
import type { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
import type { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||||
import type { Contract } from './entities/contract.entity';
|
import type { Contract } from './entities/contract.entity';
|
||||||
import type { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
import type { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||||
|
import type { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
|
||||||
function ms(
|
function ms(
|
||||||
code: string,
|
code: string,
|
||||||
@@ -37,15 +38,18 @@ function makeService(milestones: ClearanceMilestone[]) {
|
|||||||
};
|
};
|
||||||
const milestoneService = {
|
const milestoneService = {
|
||||||
listForContract: jest.fn().mockResolvedValue(milestones),
|
listForContract: jest.fn().mockResolvedValue(milestones),
|
||||||
|
listForBooking: jest.fn().mockResolvedValue(milestones),
|
||||||
skipForContract: jest.fn(),
|
skipForContract: jest.fn(),
|
||||||
completeForContract: jest.fn(),
|
completeForContract: jest.fn(),
|
||||||
completeWithMetadataForContract: jest.fn(),
|
completeWithMetadataForContract: jest.fn(),
|
||||||
};
|
};
|
||||||
|
const bookingsRepository = { update: jest.fn() };
|
||||||
const service = new ClearanceWorkflowService(
|
const service = new ClearanceWorkflowService(
|
||||||
contractsRepository as never,
|
contractsRepository as never,
|
||||||
milestoneService as never,
|
milestoneService as never,
|
||||||
|
bookingsRepository as never,
|
||||||
);
|
);
|
||||||
return { service, milestoneService, contractsRepository };
|
return { service, milestoneService, contractsRepository, bookingsRepository };
|
||||||
}
|
}
|
||||||
|
|
||||||
const importContract = {
|
const importContract = {
|
||||||
@@ -199,7 +203,7 @@ describe('ClearanceWorkflowService', () => {
|
|||||||
expect(next?.milestoneCode).toBe('TRANSIT_PERMIT_UPLOADED');
|
expect(next?.milestoneCode).toBe('TRANSIT_PERMIT_UPLOADED');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prompts DJ for DO then ET booking when pre-booking complete', () => {
|
it('prompts ET to finalize pre-clearance after transit permit', () => {
|
||||||
const milestones = [
|
const milestones = [
|
||||||
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
|
||||||
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
|
||||||
@@ -211,6 +215,26 @@ describe('ClearanceWorkflowService', () => {
|
|||||||
];
|
];
|
||||||
const cycle = { dutyRequired: false } as ContractClearanceCycle;
|
const cycle = { dutyRequired: false } as ContractClearanceCycle;
|
||||||
const { service } = makeService(milestones);
|
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);
|
const djNext = service.computeNextAction(importContract, cycle, milestones);
|
||||||
expect(djNext?.actor).toBe('GL_DJ');
|
expect(djNext?.actor).toBe('GL_DJ');
|
||||||
|
|
||||||
@@ -281,4 +305,27 @@ describe('ClearanceWorkflowService', () => {
|
|||||||
).toBe('DO_COLLECTED');
|
).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { splitMilestones } from './clearance-milestone.catalog';
|
|||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||||
import { ClearanceMilestone } from './entities/clearance-milestone.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 type ClearanceActorRole = 'CUSTOMER' | 'GL_ET' | 'GL_DJ' | 'OPERATIONS';
|
||||||
|
|
||||||
@@ -29,19 +33,45 @@ export class ClearanceWorkflowService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly contractsRepository: ContractsRepository,
|
private readonly contractsRepository: ContractsRepository,
|
||||||
private readonly milestoneService: ClearanceMilestoneService,
|
private readonly milestoneService: ClearanceMilestoneService,
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
boundaryMilestone(tradeDirection: string): string {
|
boundaryMilestone(tradeDirection: string): string {
|
||||||
return tradeDirection === 'IMPORT' ? IMPORT_BOUNDARY : EXPORT_BOUNDARY;
|
return tradeDirection === 'IMPORT' ? IMPORT_BOUNDARY : EXPORT_BOUNDARY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Contract scope (ONE_TIME) ─────────────────────────────────────────────
|
||||||
|
|
||||||
async listMilestones(contractId: string): Promise<ClearanceMilestone[]> {
|
async listMilestones(contractId: string): Promise<ClearanceMilestone[]> {
|
||||||
return this.milestoneService.listForContract(contractId);
|
return this.milestoneService.listForContract(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listMilestonesForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
|
||||||
|
return this.milestoneService.listForBooking(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
async isBoundaryComplete(contractId: string, tradeDirection: string): Promise<boolean> {
|
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 code = this.boundaryMilestone(tradeDirection);
|
||||||
const milestones = await this.listMilestones(contractId);
|
|
||||||
const m = milestones.find((x) => x.milestoneCode === code);
|
const m = milestones.find((x) => x.milestoneCode === code);
|
||||||
return m?.status === 'COMPLETED';
|
return m?.status === 'COMPLETED';
|
||||||
}
|
}
|
||||||
@@ -59,14 +89,38 @@ export class ClearanceWorkflowService {
|
|||||||
contractId: string,
|
contractId: string,
|
||||||
tradeDirection: string,
|
tradeDirection: string,
|
||||||
targetCode: 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> {
|
): Promise<void> {
|
||||||
const { preBooking } = splitMilestones(tradeDirection);
|
const { preBooking } = splitMilestones(tradeDirection);
|
||||||
const milestones = await this.listMilestones(contractId);
|
|
||||||
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||||
const targetIdx = preBooking.findIndex((d) => d.code === targetCode);
|
const targetIdx = preBooking.findIndex((d) => d.code === targetCode);
|
||||||
if (targetIdx < 0) return;
|
if (targetIdx < 0) return;
|
||||||
|
|
||||||
for (let i = 0; i < targetIdx; i++) {
|
for (let i = 0; i < targetIdx; i++) {
|
||||||
|
|
||||||
const code = preBooking[i]!.code;
|
const code = preBooking[i]!.code;
|
||||||
const m = byCode.get(code);
|
const m = byCode.get(code);
|
||||||
if (!m) continue;
|
if (!m) continue;
|
||||||
@@ -85,6 +139,12 @@ export class ClearanceWorkflowService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async skipMilestonesForBooking(bookingId: string, codes: string[]): Promise<void> {
|
||||||
|
for (const code of codes) {
|
||||||
|
await this.milestoneService.skipForBooking(bookingId, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async completeMilestone(
|
async completeMilestone(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
code: string,
|
code: string,
|
||||||
@@ -102,6 +162,23 @@ export class ClearanceWorkflowService {
|
|||||||
return this.milestoneService.completeForContract(contractId, code, 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> {
|
async onCustomerDocsUploaded(contractId: string, tradeDirection: string): Promise<void> {
|
||||||
const uploaded =
|
const uploaded =
|
||||||
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
|
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
|
||||||
@@ -109,24 +186,52 @@ export class ClearanceWorkflowService {
|
|||||||
await this.completeMilestone(contractId, 'PENDING_DOCUMENT_REVIEW');
|
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> {
|
async onAllDocsApproved(contractId: string): Promise<void> {
|
||||||
await this.completeMilestone(contractId, 'DOCUMENTS_APPROVED');
|
await this.completeMilestone(contractId, 'DOCUMENTS_APPROVED');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async onAllDocsApprovedForBooking(bookingId: string): Promise<void> {
|
||||||
|
await this.completeMilestoneForBooking(bookingId, 'DOCUMENTS_APPROVED');
|
||||||
|
}
|
||||||
|
|
||||||
async onDeclarationUploaded(contractId: string, userId?: string): Promise<void> {
|
async onDeclarationUploaded(contractId: string, userId?: string): Promise<void> {
|
||||||
await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE');
|
await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE');
|
||||||
await this.completeMilestone(contractId, 'DECLARED', userId);
|
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> {
|
async onDutySkipped(contractId: string): Promise<void> {
|
||||||
await this.skipMilestones(contractId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']);
|
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> {
|
async onExportReleased(contractId: string, userId?: string): Promise<void> {
|
||||||
await this.completeMilestone(contractId, 'EXPORT_RELEASED', userId);
|
await this.completeMilestone(contractId, 'EXPORT_RELEASED', userId);
|
||||||
await this.markReadyForBooking(contractId);
|
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> {
|
async markReadyForBooking(contractId: string): Promise<void> {
|
||||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
@@ -144,37 +249,92 @@ export class ClearanceWorkflowService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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(
|
resolvePhase(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
cycle: ContractClearanceCycle | null,
|
cycle: ContractClearanceCycle | null,
|
||||||
milestones: ClearanceMilestone[],
|
milestones: ClearanceMilestone[],
|
||||||
): ContractDocPhase {
|
): ContractDocPhase {
|
||||||
if (cycle?.currentPhase) {
|
const meta: ClearanceMetaState = {
|
||||||
return cycle.currentPhase as ContractDocPhase;
|
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.inferPhase(contract, cycle, milestones);
|
return this.inferPhaseFromMeta(tradeDirection, meta, milestones);
|
||||||
}
|
}
|
||||||
|
|
||||||
inferPhase(
|
inferPhase(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
cycle: ContractClearanceCycle | null,
|
cycle: ContractClearanceCycle | null,
|
||||||
milestones: ClearanceMilestone[],
|
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 {
|
): ContractDocPhase {
|
||||||
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||||
const isDone = (code: string) =>
|
const isDone = (code: string) =>
|
||||||
byCode.get(code)?.status === 'COMPLETED' || byCode.get(code)?.status === 'SKIPPED';
|
byCode.get(code)?.status === 'COMPLETED' || byCode.get(code)?.status === 'SKIPPED';
|
||||||
|
|
||||||
const docUploaded =
|
const docUploaded =
|
||||||
contract.tradeDirection === 'IMPORT'
|
tradeDirection === 'IMPORT'
|
||||||
? isDone(IMPORT_DOC_UPLOADED)
|
? isDone(IMPORT_DOC_UPLOADED)
|
||||||
: isDone(EXPORT_DOC_UPLOADED);
|
: isDone(EXPORT_DOC_UPLOADED);
|
||||||
|
|
||||||
if (!docUploaded) return ContractDocPhase.CustomerIntake;
|
if (!docUploaded) return ContractDocPhase.CustomerIntake;
|
||||||
if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview;
|
if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview;
|
||||||
|
|
||||||
if (contract.tradeDirection === 'EXPORT') {
|
if (tradeDirection === 'EXPORT') {
|
||||||
if (!isDone('RELEASE_ORDER_SECURED')) {
|
if (!isDone('RELEASE_ORDER_SECURED')) {
|
||||||
if (cycle?.roHoldReason) return ContractDocPhase.GlDjCollection;
|
|
||||||
return ContractDocPhase.GlDjCollection;
|
return ContractDocPhase.GlDjCollection;
|
||||||
}
|
}
|
||||||
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
||||||
@@ -182,12 +342,12 @@ export class ClearanceWorkflowService {
|
|||||||
return ContractDocPhase.GlEtPostClearance;
|
return ContractDocPhase.GlEtPostClearance;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import
|
|
||||||
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
||||||
if (cycle?.dutyRequired === true && !isDone('DUTY_TAX_PAID')) {
|
if (meta.dutyRequired === true && !isDone('DUTY_TAX_PAID')) {
|
||||||
return ContractDocPhase.CustomerDuty;
|
return ContractDocPhase.CustomerDuty;
|
||||||
}
|
}
|
||||||
if (!isDone('TRANSIT_PERMIT_UPLOADED')) return ContractDocPhase.GlEtPostClearance;
|
if (!isDone('TRANSIT_PERMIT_UPLOADED')) return ContractDocPhase.GlEtPostClearance;
|
||||||
|
if (!meta.preClearanceFinalizedAt) return ContractDocPhase.GlEtPostClearance;
|
||||||
if (!isDone(IMPORT_BOUNDARY)) return ContractDocPhase.GlDjCollection;
|
if (!isDone(IMPORT_BOUNDARY)) return ContractDocPhase.GlDjCollection;
|
||||||
return ContractDocPhase.GlEtPostClearance;
|
return ContractDocPhase.GlEtPostClearance;
|
||||||
}
|
}
|
||||||
@@ -197,12 +357,42 @@ export class ClearanceWorkflowService {
|
|||||||
cycle: ContractClearanceCycle | null,
|
cycle: ContractClearanceCycle | null,
|
||||||
milestones: ClearanceMilestone[],
|
milestones: ClearanceMilestone[],
|
||||||
): ClearanceNextAction | null {
|
): ClearanceNextAction | null {
|
||||||
if (cycle?.roHoldReason) {
|
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 {
|
return {
|
||||||
actor: 'GL_DJ',
|
actor: 'GL_DJ',
|
||||||
action: 'Re-upload Release Order or request port amendment',
|
action: 'Re-upload Release Order or request port amendment',
|
||||||
milestoneCode: 'RELEASE_ORDER_SECURED',
|
milestoneCode: 'RELEASE_ORDER_SECURED',
|
||||||
blockedReason: cycle.roHoldReason,
|
blockedReason: meta.roHoldReason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +407,7 @@ export class ClearanceWorkflowService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const docCode =
|
const docCode =
|
||||||
contract.tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
|
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
|
||||||
|
|
||||||
if (pending(docCode) || !isDone(docCode)) {
|
if (pending(docCode) || !isDone(docCode)) {
|
||||||
return {
|
return {
|
||||||
@@ -235,7 +425,12 @@ export class ClearanceWorkflowService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contract.tradeDirection === 'EXPORT') {
|
const terminalAction =
|
||||||
|
terminalScope === 'contract'
|
||||||
|
? 'Create shipment booking'
|
||||||
|
: 'Proceed to request operation';
|
||||||
|
|
||||||
|
if (tradeDirection === 'EXPORT') {
|
||||||
if (!isDone('RELEASE_ORDER_SECURED')) {
|
if (!isDone('RELEASE_ORDER_SECURED')) {
|
||||||
return {
|
return {
|
||||||
actor: 'GL_DJ',
|
actor: 'GL_DJ',
|
||||||
@@ -258,13 +453,12 @@ export class ClearanceWorkflowService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
actor: 'GL_ET',
|
actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER',
|
||||||
action: 'Create shipment booking',
|
action: terminalAction,
|
||||||
milestoneCode: EXPORT_BOUNDARY,
|
milestoneCode: EXPORT_BOUNDARY,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import
|
|
||||||
if (!isDone('DECLARED')) {
|
if (!isDone('DECLARED')) {
|
||||||
return {
|
return {
|
||||||
actor: 'GL_ET',
|
actor: 'GL_ET',
|
||||||
@@ -273,7 +467,7 @@ export class ClearanceWorkflowService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cycle?.dutyRequired === null || cycle?.dutyRequired === undefined) {
|
if (meta.dutyRequired === null || meta.dutyRequired === undefined) {
|
||||||
return {
|
return {
|
||||||
actor: 'GL_ET',
|
actor: 'GL_ET',
|
||||||
action: 'Set whether duty/tax applies',
|
action: 'Set whether duty/tax applies',
|
||||||
@@ -281,7 +475,7 @@ export class ClearanceWorkflowService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cycle.dutyRequired && !isDone('DUTY_TAX_PAID')) {
|
if (meta.dutyRequired && !isDone('DUTY_TAX_PAID')) {
|
||||||
if (!isDone('DUTY_TAXES_ADVISED')) {
|
if (!isDone('DUTY_TAXES_ADVISED')) {
|
||||||
return {
|
return {
|
||||||
actor: 'GL_ET',
|
actor: 'GL_ET',
|
||||||
@@ -304,6 +498,14 @@ export class ClearanceWorkflowService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!meta.preClearanceFinalizedAt) {
|
||||||
|
return {
|
||||||
|
actor: 'GL_ET',
|
||||||
|
action: 'Finalize pre-clearance',
|
||||||
|
milestoneCode: 'TRANSIT_PERMIT_UPLOADED',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!isDone(IMPORT_BOUNDARY)) {
|
if (!isDone(IMPORT_BOUNDARY)) {
|
||||||
return {
|
return {
|
||||||
actor: 'GL_DJ',
|
actor: 'GL_DJ',
|
||||||
@@ -313,19 +515,17 @@ export class ClearanceWorkflowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actor: 'GL_ET',
|
actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER',
|
||||||
action: 'Create shipment booking',
|
action: terminalAction,
|
||||||
milestoneCode: IMPORT_BOUNDARY,
|
milestoneCode: IMPORT_BOUNDARY,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Contracts where the next pending milestone is owned by ET. */
|
|
||||||
etPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null {
|
etPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null {
|
||||||
const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'ET');
|
const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'ET');
|
||||||
return pending?.milestoneCode ?? null;
|
return pending?.milestoneCode ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Contracts where the next pending milestone is owned by DJ. */
|
|
||||||
djPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null {
|
djPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null {
|
||||||
const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'DJ');
|
const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'DJ');
|
||||||
return pending?.milestoneCode ?? null;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -196,9 +196,11 @@ export class ContractBookingService {
|
|||||||
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||||
} as never);
|
} as never);
|
||||||
} else if (generalCustoms) {
|
} else if (generalCustoms) {
|
||||||
// Per-booking clearance: seed post-booking milestones on the booking (no
|
// Per-booking clearance: seed full milestone timeline on the booking.
|
||||||
// cycle needed) and leave the contract active. The booking now drives its
|
await this.milestoneService.seedPreBookingMilestonesOnBooking(
|
||||||
// own clearance via the booking-level pipeline.
|
booking.id,
|
||||||
|
contract.tradeDirection,
|
||||||
|
);
|
||||||
await this.milestoneService.seedPostBookingMilestones(
|
await this.milestoneService.seedPostBookingMilestones(
|
||||||
booking.id,
|
booking.id,
|
||||||
contract.tradeDirection,
|
contract.tradeDirection,
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import { ContractsService, PaginatedContracts } from './contracts.service';
|
|||||||
import { contractClearanceCodes } from './contract-clearance.util';
|
import { contractClearanceCodes } from './contract-clearance.util';
|
||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
|
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||||
|
import { assertDeclarationFiles, buildWorkflowFiles } from './phased-clearance.util';
|
||||||
|
|
||||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||||
|
|
||||||
@@ -63,6 +65,14 @@ export interface ContractClearanceView {
|
|||||||
vesselDepartureDate?: string | null;
|
vesselDepartureDate?: string | null;
|
||||||
roAmendmentRequestedAt?: string | null;
|
roAmendmentRequestedAt?: string | null;
|
||||||
bookingReady?: boolean;
|
bookingReady?: 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()
|
@Injectable()
|
||||||
@@ -77,18 +87,49 @@ export class ContractClearanceService {
|
|||||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private isPhasedCustoms(contract: Contract): boolean {
|
||||||
|
return contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME';
|
||||||
|
}
|
||||||
|
|
||||||
private assertPhasedCustoms(contract: Contract): void {
|
private assertPhasedCustoms(contract: Contract): void {
|
||||||
if (!contract.customsClearingEnabled) {
|
if (!this.isPhasedCustoms(contract)) {
|
||||||
throw new BadRequestException('Phased clearance applies only to customs contracts.');
|
throw new BadRequestException(
|
||||||
|
'Phased clearance (Phase 1) applies to one-time customs contracts.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (contract.contractKind !== 'ONE_TIME') {
|
}
|
||||||
throw new BadRequestException('Phased clearance (Phase 1) applies to one-time 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). */
|
/** The pre-booking clearance document grid for a contract (Path B). */
|
||||||
async getClearanceView(contractId: string): Promise<ContractClearanceView> {
|
async getClearanceView(contractId: string): Promise<ContractClearanceView> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
let contract = await this.contractsService.findById(contractId);
|
||||||
const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract);
|
const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract);
|
||||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
|
||||||
@@ -151,12 +192,20 @@ export class ContractClearanceService {
|
|||||||
|
|
||||||
const allApproved = await this.isClearanceFullyApproved(contract);
|
const allApproved = await this.isClearanceFullyApproved(contract);
|
||||||
const milestones = await this.workflowService.listMilestones(contractId);
|
const milestones = await this.workflowService.listMilestones(contractId);
|
||||||
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
|
let boundary = await this.workflowService.isBoundaryComplete(
|
||||||
const nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
|
|
||||||
const boundary = await this.workflowService.isBoundaryComplete(
|
|
||||||
contractId,
|
contractId,
|
||||||
contract.tradeDirection,
|
contract.tradeDirection,
|
||||||
);
|
);
|
||||||
|
contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary);
|
||||||
|
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
|
||||||
|
const nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
|
||||||
|
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
||||||
|
const documentFileKeys = new Set(documents.map((d) => d.fileKey));
|
||||||
|
const workflowFiles = buildWorkflowFiles(
|
||||||
|
files,
|
||||||
|
contract.tradeDirection ?? 'IMPORT',
|
||||||
|
documentFileKeys,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contractId,
|
contractId,
|
||||||
@@ -187,6 +236,34 @@ export class ContractClearanceService {
|
|||||||
? cycle.roAmendmentRequestedAt.toISOString()
|
? cycle.roAmendmentRequestedAt.toISOString()
|
||||||
: null,
|
: null,
|
||||||
bookingReady: boundary,
|
bookingReady: boundary,
|
||||||
|
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
|
||||||
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,6 +539,12 @@ export class ContractClearanceService {
|
|||||||
} as never);
|
} as never);
|
||||||
if (cycle) {
|
if (cycle) {
|
||||||
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
|
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
|
||||||
|
if (this.isPhasedCustoms(contract) && cycle.preClearanceFinalizedAt) {
|
||||||
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
|
preClearanceFinalizedAt: null,
|
||||||
|
currentPhase: ContractDocPhase.GlEtPostClearance,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (status === 'APPROVED') {
|
} else if (status === 'APPROVED') {
|
||||||
await this.bumpToUnderReviewWhenFullyApproved(contractId);
|
await this.bumpToUnderReviewWhenFullyApproved(contractId);
|
||||||
@@ -514,8 +597,10 @@ export class ContractClearanceService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GL ET finalizes Path B pre-booking clearance: requires every customer
|
* GL ET finalizes Path B pre-booking clearance: requires every customer
|
||||||
* document APPROVED and required output docs present → CLEARANCE_READY_FOR_BOOKING
|
* document APPROVED. For phased customs (ONE_TIME), document review completes
|
||||||
* (GL then creates the booking). Rejects self-clearance (Path A) contracts.
|
* 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> {
|
async finalize(contractId: string): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
@@ -533,6 +618,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);
|
const { outputCode } = contractClearanceCodes(contract);
|
||||||
if (outputCode) {
|
if (outputCode) {
|
||||||
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
|
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
|
||||||
@@ -661,6 +764,24 @@ export class ContractClearanceService {
|
|||||||
|
|
||||||
// ── Phased clearance actions (ONE_TIME customs, Phase 1) ───────────────────
|
// ── 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(
|
async uploadDeclaration(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
@@ -668,15 +789,17 @@ export class ContractClearanceService {
|
|||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
this.assertPhasedCustoms(contract);
|
this.assertPhasedCustoms(contract);
|
||||||
|
await this.ensureDeclarationPrerequisites(contractId, contract);
|
||||||
await this.workflowService.assertPriorComplete(
|
await this.workflowService.assertPriorComplete(
|
||||||
contractId,
|
contractId,
|
||||||
contract.tradeDirection,
|
contract.tradeDirection,
|
||||||
'DECLARED',
|
'UNDER_CUSTOMS_CLEARANCE',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
throw new BadRequestException('No declaration documents uploaded');
|
throw new BadRequestException('No declaration documents uploaded');
|
||||||
}
|
}
|
||||||
|
assertDeclarationFiles(files, contract.tradeDirection);
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
await this.filesService.upsertByCode({
|
await this.filesService.upsertByCode({
|
||||||
@@ -706,6 +829,7 @@ export class ContractClearanceService {
|
|||||||
contractId: string,
|
contractId: string,
|
||||||
dto: AdviseContractDutyDto,
|
dto: AdviseContractDutyDto,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
|
attachment?: Express.Multer.File,
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
this.assertPhasedCustoms(contract);
|
this.assertPhasedCustoms(contract);
|
||||||
@@ -730,6 +854,15 @@ export class ContractClearanceService {
|
|||||||
if (dto.amount == null || dto.amount < 0) {
|
if (dto.amount == null || dto.amount < 0) {
|
||||||
throw new BadRequestException('Duty amount is required when duty applies.');
|
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(
|
await this.milestoneService.adviseDutyForContract(
|
||||||
contractId,
|
contractId,
|
||||||
{
|
{
|
||||||
@@ -808,13 +941,40 @@ export class ContractClearanceService {
|
|||||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
if (cycle) {
|
if (cycle) {
|
||||||
await this.contractsRepository.updateCycle(cycle.id, {
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
currentPhase: ContractDocPhase.GlDjCollection,
|
currentPhase: ContractDocPhase.GlEtPostClearance,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.contractsService.findById(contractId);
|
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(
|
async uploadDeliveryOrder(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
@@ -825,6 +985,14 @@ export class ContractClearanceService {
|
|||||||
if (contract.tradeDirection !== 'IMPORT') {
|
if (contract.tradeDirection !== 'IMPORT') {
|
||||||
throw new BadRequestException('Delivery Order applies only to import contracts.');
|
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');
|
await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DO_COLLECTED');
|
||||||
|
|
||||||
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
||||||
|
|||||||
@@ -172,15 +172,11 @@ export class ContractTransitionService {
|
|||||||
const cargoTypeId =
|
const cargoTypeId =
|
||||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null;
|
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null;
|
||||||
|
|
||||||
// US-06 routing: bulk always needs director approval; container needs it only
|
// Resolve the chain from the cargo type flag only.
|
||||||
// when its cargo type flags it. Resolve the chain via the same approval_rules
|
let requiresDirectorApproval = false;
|
||||||
// source of truth the booking flow uses (no booking row is created here).
|
|
||||||
let requiresDirectorApproval = contract.freightType === 'BULK';
|
|
||||||
if (cargoTypeId) {
|
if (cargoTypeId) {
|
||||||
const cargoType = await this.cargoTypesService.findById(cargoTypeId);
|
const cargoType = await this.cargoTypesService.findById(cargoTypeId);
|
||||||
if (cargoType?.requiresDirectorApproval) {
|
requiresDirectorApproval = cargoType?.requiresDirectorApproval ?? false;
|
||||||
requiresDirectorApproval = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const chain = await this.approvalRulesService.findChain(requiresDirectorApproval);
|
const chain = await this.approvalRulesService.findChain(requiresDirectorApproval);
|
||||||
|
|||||||
@@ -43,11 +43,13 @@ import { ContractsService } from './contracts.service';
|
|||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
import { ContractTransitionService } from './contract-transition.service';
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
import { ContractClearanceService } from './contract-clearance.service';
|
import { ContractClearanceService } from './contract-clearance.service';
|
||||||
|
import { BookingClearanceService } from './booking-clearance.service';
|
||||||
import { ContractBookingService } from './contract-booking.service';
|
import { ContractBookingService } from './contract-booking.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { GlOperationsService } from './gl-operations.service';
|
import { GlOperationsService } from './gl-operations.service';
|
||||||
import { BookingRequestService } from './booking-request.service';
|
import { BookingRequestService } from './booking-request.service';
|
||||||
import { SignaturesService } from '../signatures/signatures.service';
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
|
import { BookingsService } from '../bookings/bookings.service';
|
||||||
import { CreateContractDto } from './dto/create-contract.dto';
|
import { CreateContractDto } from './dto/create-contract.dto';
|
||||||
import { UpdateContractDto } from './dto/update-contract.dto';
|
import { UpdateContractDto } from './dto/update-contract.dto';
|
||||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||||
@@ -92,6 +94,8 @@ export class ContractsController {
|
|||||||
private readonly glOperationsService: GlOperationsService,
|
private readonly glOperationsService: GlOperationsService,
|
||||||
private readonly bookingRequestService: BookingRequestService,
|
private readonly bookingRequestService: BookingRequestService,
|
||||||
private readonly signaturesService: SignaturesService,
|
private readonly signaturesService: SignaturesService,
|
||||||
|
private readonly bookingClearanceService: BookingClearanceService,
|
||||||
|
private readonly bookingsService: BookingsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── Shipment / booking requests (GENERAL + customs, Path B) ───────────────
|
// ── Shipment / booking requests (GENERAL + customs, Path B) ───────────────
|
||||||
@@ -486,7 +490,10 @@ export class ContractsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/clearance/review')
|
@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)' })
|
@ApiOperation({ summary: 'GL ET reviews a clearance document (Approve | Query)' })
|
||||||
reviewClearanceDocument(
|
reviewClearanceDocument(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@@ -536,13 +543,39 @@ export class ContractsController {
|
|||||||
|
|
||||||
@Post(':id/clearance/duty')
|
@Post(':id/clearance/duty')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise)
|
||||||
@ApiOperation({ summary: 'GL ET sets duty/tax requirement and advises amount' })
|
@UseInterceptors(FileInterceptor('attachment'))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'GL ET sets duty/tax requirement and advises amount with notice attachment' })
|
||||||
adviseContractDuty(
|
adviseContractDuty(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: AdviseContractDutyDto,
|
@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,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
return this.clearanceService.adviseDuty(id, dto, resolveAuthUserId(user));
|
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')
|
@Post(':id/clearance/duty-slip')
|
||||||
@@ -846,11 +879,16 @@ export class ContractsController {
|
|||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' })
|
@ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' })
|
||||||
uploadDutySlip(
|
async uploadDutySlip(
|
||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@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')
|
@Get('bookings/:bookingId/incidents')
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { ContractsRepository } from './contracts.repository';
|
|||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
import { ContractTransitionService } from './contract-transition.service';
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
import { ContractClearanceService } from './contract-clearance.service';
|
import { ContractClearanceService } from './contract-clearance.service';
|
||||||
|
import { BookingClearanceService } from './booking-clearance.service';
|
||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
import { ContractBookingService } from './contract-booking.service';
|
import { ContractBookingService } from './contract-booking.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
@@ -87,6 +88,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
ContractTransitionService,
|
ContractTransitionService,
|
||||||
ContractClearanceService,
|
ContractClearanceService,
|
||||||
ClearanceWorkflowService,
|
ClearanceWorkflowService,
|
||||||
|
BookingClearanceService,
|
||||||
ContractBookingService,
|
ContractBookingService,
|
||||||
ClearanceMilestoneService,
|
ClearanceMilestoneService,
|
||||||
GlOperationsService,
|
GlOperationsService,
|
||||||
@@ -106,6 +108,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
ContractTransitionService,
|
ContractTransitionService,
|
||||||
ContractClearanceService,
|
ContractClearanceService,
|
||||||
ClearanceWorkflowService,
|
ClearanceWorkflowService,
|
||||||
|
BookingClearanceService,
|
||||||
ContractBookingService,
|
ContractBookingService,
|
||||||
ClearanceMilestoneService,
|
ClearanceMilestoneService,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -547,6 +547,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
| 'roHoldReason'
|
| 'roHoldReason'
|
||||||
| 'currentPhase'
|
| 'currentPhase'
|
||||||
| 'status'
|
| 'status'
|
||||||
|
| 'preClearanceFinalizedAt'
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -51,4 +51,8 @@ export class ContractClearanceCycle extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'current_phase', type: 'varchar', length: 40, nullable: true })
|
@Column({ name: 'current_phase', type: 'varchar', length: 40, nullable: true })
|
||||||
currentPhase?: string | null;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
catalogEntriesForTradeDirection,
|
||||||
|
type ClearanceWorkflowFile,
|
||||||
|
} from '@edr/types';
|
||||||
|
|
||||||
|
const IMPORT_DECLARATION_CODES = new Set(['im4', 'im5']);
|
||||||
|
const EXPORT_DECLARATION_CODES = new Set(['ex3', 'ex8']);
|
||||||
|
|
||||||
|
/** Require at least one declaration file for the trade direction (IM4 or IM5, EX3 or EX8). */
|
||||||
|
export function assertDeclarationFiles(
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
tradeDirection: string,
|
||||||
|
): void {
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new BadRequestException('No declaration documents uploaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed =
|
||||||
|
tradeDirection === 'EXPORT' ? EXPORT_DECLARATION_CODES : IMPORT_DECLARATION_CODES;
|
||||||
|
const labels = tradeDirection === 'EXPORT' ? 'EX3 or EX8' : 'IM4 or IM5';
|
||||||
|
|
||||||
|
const uploaded = new Set(files.map((f) => f.fieldname?.toLowerCase()));
|
||||||
|
const hasValid = [...allowed].some((code) => uploaded.has(code));
|
||||||
|
if (!hasValid) {
|
||||||
|
throw new BadRequestException(`Upload at least one declaration document (${labels}).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
documentFileKeys: Set<string> = new Set(),
|
||||||
|
): ClearanceWorkflowFile[] {
|
||||||
|
const fileByCode = new Map(
|
||||||
|
files.filter((f) => f.code).map((f) => [f.code as string, f]),
|
||||||
|
);
|
||||||
|
const out: ClearanceWorkflowFile[] = [];
|
||||||
|
|
||||||
|
for (const entry of catalogEntriesForTradeDirection(tradeDirection)) {
|
||||||
|
if (documentFileKeys.has(entry.code)) continue;
|
||||||
|
const file = fileByCode.get(entry.code) ?? null;
|
||||||
|
if (!file) continue;
|
||||||
|
out.push({
|
||||||
|
code: entry.code,
|
||||||
|
label: entry.label,
|
||||||
|
uploadedBy: entry.uploadedBy,
|
||||||
|
category: entry.category,
|
||||||
|
file: { id: file.id, name: file.name, url: file.url },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -1,19 +1,31 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
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 {
|
export class CreateRouteMilestoneDto {
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
yardId!: string;
|
yardId!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Km from the previous stop (0 for origin)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
distanceKm?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateRouteDto {
|
export class CreateRouteDto {
|
||||||
@ApiProperty()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(120)
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ type: [CreateRouteMilestoneDto] })
|
@ApiProperty({ type: [CreateRouteMilestoneDto] })
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ArrayMinSize(2)
|
@ArrayMinSize(2)
|
||||||
@@ -21,8 +33,8 @@ export class CreateRouteDto {
|
|||||||
@Type(() => CreateRouteMilestoneDto)
|
@Type(() => CreateRouteMilestoneDto)
|
||||||
milestones!: CreateRouteMilestoneDto[];
|
milestones!: CreateRouteMilestoneDto[];
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'])
|
||||||
isActive?: boolean;
|
status?: RouteStatus;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { IsEnum, IsOptional, IsString } from 'class-validator';
|
||||||
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
|
||||||
|
import { RouteStatus } from '../entities/route.entity';
|
||||||
|
|
||||||
export class FilterRoutesDto {
|
export class FilterRoutesDto {
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional({ description: 'Search origin/destination yard codes or names' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
search?: string;
|
search?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => value === 'true' || value === true)
|
@IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'])
|
||||||
@IsBoolean()
|
status?: RouteStatus;
|
||||||
isActive?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,4 +23,8 @@ export class RouteMilestone extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'sequence_no', type: 'int' })
|
@Column({ name: 'sequence_no', type: 'int' })
|
||||||
sequenceNo!: number;
|
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 { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
import { RouteMilestone } from './route-milestone.entity';
|
import { RouteMilestone } from './route-milestone.entity';
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'routes' })
|
export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
|
||||||
@Index(['name'])
|
|
||||||
@Index(['isActive'])
|
|
||||||
export class Route extends BaseEntity {
|
|
||||||
@Column({ name: 'name', type: 'varchar', length: 120, unique: true })
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
|
@Entity({ schema: 'freight', name: 'routes' })
|
||||||
|
@Index(['status'])
|
||||||
|
export class Route extends BaseEntity {
|
||||||
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
||||||
originYardId!: string;
|
originYardId!: string;
|
||||||
|
|
||||||
@@ -25,9 +23,24 @@ export class Route extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'destination_yard_id' })
|
@JoinColumn({ name: 'destination_yard_id' })
|
||||||
destinationYard?: Yard;
|
destinationYard?: Yard;
|
||||||
|
|
||||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
@Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' })
|
||||||
isActive!: boolean;
|
status!: RouteStatus;
|
||||||
|
|
||||||
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
|
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
|
||||||
milestones?: RouteMilestone[];
|
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 { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource, ILike } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { CreateRouteDto } from './dto/create-route.dto';
|
import { CreateRouteDto } from './dto/create-route.dto';
|
||||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||||
import { UpdateRouteDto } from './dto/update-route.dto';
|
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||||
import { RouteMilestone } from './entities/route-milestone.entity';
|
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';
|
import { RoutesRepository } from './routes.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -16,11 +16,10 @@ export class RoutesService {
|
|||||||
private readonly routesRepository: RoutesRepository,
|
private readonly routesRepository: RoutesRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
findAll(filter: FilterRoutesDto): Promise<Route[]> {
|
async findAll(filter: FilterRoutesDto): Promise<Route[]> {
|
||||||
return this.routesRepository.findAll({
|
const routes = await this.routesRepository.findAll({
|
||||||
where: {
|
where: {
|
||||||
...(filter.search ? { name: ILike(`%${filter.search.trim()}%`) } : {}),
|
...(filter.status ? { status: filter.status } : {}),
|
||||||
...(filter.isActive !== undefined ? { isActive: filter.isActive } : {}),
|
|
||||||
},
|
},
|
||||||
relations: {
|
relations: {
|
||||||
originYard: true,
|
originYard: true,
|
||||||
@@ -28,10 +27,33 @@ export class RoutesService {
|
|||||||
milestones: { yard: true },
|
milestones: { yard: true },
|
||||||
},
|
},
|
||||||
order: {
|
order: {
|
||||||
name: 'ASC',
|
|
||||||
milestones: { sequenceNo: '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> {
|
async findById(id: string): Promise<Route> {
|
||||||
@@ -53,16 +75,14 @@ export class RoutesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateRouteDto): Promise<Route> {
|
async create(dto: CreateRouteDto): Promise<Route> {
|
||||||
await this.validateRouteName(dto.name);
|
|
||||||
const validated = await this.validateMilestones(dto.milestones);
|
const validated = await this.validateMilestones(dto.milestones);
|
||||||
|
|
||||||
const route = await this.dataSource.transaction(async (manager) => {
|
const route = await this.dataSource.transaction(async (manager) => {
|
||||||
const savedRoute = await manager.getRepository(Route).save(
|
const savedRoute = await manager.getRepository(Route).save(
|
||||||
manager.getRepository(Route).create({
|
manager.getRepository(Route).create({
|
||||||
name: dto.name.trim(),
|
|
||||||
originYardId: validated.originYardId,
|
originYardId: validated.originYardId,
|
||||||
destinationYardId: validated.destinationYardId,
|
destinationYardId: validated.destinationYardId,
|
||||||
isActive: dto.isActive ?? true,
|
status: dto.status ?? 'AVAILABLE',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -72,6 +92,7 @@ export class RoutesService {
|
|||||||
routeId: savedRoute.id,
|
routeId: savedRoute.id,
|
||||||
yardId: milestone.yardId,
|
yardId: milestone.yardId,
|
||||||
sequenceNo: milestone.sequenceNo,
|
sequenceNo: milestone.sequenceNo,
|
||||||
|
distanceKm: milestone.distanceKm,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -85,20 +106,16 @@ export class RoutesService {
|
|||||||
async update(id: string, dto: UpdateRouteDto): Promise<Route> {
|
async update(id: string, dto: UpdateRouteDto): Promise<Route> {
|
||||||
const existing = await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
|
|
||||||
if (dto.name && dto.name.trim() !== existing.name) {
|
|
||||||
await this.validateRouteName(dto.name, id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const milestoneInput = dto.milestones
|
const milestoneInput = dto.milestones
|
||||||
? await this.validateMilestones(dto.milestones)
|
? await this.validateMilestones(dto.milestones)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await manager.getRepository(Route).update(id, {
|
await manager.getRepository(Route).update(id, {
|
||||||
name: dto.name?.trim() ?? existing.name,
|
|
||||||
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
|
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
|
||||||
destinationYardId: milestoneInput?.destinationYardId ?? existing.destinationYardId,
|
destinationYardId:
|
||||||
isActive: dto.isActive ?? existing.isActive,
|
milestoneInput?.destinationYardId ?? existing.destinationYardId,
|
||||||
|
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (milestoneInput) {
|
if (milestoneInput) {
|
||||||
@@ -109,6 +126,7 @@ export class RoutesService {
|
|||||||
routeId: id,
|
routeId: id,
|
||||||
yardId: milestone.yardId,
|
yardId: milestone.yardId,
|
||||||
sequenceNo: milestone.sequenceNo,
|
sequenceNo: milestone.sequenceNo,
|
||||||
|
distanceKm: milestone.distanceKm,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -120,7 +138,9 @@ export class RoutesService {
|
|||||||
|
|
||||||
async deactivate(id: string): Promise<Route> {
|
async deactivate(id: string): Promise<Route> {
|
||||||
await this.findById(id);
|
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) {
|
if (!updated) {
|
||||||
throw new NotFoundException(`Route ${id} not found`);
|
throw new NotFoundException(`Route ${id} not found`);
|
||||||
@@ -129,27 +149,32 @@ export class RoutesService {
|
|||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateRouteName(name: string, routeId?: string) {
|
private async validateMilestones(
|
||||||
const trimmedName = name.trim();
|
milestones: Array<{ yardId: string; distanceKm?: number }>,
|
||||||
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 }>) {
|
|
||||||
if (milestones.length < 2) {
|
if (milestones.length < 2) {
|
||||||
throw new BadRequestException('A route requires at least two yards');
|
throw new BadRequestException('A route requires at least two yards');
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = milestones.map((milestone, index) => ({
|
const normalized = milestones.map((milestone, index) => {
|
||||||
yardId: milestone.yardId,
|
const distanceKm =
|
||||||
sequenceNo: index + 1,
|
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 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));
|
const yardIds = new Set(yards.map((yard) => yard.id));
|
||||||
|
|
||||||
for (const milestone of normalized) {
|
for (const milestone of normalized) {
|
||||||
|
|||||||
@@ -21,11 +21,6 @@ export class CreateCargoTypeDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
parentGroupId?: string;
|
parentGroupId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: false })
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
showFreeTextBox?: boolean;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: false })
|
@ApiPropertyOptional({ default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ export class CargoType extends BaseEntity {
|
|||||||
@Column({ name: 'parent_group_id', type: 'uuid', nullable: true })
|
@Column({ name: 'parent_group_id', type: 'uuid', nullable: true })
|
||||||
parentGroupId?: string | null;
|
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
|
* How this cargo's quantity is measured: PER_TON (bulk) or PER_ITEM
|
||||||
* (break-bulk). Nullable for container/legacy cargo, which is counted by
|
* (break-bulk). Nullable for container/legacy cargo, which is counted by
|
||||||
|
|||||||
@@ -331,16 +331,14 @@ export class RuleEngineService {
|
|||||||
): Promise<BookingApprovalStep[]> {
|
): Promise<BookingApprovalStep[]> {
|
||||||
await this.ensureDefaultApprovalRules();
|
await this.ensureDefaultApprovalRules();
|
||||||
|
|
||||||
let requiresDirectorApproval = options.freightType === 'BULK';
|
let requiresDirectorApproval = false;
|
||||||
|
|
||||||
if (options.cargoTypeId) {
|
if (options.cargoTypeId) {
|
||||||
const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId);
|
const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId);
|
||||||
if (!cargoType) {
|
if (!cargoType) {
|
||||||
throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`);
|
throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`);
|
||||||
}
|
}
|
||||||
if (cargoType.requiresDirectorApproval) {
|
requiresDirectorApproval = cargoType.requiresDirectorApproval;
|
||||||
requiresDirectorApproval = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const chain = await this.approvalRulesRepo.findChainForCargo(
|
const chain = await this.approvalRulesRepo.findChainForCargo(
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ export class CargoTypesService {
|
|||||||
code,
|
code,
|
||||||
cargoTypeName: dto.cargoTypeName,
|
cargoTypeName: dto.cargoTypeName,
|
||||||
parentGroupId: dto.parentGroupId ?? null,
|
parentGroupId: dto.parentGroupId ?? null,
|
||||||
showFreeTextBox: dto.showFreeTextBox ?? false,
|
|
||||||
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
||||||
isActive: dto.isActive ?? true,
|
isActive: dto.isActive ?? true,
|
||||||
unitOfMeasure: dto.unitOfMeasure ?? null,
|
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { DataSource } from 'typeorm';
|
|||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
|
import { formatRouteLabel } from '../routes/entities/route.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
@@ -549,7 +550,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return {
|
return {
|
||||||
scheduleId: s.id,
|
scheduleId: s.id,
|
||||||
trainNumber: s.trainNumber ?? null,
|
trainNumber: s.trainNumber ?? null,
|
||||||
routeName: s.route?.name ?? null,
|
routeName: s.route ? formatRouteLabel(s.route) : null,
|
||||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||||
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||||
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
|
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
|
||||||
@@ -624,7 +625,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return {
|
return {
|
||||||
scheduleId: s.id,
|
scheduleId: s.id,
|
||||||
trainNumber: s.trainNumber ?? null,
|
trainNumber: s.trainNumber ?? null,
|
||||||
routeName: s.route?.name ?? null,
|
routeName: s.route ? formatRouteLabel(s.route) : null,
|
||||||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||||||
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||||||
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
|
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
|
|||||||
import { Container } from '../container-management/entities/container.entity';
|
import { Container } from '../container-management/entities/container.entity';
|
||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
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 { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||||
@@ -304,7 +304,7 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
|
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
|
||||||
const route = await this.getActiveRoute(dto.routeId);
|
const route = await this.getSchedulableRoute(dto.routeId);
|
||||||
|
|
||||||
const locomotiveIds = [...new Set(dto.locomotiveIds)];
|
const locomotiveIds = [...new Set(dto.locomotiveIds)];
|
||||||
if (locomotiveIds.length < 2) {
|
if (locomotiveIds.length < 2) {
|
||||||
@@ -949,7 +949,7 @@ export class TrainSchedulingService {
|
|||||||
generatedAt: generatedAt.toISOString(),
|
generatedAt: generatedAt.toISOString(),
|
||||||
trainScheduleId: schedule.id,
|
trainScheduleId: schedule.id,
|
||||||
trainNumber: schedule.trainNumber ?? null,
|
trainNumber: schedule.trainNumber ?? null,
|
||||||
route: schedule.route?.name ?? null,
|
route: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||||
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||||
totalBookings: schedule.scheduleBookings?.length ?? 0,
|
totalBookings: schedule.scheduleBookings?.length ?? 0,
|
||||||
@@ -2605,13 +2605,17 @@ export class TrainSchedulingService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getActiveRoute(routeId: string) {
|
private async getSchedulableRoute(routeId: string) {
|
||||||
const route = await this.dataSource.getRepository(Route).findOne({
|
const route = await this.dataSource.getRepository(Route).findOne({
|
||||||
where: { id: routeId },
|
where: { id: routeId },
|
||||||
relations: { originYard: true, destinationYard: true },
|
relations: { originYard: true, destinationYard: true },
|
||||||
});
|
});
|
||||||
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
|
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;
|
return route;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2656,7 +2660,7 @@ export class TrainSchedulingService {
|
|||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
scheduleDate: schedule.scheduledDepartureDate,
|
scheduleDate: schedule.scheduledDepartureDate,
|
||||||
trainNumber: schedule.trainNumber ?? null,
|
trainNumber: schedule.trainNumber ?? null,
|
||||||
routeName: schedule.route?.name ?? null,
|
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||||
destination:
|
destination:
|
||||||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||||
@@ -2691,7 +2695,7 @@ export class TrainSchedulingService {
|
|||||||
|
|
||||||
/** AVAILABLE locomotives at the route's origin yard. */
|
/** AVAILABLE locomotives at the route's origin yard. */
|
||||||
async getAvailableLocomotivesForRoute(routeId: string): Promise<Locomotive[]> {
|
async getAvailableLocomotivesForRoute(routeId: string): Promise<Locomotive[]> {
|
||||||
const route = await this.getActiveRoute(routeId);
|
const route = await this.getSchedulableRoute(routeId);
|
||||||
|
|
||||||
const locomotives = await this.locomotivesRepository.findAll({
|
const locomotives = await this.locomotivesRepository.findAll({
|
||||||
where: { status: 'AVAILABLE', currentYardId: route.originYardId },
|
where: { status: 'AVAILABLE', currentYardId: route.originYardId },
|
||||||
@@ -2933,7 +2937,9 @@ export class TrainSchedulingService {
|
|||||||
freightType: this.resolveScheduleFreightType(schedule),
|
freightType: this.resolveScheduleFreightType(schedule),
|
||||||
trainNumber: schedule.trainNumber ?? null,
|
trainNumber: schedule.trainNumber ?? null,
|
||||||
direction: schedule.direction ?? 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,
|
scheduledDepartureDate: schedule.scheduledDepartureDate,
|
||||||
scheduledArrivalDate: schedule.scheduledArrivalDate,
|
scheduledArrivalDate: schedule.scheduledArrivalDate,
|
||||||
actualDepartureAt: schedule.actualDepartureAt ?? null,
|
actualDepartureAt: schedule.actualDepartureAt ?? null,
|
||||||
|
|||||||
@@ -468,15 +468,15 @@ export class DemoBookingsSeeder {
|
|||||||
const djibouti = yardByCode.get("DJIBOUTI");
|
const djibouti = yardByCode.get("DJIBOUTI");
|
||||||
const addis = yardByCode.get("ADDIS_ABABA");
|
const addis = yardByCode.get("ADDIS_ABABA");
|
||||||
if (djibouti && addis) {
|
if (djibouti && addis) {
|
||||||
const routeName = "Djibouti → Addis Ababa";
|
let route = await manager.getRepository(Route).findOne({
|
||||||
let route = await manager.getRepository(Route).findOneBy({ name: routeName });
|
where: { originYardId: djibouti.id, destinationYardId: addis.id },
|
||||||
|
});
|
||||||
if (!route) {
|
if (!route) {
|
||||||
route = await manager.getRepository(Route).save(
|
route = await manager.getRepository(Route).save(
|
||||||
manager.getRepository(Route).create({
|
manager.getRepository(Route).create({
|
||||||
name: routeName,
|
|
||||||
originYardId: djibouti.id,
|
originYardId: djibouti.id,
|
||||||
destinationYardId: addis.id,
|
destinationYardId: addis.id,
|
||||||
isActive: true,
|
status: 'AVAILABLE',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await manager.getRepository(RouteMilestone).save([
|
await manager.getRepository(RouteMilestone).save([
|
||||||
@@ -484,11 +484,13 @@ export class DemoBookingsSeeder {
|
|||||||
routeId: route.id,
|
routeId: route.id,
|
||||||
yardId: djibouti.id,
|
yardId: djibouti.id,
|
||||||
sequenceNo: 1,
|
sequenceNo: 1,
|
||||||
|
distanceKm: 0,
|
||||||
}),
|
}),
|
||||||
manager.getRepository(RouteMilestone).create({
|
manager.getRepository(RouteMilestone).create({
|
||||||
routeId: route.id,
|
routeId: route.id,
|
||||||
yardId: addis.id,
|
yardId: addis.id,
|
||||||
sequenceNo: 2,
|
sequenceNo: 2,
|
||||||
|
distanceKm: 780,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,15 +286,15 @@ export class PricingDataSeeder {
|
|||||||
|
|
||||||
const routeRepo = manager.getRepository(Route);
|
const routeRepo = manager.getRepository(Route);
|
||||||
const milestoneRepo = manager.getRepository(RouteMilestone);
|
const milestoneRepo = manager.getRepository(RouteMilestone);
|
||||||
const routeName = "Addis Ababa → Dire Dawa";
|
let route = await routeRepo.findOne({
|
||||||
let route = await routeRepo.findOneBy({ name: routeName });
|
where: { originYardId: addis.id, destinationYardId: direDawa.id },
|
||||||
|
});
|
||||||
if (!route) {
|
if (!route) {
|
||||||
route = await routeRepo.save(
|
route = await routeRepo.save(
|
||||||
routeRepo.create({
|
routeRepo.create({
|
||||||
name: routeName,
|
|
||||||
originYardId: addis.id,
|
originYardId: addis.id,
|
||||||
destinationYardId: direDawa.id,
|
destinationYardId: direDawa.id,
|
||||||
isActive: true,
|
status: 'AVAILABLE',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await milestoneRepo.save([
|
await milestoneRepo.save([
|
||||||
@@ -302,11 +302,13 @@ export class PricingDataSeeder {
|
|||||||
routeId: route.id,
|
routeId: route.id,
|
||||||
yardId: addis.id,
|
yardId: addis.id,
|
||||||
sequenceNo: 1,
|
sequenceNo: 1,
|
||||||
|
distanceKm: 0,
|
||||||
}),
|
}),
|
||||||
milestoneRepo.create({
|
milestoneRepo.create({
|
||||||
routeId: route.id,
|
routeId: route.id,
|
||||||
yardId: direDawa.id,
|
yardId: direDawa.id,
|
||||||
sequenceNo: 2,
|
sequenceNo: 2,
|
||||||
|
distanceKm: 445,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
this.logger.log("Seeded domestic route Addis Ababa → Dire Dawa");
|
this.logger.log("Seeded domestic route Addis Ababa → Dire Dawa");
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
FileText,
|
FileText,
|
||||||
Flag,
|
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Network,
|
Network,
|
||||||
@@ -15,14 +14,21 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Ship,
|
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Train,
|
Train,
|
||||||
Truck,
|
Truck,
|
||||||
Users,
|
Users,
|
||||||
Wallet,
|
Wallet,
|
||||||
} from "lucide-react";
|
} 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 { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
|
||||||
import { useAuth } from "./auth/useAuth";
|
import { useAuth } from "./auth/useAuth";
|
||||||
@@ -37,11 +43,11 @@ import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPa
|
|||||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||||
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
|
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
|
||||||
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
||||||
import GlEthiopiaClearanceListPage from "./pages/contracts/GlEthiopiaClearanceListPage";
|
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
|
||||||
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
|
import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
|
||||||
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
|
|
||||||
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
||||||
import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage";
|
import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage";
|
||||||
|
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
||||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||||
import CustomersPage from "./pages/customers/CustomersPage";
|
import CustomersPage from "./pages/customers/CustomersPage";
|
||||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||||
@@ -139,19 +145,17 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Document Clearance",
|
label: "Document Clearance",
|
||||||
href: "/dashboard/contracts/clearance",
|
href: "/dashboard/contracts/clearance",
|
||||||
icon: <ShieldCheck />,
|
icon: <ShieldCheck />,
|
||||||
permission: FREIGHT_PERMS.contracts.clearanceReview,
|
permission: [
|
||||||
|
FREIGHT_PERMS.contracts.clearanceReview,
|
||||||
|
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||||
|
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "GL Ethiopia Clearance",
|
label: "Shipment Requests",
|
||||||
href: "/dashboard/gl-ethiopia/clearance",
|
href: "/dashboard/shipment-requests",
|
||||||
icon: <Flag />,
|
icon: <Send />,
|
||||||
permission: FREIGHT_PERMS.contracts.clearanceEtActions,
|
permission: FREIGHT_PERMS.contracts.createBooking,
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "GL Djibouti Clearance",
|
|
||||||
href: "/dashboard/gl-djibouti/clearance",
|
|
||||||
icon: <Ship />,
|
|
||||||
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Train Schedules",
|
label: "Train Schedules",
|
||||||
@@ -481,7 +485,11 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="clearance/:id"
|
path="clearance/:id"
|
||||||
element={<Navigate to="/dashboard/contracts/clearance" replace />}
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.bookings.clearanceView}>
|
||||||
|
<DocumentClearanceDetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Contracts (Path A/B) */}
|
{/* Contracts (Path A/B) */}
|
||||||
@@ -509,11 +517,41 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</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 */}
|
{/* GL (Path B) contract clearance review hub */}
|
||||||
<Route
|
<Route
|
||||||
path="contracts/clearance"
|
path="contracts/clearance"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
|
<RequirePermission
|
||||||
|
permission={[
|
||||||
|
FREIGHT_PERMS.contracts.clearanceReview,
|
||||||
|
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||||
|
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<ContractClearanceListPage />
|
<ContractClearanceListPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
@@ -521,51 +559,21 @@ const App = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="contracts/clearance/:id"
|
path="contracts/clearance/:id"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
|
<RequirePermission
|
||||||
|
permission={[
|
||||||
|
FREIGHT_PERMS.contracts.clearanceReview,
|
||||||
|
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||||
|
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<ContractClearanceDetailPage />
|
<ContractClearanceDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route path="gl-ethiopia/clearance" element={<LegacyGlClearanceRedirect />} />
|
||||||
path="gl-ethiopia/clearance"
|
<Route path="gl-ethiopia/clearance/:id" element={<LegacyGlClearanceRedirect />} />
|
||||||
element={
|
<Route path="gl-djibouti/clearance" element={<LegacyGlClearanceRedirect />} />
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceEtActions}>
|
<Route path="gl-djibouti/clearance/:id" element={<LegacyGlClearanceRedirect />} />
|
||||||
<GlEthiopiaClearanceListPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="gl-ethiopia/clearance/:id"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceEtActions}>
|
|
||||||
<GlClearanceDetailPage
|
|
||||||
backTo="/dashboard/gl-ethiopia/clearance"
|
|
||||||
breadcrumbsLabel="GL Ethiopia Clearance"
|
|
||||||
roleMode="ET"
|
|
||||||
/>
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="gl-djibouti/clearance"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
|
|
||||||
<GlDjiboutiClearanceListPage />
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="gl-djibouti/clearance/:id"
|
|
||||||
element={
|
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
|
|
||||||
<GlClearanceDetailPage
|
|
||||||
backTo="/dashboard/gl-djibouti/clearance"
|
|
||||||
breadcrumbsLabel="GL Djibouti Clearance"
|
|
||||||
roleMode="DJ"
|
|
||||||
/>
|
|
||||||
</RequirePermission>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{/* Path A ops queue out of scope for now → fold into the GL hub. */}
|
{/* Path A ops queue out of scope for now → fold into the GL hub. */}
|
||||||
<Route
|
<Route
|
||||||
path="contracts/ops-clearance"
|
path="contracts/ops-clearance"
|
||||||
@@ -909,4 +917,13 @@ const App = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Redirect legacy GL Ethiopia/Djibouti clearance URLs to the unified hub. */
|
||||||
|
function LegacyGlClearanceRedirect() {
|
||||||
|
const { id } = useParams();
|
||||||
|
if (id) {
|
||||||
|
return <Navigate to={`/dashboard/contracts/clearance/${id}`} replace />;
|
||||||
|
}
|
||||||
|
return <Navigate to="/dashboard/contracts/clearance" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -4,17 +4,6 @@ import type { Freight } from "@edr/types";
|
|||||||
|
|
||||||
const BRAND_GREEN = "var(--freight-brand, #0A6F4D)";
|
const BRAND_GREEN = "var(--freight-brand, #0A6F4D)";
|
||||||
|
|
||||||
const PHASE_LABELS: Record<string, string> = {
|
|
||||||
CUSTOMER_INTAKE: "Customer docs",
|
|
||||||
GL_ET_REVIEW: "GL ET review",
|
|
||||||
GL_DJ_COLLECTION: "GL Djibouti",
|
|
||||||
GL_ET_OUTPUT: "Declaration",
|
|
||||||
CUSTOMER_DUTY: "Duty / tax",
|
|
||||||
GL_ET_POST_CLEARANCE: "ET clearance",
|
|
||||||
GL_DJ_LOADING: "Loading",
|
|
||||||
POST_TRANSIT: "Transit",
|
|
||||||
};
|
|
||||||
|
|
||||||
const IMPORT_PHASES = [
|
const IMPORT_PHASES = [
|
||||||
"CUSTOMER_INTAKE",
|
"CUSTOMER_INTAKE",
|
||||||
"GL_ET_REVIEW",
|
"GL_ET_REVIEW",
|
||||||
@@ -24,6 +13,17 @@ const IMPORT_PHASES = [
|
|||||||
"GL_DJ_COLLECTION",
|
"GL_DJ_COLLECTION",
|
||||||
] as const;
|
] 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 = [
|
const EXPORT_PHASES = [
|
||||||
"CUSTOMER_INTAKE",
|
"CUSTOMER_INTAKE",
|
||||||
"GL_ET_REVIEW",
|
"GL_ET_REVIEW",
|
||||||
@@ -43,7 +43,7 @@ export function ClearancePhaseStepper({
|
|||||||
tradeDirection,
|
tradeDirection,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
clearance?: Freight.ContractClearanceView | null;
|
clearance?: Freight.ContractClearanceView | Freight.ClearanceView | null;
|
||||||
tradeDirection?: string;
|
tradeDirection?: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -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 { 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 type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||||
@@ -19,6 +27,10 @@ export function ContractApprovalStepsCard({
|
|||||||
contract,
|
contract,
|
||||||
mutations,
|
mutations,
|
||||||
}: ContractApprovalStepsCardProps) {
|
}: ContractApprovalStepsCardProps) {
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [pendingStep, setPendingStep] =
|
||||||
|
useState<Freight.IContractApprovalStep | null>(null);
|
||||||
|
|
||||||
const steps = useMemo(
|
const steps = useMemo(
|
||||||
() =>
|
() =>
|
||||||
[...(contract.approvalSteps ?? [])].sort(
|
[...(contract.approvalSteps ?? [])].sort(
|
||||||
@@ -30,6 +42,24 @@ export function ContractApprovalStepsCard({
|
|||||||
const nextPending = steps.find((s) => s.status === "PENDING");
|
const nextPending = steps.find((s) => s.status === "PENDING");
|
||||||
const summary = formatContractApprovalProgress(contract.status, steps);
|
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 =
|
const subtitle =
|
||||||
summary.detail ||
|
summary.detail ||
|
||||||
(nextPending
|
(nextPending
|
||||||
@@ -39,54 +69,87 @@ export function ContractApprovalStepsCard({
|
|||||||
: "Accept submission to begin");
|
: "Accept submission to begin");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard
|
<>
|
||||||
icon={ShieldCheck}
|
<SectionCard
|
||||||
title="Approval chain"
|
icon={ShieldCheck}
|
||||||
extra={
|
title="Approval chain"
|
||||||
<Badge color="edr-green" variant="light" radius="sm">
|
extra={
|
||||||
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
|
<Badge color="edr-green" variant="light" radius="sm">
|
||||||
</Badge>
|
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
|
||||||
}
|
</Badge>
|
||||||
>
|
}
|
||||||
<Text size="xs" c="dimmed" mb="sm">
|
>
|
||||||
{subtitle}
|
<Text size="xs" c="dimmed" mb="sm">
|
||||||
</Text>
|
{subtitle}
|
||||||
|
|
||||||
{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>
|
</Text>
|
||||||
) : (
|
|
||||||
<Stack gap="xs">
|
{steps.length === 0 ? (
|
||||||
{steps.map((step) => (
|
<Text
|
||||||
<StepRow
|
size="sm"
|
||||||
key={step.id}
|
c="dimmed"
|
||||||
step={step}
|
ta="center"
|
||||||
isNext={nextPending?.id === step.id}
|
py="lg"
|
||||||
isPending={mutations.approveStep.isPending}
|
px="md"
|
||||||
onApprove={() =>
|
style={{
|
||||||
mutations.approveStep.mutate({
|
borderRadius: 8,
|
||||||
stepId: step.id,
|
border: "1px dashed var(--mantine-color-gray-3)",
|
||||||
requiredRole: step.requiredRole,
|
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>
|
</Stack>
|
||||||
)}
|
</Modal>
|
||||||
</SectionCard>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,17 @@ export interface ContractClearanceReviewSectionProps {
|
|||||||
* by whom, when) but hide all approve / query / finalize actions.
|
* by whom, when) but hide all approve / query / finalize actions.
|
||||||
*/
|
*/
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
/**
|
||||||
|
* Document approvals are locked (e.g. after all docs approved in phased flow)
|
||||||
|
* but queries remain available until {@link readOnly}.
|
||||||
|
*/
|
||||||
|
approvalsLocked?: 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<
|
const STATUS_META: Record<
|
||||||
@@ -89,6 +100,8 @@ export function ContractClearanceReviewSection({
|
|||||||
hideSummary,
|
hideSummary,
|
||||||
selfClear = false,
|
selfClear = false,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
phasedCustoms = false,
|
||||||
|
approvalsLocked = false,
|
||||||
}: ContractClearanceReviewSectionProps) {
|
}: ContractClearanceReviewSectionProps) {
|
||||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||||
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
||||||
@@ -170,14 +183,16 @@ export function ContractClearanceReviewSection({
|
|||||||
subtitle={
|
subtitle={
|
||||||
readOnly
|
readOnly
|
||||||
? `Reviewed by the ${reviewerTeam} team.`
|
? `Reviewed by the ${reviewerTeam} team.`
|
||||||
: "Approve each document, or open a query to tell the customer what to fix."
|
: approvalsLocked
|
||||||
|
? "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={
|
extra={
|
||||||
<Group gap={10} wrap="nowrap" align="center">
|
<Group gap={10} wrap="nowrap" align="center">
|
||||||
<Text size="xs" c="dimmed" fw={600}>
|
<Text size="xs" c="dimmed" fw={600}>
|
||||||
{stats.approved}/{stats.total} approved
|
{stats.approved}/{stats.total} approved
|
||||||
</Text>
|
</Text>
|
||||||
{!readOnly && approvableKeys.length > 0 && (
|
{!readOnly && !approvalsLocked && approvableKeys.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
@@ -221,6 +236,7 @@ export function ContractClearanceReviewSection({
|
|||||||
doc={doc}
|
doc={doc}
|
||||||
reviewerTeam={reviewerTeam}
|
reviewerTeam={reviewerTeam}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
|
approvalsLocked={approvalsLocked}
|
||||||
note={queryNotes[doc.fileKey] ?? ""}
|
note={queryNotes[doc.fileKey] ?? ""}
|
||||||
queryOpen={openQuery[doc.fileKey] ?? false}
|
queryOpen={openQuery[doc.fileKey] ?? false}
|
||||||
onToggleQuery={(open) =>
|
onToggleQuery={(open) =>
|
||||||
@@ -241,7 +257,7 @@ export function ContractClearanceReviewSection({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{glDocs.length > 0 && (
|
{glDocs.length > 0 && !phasedCustoms && (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={Upload}
|
icon={Upload}
|
||||||
title="GL output documents"
|
title="GL output documents"
|
||||||
@@ -372,8 +388,39 @@ export function ContractClearanceReviewSection({
|
|||||||
<CheckCircle2 size={15} />
|
<CheckCircle2 size={15} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text fz="12.5px" c="dimmed">
|
<Text fz="12.5px" c="dimmed">
|
||||||
Clearance was finalized by the {reviewerTeam} team. This is a
|
{phasedCustoms
|
||||||
read-only record of the approved documents.
|
? "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 && approvalsLocked ? (
|
||||||
|
<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 — 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. Upload declaration, duty, transit permit, and delivery order in the action panel."
|
||||||
|
: "Approve every required document to unlock the customs milestone steps."}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -450,6 +497,7 @@ function DocReviewCard({
|
|||||||
doc,
|
doc,
|
||||||
reviewerTeam,
|
reviewerTeam,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
approvalsLocked,
|
||||||
note,
|
note,
|
||||||
queryOpen,
|
queryOpen,
|
||||||
onToggleQuery,
|
onToggleQuery,
|
||||||
@@ -462,6 +510,7 @@ function DocReviewCard({
|
|||||||
doc: Freight.ContractClearanceDocument;
|
doc: Freight.ContractClearanceDocument;
|
||||||
reviewerTeam: string;
|
reviewerTeam: string;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
|
approvalsLocked: boolean;
|
||||||
note: string;
|
note: string;
|
||||||
queryOpen: boolean;
|
queryOpen: boolean;
|
||||||
onToggleQuery: (open: boolean) => void;
|
onToggleQuery: (open: boolean) => void;
|
||||||
@@ -598,7 +647,7 @@ function DocReviewCard({
|
|||||||
>
|
>
|
||||||
Open query
|
Open query
|
||||||
</Button>
|
</Button>
|
||||||
{!isApproved && (
|
{!isApproved && !approvalsLocked && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
|
|||||||
@@ -382,7 +382,7 @@ export default function GlCreateBookingForm() {
|
|||||||
} catch {
|
} catch {
|
||||||
// Non-fatal — the booking exists; the request link can be retried.
|
// Non-fatal — the booking exists; the request link can be retried.
|
||||||
}
|
}
|
||||||
navigate(`/dashboard/clearance/${booking.id}`);
|
navigate(`/dashboard/bookings/${booking.id}/clearance`);
|
||||||
} else {
|
} else {
|
||||||
navigate(`/dashboard/bookings/${booking.id}/milestones`);
|
navigate(`/dashboard/bookings/${booking.id}/milestones`);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
import { clearanceWorkflowFileLabel } from "@edr/types";
|
||||||
|
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
|
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
|
||||||
@@ -199,9 +200,10 @@ function formatBytes(bytes?: number | null): string {
|
|||||||
/** Human label for a file's `code` (e.g. "business_license" → "Business license"). */
|
/** Human label for a file's `code` (e.g. "business_license" → "Business license"). */
|
||||||
function codeLabel(code?: string | null): string | null {
|
function codeLabel(code?: string | null): string | null {
|
||||||
if (!code) return null;
|
if (!code) return null;
|
||||||
return code
|
return (
|
||||||
.replace(/[_-]+/g, " ")
|
clearanceWorkflowFileLabel(code) ??
|
||||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
code.replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContractDocumentsCardProps {
|
export interface ContractDocumentsCardProps {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
|
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { formatRouteLabel } from "@/services/routes.service";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
@@ -138,7 +139,9 @@ export function AllocateBookingWizard({
|
|||||||
const schedulesQuery = useQuery(
|
const schedulesQuery = useQuery(
|
||||||
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
|
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(
|
const locomotivesQuery = useQuery(
|
||||||
api.trainScheduling.availableLocomotives.queryOptions({
|
api.trainScheduling.availableLocomotives.queryOptions({
|
||||||
input: {
|
input: {
|
||||||
@@ -244,7 +247,7 @@ export function AllocateBookingWizard({
|
|||||||
}, [containerUnits, containerSlots, savedPlacementsFromSchedule]);
|
}, [containerUnits, containerSlots, savedPlacementsFromSchedule]);
|
||||||
|
|
||||||
const activeRoutes = useMemo(
|
const activeRoutes = useMemo(
|
||||||
() => (routesQuery.data ?? []).filter((r) => r.isActive),
|
() => routesQuery.data ?? [],
|
||||||
[routesQuery.data],
|
[routesQuery.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -522,7 +525,7 @@ export function AllocateBookingWizard({
|
|||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||||
<Select
|
<Select
|
||||||
label="Route"
|
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}
|
value={routeId || null}
|
||||||
onChange={(v) => setRouteId(v ?? "")}
|
onChange={(v) => setRouteId(v ?? "")}
|
||||||
searchable
|
searchable
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export const QUERY_KEYS = {
|
|||||||
listSummary: (filter?: BookingListFilter) =>
|
listSummary: (filter?: BookingListFilter) =>
|
||||||
["bookings", "list-summary", filter ?? {}] as const,
|
["bookings", "list-summary", filter ?? {}] as const,
|
||||||
byId: (id: string) => ["bookings", "detail", id] as const,
|
byId: (id: string) => ["bookings", "detail", id] as const,
|
||||||
|
clearanceQueue: (region?: string) =>
|
||||||
|
["bookings", "clearance-queue", region ?? "ET"] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
CONTRACTS: {
|
CONTRACTS: {
|
||||||
|
|||||||
@@ -123,6 +123,18 @@ export const URL_CONSTANTS = {
|
|||||||
COMPLETE: (id: string) => `/bookings/${id}/operations/complete`,
|
COMPLETE: (id: string) => `/bookings/${id}/operations/complete`,
|
||||||
CANCEL: (id: string) => `/bookings/${id}/cancel`,
|
CANCEL: (id: string) => `/bookings/${id}/cancel`,
|
||||||
CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`,
|
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: {
|
CONTRACTS: {
|
||||||
@@ -148,6 +160,8 @@ export const URL_CONSTANTS = {
|
|||||||
CLEARANCE_DECLARATION: (id: string) => `/contracts/${id}/clearance/declaration`,
|
CLEARANCE_DECLARATION: (id: string) => `/contracts/${id}/clearance/declaration`,
|
||||||
CLEARANCE_DUTY: (id: string) => `/contracts/${id}/clearance/duty`,
|
CLEARANCE_DUTY: (id: string) => `/contracts/${id}/clearance/duty`,
|
||||||
CLEARANCE_DUTY_SLIP: (id: string) => `/contracts/${id}/clearance/duty-slip`,
|
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) =>
|
CLEARANCE_TRANSIT_PERMIT: (id: string) =>
|
||||||
`/contracts/${id}/clearance/transit-permit`,
|
`/contracts/${id}/clearance/transit-permit`,
|
||||||
CLEARANCE_DELIVERY_ORDER: (id: string) =>
|
CLEARANCE_DELIVERY_ORDER: (id: string) =>
|
||||||
|
|||||||
@@ -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),
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -264,7 +264,10 @@ export function useContractClearanceMutations(
|
|||||||
);
|
);
|
||||||
refresh();
|
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
|
// Approve every still-pending customer document in one click. There is no
|
||||||
|
|||||||
@@ -29,17 +29,22 @@ import { PageContainer } from "@/components/page/PageContainer";
|
|||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { SectionCard } from "@/components/bookings/detail";
|
import { SectionCard } from "@/components/bookings/detail";
|
||||||
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
|
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 { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
||||||
|
|
||||||
export default function DocumentClearanceDetailPage() {
|
export default function DocumentClearanceDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const params = useParams<{ id?: string; bookingId?: string }>();
|
||||||
|
const id = params.id ?? params.bookingId;
|
||||||
|
|
||||||
const { data: booking } = useBookingDetail(id);
|
const { data: booking } = useBookingDetail(id);
|
||||||
const {
|
const {
|
||||||
data: clearance,
|
data: clearance,
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
|
refetch,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["clearance", id],
|
queryKey: ["clearance", id],
|
||||||
queryFn: () => bookingsService.getClearance(id!),
|
queryFn: () => bookingsService.getClearance(id!),
|
||||||
@@ -59,6 +64,10 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
}, [clearance]);
|
}, [clearance]);
|
||||||
|
|
||||||
const reference = booking?.reference ?? "Clearance";
|
const reference = booking?.reference ?? "Clearance";
|
||||||
|
const isPhasedGeneral =
|
||||||
|
Boolean(booking?.customsClearingEnabled) &&
|
||||||
|
booking?.contractKind === "GENERAL" &&
|
||||||
|
Boolean(clearance?.phase);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -124,15 +133,30 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
|
|
||||||
<ClearanceHero booking={booking} clearance={clearance} stats={stats} />
|
<ClearanceHero booking={booking} clearance={clearance} stats={stats} />
|
||||||
|
|
||||||
|
{isPhasedGeneral ? (
|
||||||
|
<Paper withBorder radius="md" p="lg">
|
||||||
|
<ClearancePhaseStepper
|
||||||
|
clearance={clearance as Freight.ContractClearanceView}
|
||||||
|
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Grid gap="lg">
|
<Grid gap="lg">
|
||||||
{/* LEFT — document review (shared with the Marketing booking detail) */}
|
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 7 : 8 }}>
|
||||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
|
||||||
<ClearanceReviewSection bookingId={id!} hideSummary />
|
<ClearanceReviewSection bookingId={id!} hideSummary />
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
{/* RIGHT — sticky progress gauge */}
|
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
|
||||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
{isPhasedGeneral ? (
|
||||||
<Box style={{ position: "sticky", top: 24 }}>
|
<PhasedClearanceActionPanel
|
||||||
|
bookingId={id!}
|
||||||
|
clearance={clearance}
|
||||||
|
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||||
|
onChanged={() => void refetch()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box style={{ position: "sticky", top: 24 }}>
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={PackageCheck}
|
icon={PackageCheck}
|
||||||
title="Review progress"
|
title="Review progress"
|
||||||
@@ -174,9 +198,20 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{isPhasedGeneral && clearance.milestones && clearance.milestones.length > 0 ? (
|
||||||
|
<ClearanceMilestoneTimeline
|
||||||
|
milestones={
|
||||||
|
clearance.milestones as Parameters<
|
||||||
|
typeof ClearanceMilestoneTimeline
|
||||||
|
>[0]["milestones"]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ interface RefCargoChild {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
code: string;
|
code: string;
|
||||||
show_free_text_box?: boolean;
|
|
||||||
}
|
}
|
||||||
interface RefCargoGroup {
|
interface RefCargoGroup {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -307,20 +306,18 @@ export default function NewBookingPage() {
|
|||||||
[refData?.containers],
|
[refData?.containers],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { cargoData, freeTextById } = useMemo(() => {
|
const { cargoData } = useMemo(() => {
|
||||||
const groups = refData?.cargo_type ?? [];
|
const groups = refData?.cargo_type ?? [];
|
||||||
const freeText = new Map<string, boolean>();
|
|
||||||
const data = groups.map((g) => {
|
const data = groups.map((g) => {
|
||||||
if (g.children?.length) {
|
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 { group: g.name, items: g.children.map((c) => ({ value: c.id, label: c.name })) };
|
||||||
}
|
}
|
||||||
return { value: g.id, label: g.name };
|
return { value: g.id, label: g.name };
|
||||||
});
|
});
|
||||||
return { cargoData: data, freeTextById: freeText };
|
return { cargoData: data };
|
||||||
}, [refData?.cargo_type]);
|
}, [refData?.cargo_type]);
|
||||||
|
|
||||||
const showFreeText = cargoTypeId ? freeTextById.get(cargoTypeId) : false;
|
const showFreeText = freightType === "BULK" && Boolean(cargoTypeId);
|
||||||
|
|
||||||
// ---- derived totals ----
|
// ---- derived totals ----
|
||||||
const totalContainers = lines.reduce((s, l) => s + (l.quantity || 0), 0);
|
const totalContainers = lines.reduce((s, l) => s + (l.quantity || 0), 0);
|
||||||
@@ -376,7 +373,7 @@ export default function NewBookingPage() {
|
|||||||
lastMileDeliveryAddress: lastMileDeliveryAddress.trim() || undefined,
|
lastMileDeliveryAddress: lastMileDeliveryAddress.trim() || undefined,
|
||||||
cargoTotalWeightVgm,
|
cargoTotalWeightVgm,
|
||||||
cargoTypeId: freightType === "BULK" ? cargoTypeId : undefined,
|
cargoTypeId: freightType === "BULK" ? cargoTypeId : undefined,
|
||||||
cargoFreeText: freightType === "BULK" && showFreeText ? cargoFreeText.trim() || undefined : undefined,
|
cargoFreeText: freightType === "BULK" ? cargoFreeText.trim() || undefined : undefined,
|
||||||
containers:
|
containers:
|
||||||
freightType === "CONTAINER"
|
freightType === "CONTAINER"
|
||||||
? lines.map((l) => ({
|
? lines.map((l) => ({
|
||||||
|
|||||||
@@ -19,24 +19,50 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ClipboardList,
|
||||||
Clock,
|
Clock,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { PageContainer } from "@/components/page/PageContainer";
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||||
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
import { downloadBookingFile } from "@/services/files.service";
|
||||||
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
|
type RoleMode = "ET" | "DJ" | "ALL";
|
||||||
|
|
||||||
|
function resolveRoleMode(
|
||||||
|
canReview: boolean,
|
||||||
|
canEt: boolean,
|
||||||
|
canDj: boolean,
|
||||||
|
): RoleMode {
|
||||||
|
if (canReview || (canEt && canDj)) return "ALL";
|
||||||
|
if (canEt) return "ET";
|
||||||
|
if (canDj) return "DJ";
|
||||||
|
return "ALL";
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContractClearanceDetailPage() {
|
export default function ContractClearanceDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { view, viewer } = useFileViewer();
|
||||||
|
|
||||||
|
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||||
|
const canEt =
|
||||||
|
hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) || canReview;
|
||||||
|
const canDj =
|
||||||
|
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) || canReview;
|
||||||
|
const roleMode = resolveRoleMode(canReview, canEt, canDj);
|
||||||
|
|
||||||
const { data: contract } = useContractDetail(id);
|
const { data: contract } = useContractDetail(id);
|
||||||
const {
|
const {
|
||||||
@@ -63,10 +89,17 @@ export default function ContractClearanceDetailPage() {
|
|||||||
}, [clearance]);
|
}, [clearance]);
|
||||||
|
|
||||||
const reference = contract?.reference ?? "Clearance";
|
const reference = contract?.reference ?? "Clearance";
|
||||||
// Customs (Path B) hub. The customer always creates the booking in the portal
|
const phasedCustoms =
|
||||||
// after GL finalizes clearance — there is no GL "Create booking" action here.
|
contract?.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled);
|
||||||
const ready = clearance?.bookingReady ?? clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
|
const docsPhaseComplete =
|
||||||
const clearanceReadOnly = Boolean(
|
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 &&
|
contract?.status &&
|
||||||
[
|
[
|
||||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||||
@@ -76,6 +109,14 @@ export default function ContractClearanceDetailPage() {
|
|||||||
"EXPIRED",
|
"EXPIRED",
|
||||||
].includes(contract.status),
|
].includes(contract.status),
|
||||||
);
|
);
|
||||||
|
const reviewReadOnly =
|
||||||
|
roleMode === "DJ" ? true : shipmentLocked;
|
||||||
|
const reviewColSpan = roleMode === "DJ" ? 12 : 7;
|
||||||
|
const actionColSpan = roleMode === "DJ" ? 12 : 5;
|
||||||
|
const bookingHref =
|
||||||
|
roleMode === "ET" || roleMode === "ALL"
|
||||||
|
? `/dashboard/contracts/${id}/create-booking`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -109,6 +150,8 @@ export default function ContractClearanceDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workflowFiles = clearance.workflowFiles ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
@@ -156,15 +199,6 @@ export default function ContractClearanceDetailPage() {
|
|||||||
|
|
||||||
<ClearanceHero contract={contract} stats={stats} />
|
<ClearanceHero contract={contract} stats={stats} />
|
||||||
|
|
||||||
{contract?.contractKind === "ONE_TIME" && contract.customsClearingEnabled ? (
|
|
||||||
<Paper withBorder radius="md" p="lg">
|
|
||||||
<ClearancePhaseStepper
|
|
||||||
clearance={clearance}
|
|
||||||
tradeDirection={contract.tradeDirection}
|
|
||||||
/>
|
|
||||||
</Paper>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{ready ? (
|
{ready ? (
|
||||||
<Alert
|
<Alert
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
@@ -176,29 +210,57 @@ export default function ContractClearanceDetailPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Grid gap="lg">
|
<Grid>
|
||||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
<Grid.Col span={{ base: 12, lg: reviewColSpan }}>
|
||||||
<ContractClearanceReviewSection
|
{roleMode === "DJ" ? (
|
||||||
contractId={id!}
|
<SectionCard
|
||||||
hideSummary
|
icon={ClipboardList}
|
||||||
selfClear={false}
|
title="Contract context"
|
||||||
readOnly={clearanceReadOnly}
|
accent="edr-green"
|
||||||
onChanged={() => void refetch()}
|
>
|
||||||
/>
|
<Text size="sm" c="dimmed" mb="sm">
|
||||||
|
Review upstream status before uploading Djibouti documents.
|
||||||
|
</Text>
|
||||||
|
<ContractClearanceReviewSection
|
||||||
|
contractId={id!}
|
||||||
|
hideSummary
|
||||||
|
selfClear={false}
|
||||||
|
readOnly
|
||||||
|
phasedCustoms={phasedCustoms}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
) : (
|
||||||
|
<ContractClearanceReviewSection
|
||||||
|
contractId={id!}
|
||||||
|
hideSummary
|
||||||
|
selfClear={false}
|
||||||
|
readOnly={reviewReadOnly}
|
||||||
|
approvalsLocked={phasedCustoms && docReviewLocked}
|
||||||
|
phasedCustoms={phasedCustoms}
|
||||||
|
onChanged={() => void refetch()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{workflowFiles.length > 0 ? (
|
||||||
|
<Box mt="lg">
|
||||||
|
<ClearanceWorkflowFilesPanel
|
||||||
|
files={workflowFiles}
|
||||||
|
onView={view}
|
||||||
|
onDownload={(f) => void downloadBookingFile(f.id, f.name)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
<Grid.Col span={{ base: 12, lg: actionColSpan }}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{contract?.contractKind === "ONE_TIME" && contract.customsClearingEnabled ? (
|
{phasedCustoms ? (
|
||||||
<PhasedClearanceActionPanel
|
<PhasedClearanceActionPanel
|
||||||
contractId={id!}
|
contractId={id!}
|
||||||
clearance={clearance}
|
clearance={clearance}
|
||||||
tradeDirection={contract.tradeDirection}
|
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
|
||||||
roleMode="ALL"
|
roleMode={roleMode}
|
||||||
onChanged={() => void refetch()}
|
onChanged={() => void refetch()}
|
||||||
bookingCreateHref={
|
bookingCreateHref={ready ? bookingHref : undefined}
|
||||||
ready ? `/dashboard/contracts/${id}/create-booking` : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Box style={{ position: "sticky", top: 24 }}>
|
<Box style={{ position: "sticky", top: 24 }}>
|
||||||
@@ -248,17 +310,8 @@ export default function ContractClearanceDetailPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{clearance.milestones && clearance.milestones.length > 0 ? (
|
|
||||||
<ClearanceMilestoneTimeline
|
|
||||||
milestones={
|
|
||||||
clearance.milestones as Parameters<
|
|
||||||
typeof ClearanceMilestoneTimeline
|
|
||||||
>[0]["milestones"]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{viewer}
|
||||||
</PageContainer>
|
</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 { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FileText,
|
FileText,
|
||||||
|
Flag,
|
||||||
Inbox,
|
Inbox,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
Ship,
|
||||||
ShipWheel,
|
ShipWheel,
|
||||||
Table as TableIcon,
|
Table as TableIcon,
|
||||||
Truck,
|
Truck,
|
||||||
@@ -43,9 +45,16 @@ import { PageContainer } from "@/components/page/PageContainer";
|
|||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||||
import { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import {
|
||||||
|
useContractClearanceQueue,
|
||||||
|
useDjClearanceQueue,
|
||||||
|
useEtClearanceQueue,
|
||||||
|
} from "@/hooks/contracts/useContracts";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
type ViewMode = "table" | "cards";
|
type ViewMode = "table" | "cards";
|
||||||
|
type QueueTab = "all" | "et" | "dj";
|
||||||
|
|
||||||
interface ClearanceRow {
|
interface ClearanceRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -167,12 +176,81 @@ function StatusBadge({ row }: { row: ClearanceRow }) {
|
|||||||
*/
|
*/
|
||||||
export default function ContractClearanceListPage() {
|
export default function ContractClearanceListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||||
|
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
||||||
|
const canDj = hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
|
||||||
|
|
||||||
|
const defaultQueue: QueueTab = canReview
|
||||||
|
? "all"
|
||||||
|
: canEt
|
||||||
|
? "et"
|
||||||
|
: canDj
|
||||||
|
? "dj"
|
||||||
|
: "all";
|
||||||
|
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [view, setView] = useState<ViewMode>("table");
|
const [view, setView] = useState<ViewMode>("table");
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
|
||||||
const { data, isLoading, isError, isFetching, refetch } =
|
const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } =
|
||||||
useContractClearanceQueue(true);
|
useContractClearanceQueue(queueTab === "all");
|
||||||
|
const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } =
|
||||||
|
useEtClearanceQueue(queueTab === "et");
|
||||||
|
const { data: djData, isLoading: djLoading, isError: djError, isFetching: djFetching, refetch: refetchDj } =
|
||||||
|
useDjClearanceQueue(queueTab === "dj");
|
||||||
|
|
||||||
|
const data =
|
||||||
|
queueTab === "et" ? etData : queueTab === "dj" ? djData : allData;
|
||||||
|
const isLoading =
|
||||||
|
queueTab === "et" ? etLoading : queueTab === "dj" ? djLoading : allLoading;
|
||||||
|
const isError =
|
||||||
|
queueTab === "et" ? etError : queueTab === "dj" ? djError : allError;
|
||||||
|
const isFetching =
|
||||||
|
queueTab === "et" ? etFetching : queueTab === "dj" ? djFetching : allFetching;
|
||||||
|
const refetch = () => {
|
||||||
|
if (queueTab === "et") void refetchEt();
|
||||||
|
else if (queueTab === "dj") void refetchDj();
|
||||||
|
else void refetchAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const queueTabOptions = useMemo(() => {
|
||||||
|
const opts: { value: QueueTab; label: ReactNode }[] = [];
|
||||||
|
if (canReview || (canEt && canDj)) {
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (canDj) {
|
||||||
|
opts.push({
|
||||||
|
value: "dj",
|
||||||
|
label: (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Ship size={15} />
|
||||||
|
<Box visibleFrom="sm">DJ queue</Box>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}, [canReview, canEt, canDj]);
|
||||||
|
|
||||||
const allRows = useMemo(
|
const allRows = useMemo(
|
||||||
() => (data?.items ?? []).map(toClearanceRow),
|
() => (data?.items ?? []).map(toClearanceRow),
|
||||||
@@ -380,6 +458,20 @@ export default function ContractClearanceListPage() {
|
|||||||
|
|
||||||
<Card p={0} withBorder shadow="sm" radius="lg">
|
<Card p={0} withBorder shadow="sm" radius="lg">
|
||||||
<Stack gap={0}>
|
<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">
|
<Box px="md" pt="md" pb="sm">
|
||||||
<Group justify="space-between" gap="md" wrap="wrap">
|
<Group justify="space-between" gap="md" wrap="wrap">
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -48,6 +49,7 @@ import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflow
|
|||||||
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
||||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||||
|
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||||
import {
|
import {
|
||||||
ContractCustomerCard,
|
ContractCustomerCard,
|
||||||
ContractDocumentsCard,
|
ContractDocumentsCard,
|
||||||
@@ -59,6 +61,7 @@ import {
|
|||||||
useContractMutations,
|
useContractMutations,
|
||||||
} from "@/hooks/contracts/useContracts";
|
} from "@/hooks/contracts/useContracts";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
import { downloadBookingFile } from "@/services/files.service";
|
import { downloadBookingFile } from "@/services/files.service";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
@@ -140,6 +143,15 @@ export default function ContractRequestDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 () => {
|
const downloadContractPdf = async () => {
|
||||||
if (!contract?.id) return;
|
if (!contract?.id) return;
|
||||||
try {
|
try {
|
||||||
@@ -222,6 +234,13 @@ export default function ContractRequestDetailPage() {
|
|||||||
contract.status === "APPROVED_PENDING_SIGNATURE";
|
contract.status === "APPROVED_PENDING_SIGNATURE";
|
||||||
|
|
||||||
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
|
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.
|
// Once clearance is finalized the tab is informational only — no approve/query.
|
||||||
const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
|
const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
|
||||||
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
|
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
|
||||||
@@ -414,18 +433,39 @@ export default function ContractRequestDetailPage() {
|
|||||||
{/* LEFT — primary content */}
|
{/* LEFT — primary content */}
|
||||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
{currentTab === "clearance" ? (
|
{currentTab === "clearance" ? (
|
||||||
<ContractClearanceReviewSection
|
<Stack gap="lg">
|
||||||
contractId={id!}
|
<ContractClearanceReviewSection
|
||||||
selfClear={selfClear}
|
contractId={id!}
|
||||||
readOnly={clearanceReadOnly}
|
selfClear={selfClear}
|
||||||
onChanged={() => refetch()}
|
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" ? (
|
) : currentTab === "documents" ? (
|
||||||
<ContractDocumentsCard
|
<Stack gap="lg">
|
||||||
files={files}
|
<ContractDocumentsCard
|
||||||
onView={handleViewFile}
|
files={files}
|
||||||
onDownload={handleDownloadFile}
|
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" ? (
|
) : currentTab === "customer" ? (
|
||||||
<ContractCustomerCard contract={contract} />
|
<ContractCustomerCard contract={contract} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
import { Alert, Grid, Loader, Paper, Stack, Text } from "@mantine/core";
|
|
||||||
import { AlertCircle } from "lucide-react";
|
|
||||||
|
|
||||||
import { PageContainer } from "@/components/page/PageContainer";
|
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
|
||||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
|
||||||
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
|
||||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
|
||||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
|
||||||
import { contractsService } from "@/services/contracts.service";
|
|
||||||
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
|
||||||
|
|
||||||
export default function GlClearanceDetailPage({
|
|
||||||
backTo,
|
|
||||||
breadcrumbsLabel,
|
|
||||||
roleMode,
|
|
||||||
}: {
|
|
||||||
backTo: string;
|
|
||||||
breadcrumbsLabel: string;
|
|
||||||
roleMode: "ET" | "DJ";
|
|
||||||
}) {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const { data: contract } = useContractDetail(id);
|
|
||||||
const {
|
|
||||||
data: clearance,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
refetch,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
|
||||||
queryFn: () => contractsService.getClearance(id!),
|
|
||||||
enabled: Boolean(id),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<Stack align="center" py={80}>
|
|
||||||
<Loader color="edr-green" />
|
|
||||||
<Text c="dimmed">Loading clearance…</Text>
|
|
||||||
</Stack>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError || !clearance || !contract) {
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<Alert color="red" icon={<AlertCircle size={16} />}>
|
|
||||||
Could not load clearance for this contract.
|
|
||||||
</Alert>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const bookingHref =
|
|
||||||
roleMode === "ET" ? `/dashboard/contracts/${id}/create-booking` : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<PageHeader
|
|
||||||
title={contract.reference}
|
|
||||||
backTo={backTo}
|
|
||||||
breadcrumbs={[
|
|
||||||
{ label: breadcrumbsLabel, href: backTo },
|
|
||||||
{ label: contract.reference },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Paper withBorder radius="md" p="lg">
|
|
||||||
<ClearancePhaseStepper
|
|
||||||
clearance={clearance}
|
|
||||||
tradeDirection={contract.tradeDirection}
|
|
||||||
/>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Grid>
|
|
||||||
<Grid.Col span={{ base: 12, lg: roleMode === "DJ" ? 12 : 7 }}>
|
|
||||||
{roleMode === "ET" ? (
|
|
||||||
<ContractClearanceReviewSection
|
|
||||||
contractId={id!}
|
|
||||||
hideSummary
|
|
||||||
selfClear={false}
|
|
||||||
readOnly={false}
|
|
||||||
onChanged={() => void refetch()}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<SectionCard title="Contract context" accent="edr-green">
|
|
||||||
<Text size="sm" c="dimmed" mb="sm">
|
|
||||||
Review upstream status before uploading Djibouti documents.
|
|
||||||
</Text>
|
|
||||||
<ContractClearanceReviewSection
|
|
||||||
contractId={id!}
|
|
||||||
hideSummary
|
|
||||||
selfClear={false}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
</SectionCard>
|
|
||||||
)}
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={{ base: 12, lg: roleMode === "DJ" ? 12 : 5 }}>
|
|
||||||
<PhasedClearanceActionPanel
|
|
||||||
contractId={id!}
|
|
||||||
clearance={clearance}
|
|
||||||
tradeDirection={contract.tradeDirection}
|
|
||||||
roleMode={roleMode}
|
|
||||||
onChanged={() => void refetch()}
|
|
||||||
bookingCreateHref={bookingHref}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{clearance.milestones && clearance.milestones.length > 0 ? (
|
|
||||||
<ClearanceMilestoneTimeline
|
|
||||||
milestones={clearance.milestones as Parameters<typeof ClearanceMilestoneTimeline>[0]["milestones"]}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
|
|
||||||
import { ChevronRight, Ship } from "lucide-react";
|
|
||||||
|
|
||||||
import { PageContainer } from "@/components/page/PageContainer";
|
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
|
||||||
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
|
||||||
|
|
||||||
export default function GlDjiboutiClearanceListPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { data, isLoading } = useDjClearanceQueue();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<PageHeader
|
|
||||||
title="GL Djibouti — Clearance"
|
|
||||||
subtitle="Contracts awaiting Djibouti GL action (DO / RO)."
|
|
||||||
/>
|
|
||||||
{isLoading ? (
|
|
||||||
<Group justify="center" py={60}>
|
|
||||||
<Loader color="edr-green" />
|
|
||||||
</Group>
|
|
||||||
) : (
|
|
||||||
<Stack gap="sm">
|
|
||||||
{(data?.items ?? []).length === 0 ? (
|
|
||||||
<Text c="dimmed" ta="center" py="xl">
|
|
||||||
No contracts need Djibouti GL action right now.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
(data?.items ?? []).map((c) => (
|
|
||||||
<Card
|
|
||||||
key={c.id}
|
|
||||||
withBorder
|
|
||||||
radius="md"
|
|
||||||
padding="md"
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<Group gap="sm">
|
|
||||||
<Ship size={18} className="text-[color:var(--freight-brand)]" />
|
|
||||||
<div>
|
|
||||||
<Text fw={700}>{c.reference}</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{c.tradeDirection} · {c.status}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Badge variant="light" color="cyan">
|
|
||||||
{c.tradeDirection}
|
|
||||||
</Badge>
|
|
||||||
<ChevronRight size={18} className="text-muted-foreground" />
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
|
|
||||||
import { ChevronRight, Flag } from "lucide-react";
|
|
||||||
|
|
||||||
import { PageContainer } from "@/components/page/PageContainer";
|
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
|
||||||
import { useEtClearanceQueue } from "@/hooks/contracts/useContracts";
|
|
||||||
|
|
||||||
export default function GlEthiopiaClearanceListPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { data, isLoading } = useEtClearanceQueue();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer>
|
|
||||||
<PageHeader
|
|
||||||
title="GL Ethiopia — Clearance"
|
|
||||||
subtitle="Contracts awaiting Ethiopia GL action in the phased clearance workflow."
|
|
||||||
/>
|
|
||||||
{isLoading ? (
|
|
||||||
<Group justify="center" py={60}>
|
|
||||||
<Loader color="edr-green" />
|
|
||||||
</Group>
|
|
||||||
) : (
|
|
||||||
<Stack gap="sm">
|
|
||||||
{(data?.items ?? []).length === 0 ? (
|
|
||||||
<Text c="dimmed" ta="center" py="xl">
|
|
||||||
No contracts need ET GL action right now.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
(data?.items ?? []).map((c) => (
|
|
||||||
<Card
|
|
||||||
key={c.id}
|
|
||||||
withBorder
|
|
||||||
radius="md"
|
|
||||||
padding="md"
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
onClick={() => navigate(`/dashboard/gl-ethiopia/clearance/${c.id}`)}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<Group gap="sm">
|
|
||||||
<Flag size={18} className="text-[color:var(--freight-brand)]" />
|
|
||||||
<div>
|
|
||||||
<Text fw={700}>{c.reference}</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{c.tradeDirection} · {c.status}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Badge variant="light" color="edr-green">
|
|
||||||
{c.tradeDirection}
|
|
||||||
</Badge>
|
|
||||||
<ChevronRight size={18} className="text-muted-foreground" />
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,30 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Group,
|
Group,
|
||||||
|
Modal,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
Textarea,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { ChevronRight, Inbox, PackageSearch, RefreshCw, Search } from "lucide-react";
|
import { Inbox, PackageSearch, RefreshCw, Search } from "lucide-react";
|
||||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { PageContainer } from "@/components/page/PageContainer";
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
|
import {
|
||||||
|
getShipmentRejectAction,
|
||||||
|
getShipmentStaffRowAction,
|
||||||
|
type ShipmentListRow,
|
||||||
|
} from "@/features/contracts/mapShipmentListRow";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
|
||||||
const cellMeta = {
|
const cellMeta = {
|
||||||
@@ -33,7 +41,6 @@ const fmtDate = (iso?: string | null) =>
|
|||||||
}).format(new Date(iso))
|
}).format(new Date(iso))
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
/** Summarize requested quantities for the list row. */
|
|
||||||
function summarizeLines(lines: Freight.RequestedShipmentLines): string {
|
function summarizeLines(lines: Freight.RequestedShipmentLines): string {
|
||||||
if (lines.containers?.length) {
|
if (lines.containers?.length) {
|
||||||
return lines.containers
|
return lines.containers
|
||||||
@@ -49,17 +56,13 @@ function summarizeLines(lines: Freight.RequestedShipmentLines): string {
|
|||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RequestRow {
|
|
||||||
id: string;
|
|
||||||
reference: string;
|
|
||||||
contractReference: string;
|
|
||||||
scheduledDate?: string | null;
|
|
||||||
summary: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ShipmentRequestsPage() {
|
export default function ShipmentRequestsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [rejectTarget, setRejectTarget] = useState<ShipmentListRow | null>(null);
|
||||||
|
const [rejectNote, setRejectNote] = useState("");
|
||||||
|
const [acceptTarget, setAcceptTarget] = useState<ShipmentListRow | null>(null);
|
||||||
|
|
||||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||||
queryKey: ["shipment-request-queue"],
|
queryKey: ["shipment-request-queue"],
|
||||||
@@ -67,13 +70,26 @@ export default function ShipmentRequestsPage() {
|
|||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = useMemo<RequestRow[]>(() => {
|
const reject = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
contractsService.rejectBookingRequest(rejectTarget!.id, rejectNote),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["shipment-request-queue"] });
|
||||||
|
setRejectTarget(null);
|
||||||
|
setRejectNote("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = useMemo<ShipmentListRow[]>(() => {
|
||||||
const all = (data ?? []).map((r) => ({
|
const all = (data ?? []).map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
reference: r.reference || r.id.slice(0, 8),
|
reference: r.reference || r.id.slice(0, 8),
|
||||||
|
contractId: r.contractId,
|
||||||
contractReference: r.contract?.reference ?? r.contractId,
|
contractReference: r.contract?.reference ?? r.contractId,
|
||||||
scheduledDate: r.scheduledDate,
|
scheduledDate: r.scheduledDate,
|
||||||
summary: summarizeLines(r.requestedLines ?? {}),
|
summary: summarizeLines(r.requestedLines ?? {}),
|
||||||
|
status: r.status,
|
||||||
|
createdBookingId: r.createdBookingId,
|
||||||
}));
|
}));
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
if (!q) return all;
|
if (!q) return all;
|
||||||
@@ -85,7 +101,7 @@ export default function ShipmentRequestsPage() {
|
|||||||
);
|
);
|
||||||
}, [data, query]);
|
}, [data, query]);
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<RequestRow>[]>(
|
const columns = useMemo<ColumnDef<ShipmentListRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: "reference",
|
id: "reference",
|
||||||
@@ -126,16 +142,55 @@ export default function ShipmentRequestsPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "go",
|
id: "actions",
|
||||||
size: 56,
|
header: () => <span className={ruleEngineTable.headerCell}>Action</span>,
|
||||||
cell: () => (
|
meta: cellMeta,
|
||||||
<Group justify="flex-end" pr="xs">
|
cell: ({ row }) => {
|
||||||
<ChevronRight size={16} className="text-muted-foreground" />
|
const primary = getShipmentStaffRowAction(row.original);
|
||||||
</Group>
|
const rejectAction = getShipmentRejectAction(row.original);
|
||||||
),
|
return (
|
||||||
|
<Group gap={6} wrap="nowrap" justify="flex-end">
|
||||||
|
{rejectAction ? (
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setRejectTarget(row.original);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rejectAction.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{primary.kind === "navigate" ? (
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
variant={primary.variant === "filled" ? "filled" : primary.variant}
|
||||||
|
color="edr-green"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (
|
||||||
|
primary.label === "Accept" &&
|
||||||
|
row.original.status === "PENDING"
|
||||||
|
) {
|
||||||
|
setAcceptTarget(row.original);
|
||||||
|
} else {
|
||||||
|
navigate(primary.to(row.original));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{primary.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -203,6 +258,95 @@ export default function ShipmentRequestsPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={rejectTarget !== null}
|
||||||
|
onClose={() => {
|
||||||
|
if (!reject.isPending) {
|
||||||
|
setRejectTarget(null);
|
||||||
|
setRejectNote("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Reject shipment request"
|
||||||
|
radius="md"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Reject request{" "}
|
||||||
|
<Text span fw={600}>
|
||||||
|
{rejectTarget?.reference}
|
||||||
|
</Text>
|
||||||
|
? The customer will be notified.
|
||||||
|
</Text>
|
||||||
|
<Textarea
|
||||||
|
label="Reason"
|
||||||
|
placeholder="Explain why this request cannot be accepted…"
|
||||||
|
value={rejectNote}
|
||||||
|
onChange={(e) => setRejectNote(e.currentTarget.value)}
|
||||||
|
minRows={3}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => {
|
||||||
|
setRejectTarget(null);
|
||||||
|
setRejectNote("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
loading={reject.isPending}
|
||||||
|
disabled={!rejectNote.trim()}
|
||||||
|
onClick={() => reject.mutate()}
|
||||||
|
>
|
||||||
|
Reject request
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={acceptTarget !== null}
|
||||||
|
onClose={() => setAcceptTarget(null)}
|
||||||
|
title="Accept shipment request"
|
||||||
|
radius="md"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Proceed to create a booking for request{" "}
|
||||||
|
<Text span fw={600}>
|
||||||
|
{acceptTarget?.reference}
|
||||||
|
</Text>
|
||||||
|
? You will confirm the shipment price before submitting.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" radius="md" onClick={() => setAcceptTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => {
|
||||||
|
if (!acceptTarget) return;
|
||||||
|
const to = getShipmentStaffRowAction(acceptTarget);
|
||||||
|
if (to.kind === "navigate") {
|
||||||
|
navigate(to.to(acceptTarget));
|
||||||
|
}
|
||||||
|
setAcceptTarget(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { FormEvent, useMemo, useState } from "react";
|
import { FormEvent, useMemo, useState } from "react";
|
||||||
import { Ban, CircleCheck, Edit, Eye, Plus, Route as RouteIcon, Trash2 } from "lucide-react";
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Ban,
|
||||||
|
CircleCheck,
|
||||||
|
Edit,
|
||||||
|
Eye,
|
||||||
|
Plus,
|
||||||
|
Route as RouteIcon,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import type { ColumnDef } from "@edr/ui-common";
|
import type { ColumnDef } from "@edr/ui-common";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
@@ -7,13 +16,16 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
|
NumberInput,
|
||||||
Select,
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
@@ -26,22 +38,51 @@ import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
|||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { RouteRecord, YardRef } from "@/services/routes.service";
|
import {
|
||||||
|
formatRouteLabel,
|
||||||
|
ROUTE_STATUS_OPTIONS,
|
||||||
|
totalRouteDistanceKm,
|
||||||
|
type RouteRecord,
|
||||||
|
type RouteStatus,
|
||||||
|
type YardRef,
|
||||||
|
} from "@/services/routes.service";
|
||||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||||
|
|
||||||
|
type MilestoneFormRow = { yardId: string; distanceKm: string };
|
||||||
|
|
||||||
type RouteFormState = {
|
type RouteFormState = {
|
||||||
name: string;
|
status: RouteStatus;
|
||||||
milestones: string[];
|
milestones: MilestoneFormRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyForm = (): RouteFormState => ({ name: "", milestones: ["", ""] });
|
const emptyForm = (): RouteFormState => ({
|
||||||
|
status: "AVAILABLE",
|
||||||
|
milestones: [
|
||||||
|
{ yardId: "", distanceKm: "0" },
|
||||||
|
{ yardId: "", distanceKm: "" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
const yardLabel = (yard?: YardRef | null) => (yard ? `${yard.label} (${yard.code})` : "—");
|
const yardLabel = (yard?: YardRef | null) =>
|
||||||
|
yard ? `${yard.label} (${yard.code})` : "—";
|
||||||
|
|
||||||
const routeStops = (route: RouteRecord) =>
|
const statusColor = (status: RouteStatus) => {
|
||||||
(route.milestones ?? [])
|
switch (status) {
|
||||||
.sort((left, right) => left.sequenceNo - right.sequenceNo)
|
case "AVAILABLE":
|
||||||
.map((milestone) => milestone.yard?.label ?? milestone.yard?.code ?? milestone.yardId);
|
return "edr-green";
|
||||||
|
case "MAINTENANCE":
|
||||||
|
return "yellow";
|
||||||
|
case "DAMAGED":
|
||||||
|
return "red";
|
||||||
|
case "STOP_WORKING":
|
||||||
|
return "gray";
|
||||||
|
default:
|
||||||
|
return "gray";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel = (status: RouteStatus) =>
|
||||||
|
ROUTE_STATUS_OPTIONS.find((o) => o.value === status)?.label ?? status;
|
||||||
|
|
||||||
const normalizeRouteError = (error: unknown) => {
|
const normalizeRouteError = (error: unknown) => {
|
||||||
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
||||||
@@ -57,6 +98,60 @@ const normalizeRouteError = (error: unknown) => {
|
|||||||
: "Save failed";
|
: "Save failed";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function RouteTimeline({ route }: { route: RouteRecord }) {
|
||||||
|
const stops = [...(route.milestones ?? [])].sort(
|
||||||
|
(a, b) => a.sequenceNo - b.sequenceNo,
|
||||||
|
);
|
||||||
|
const total = totalRouteDistanceKm(route);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{stops.map((milestone, index) => {
|
||||||
|
const label =
|
||||||
|
milestone.yard?.label ?? milestone.yard?.code ?? milestone.yardId;
|
||||||
|
const role =
|
||||||
|
index === 0
|
||||||
|
? "Origin"
|
||||||
|
: index === stops.length - 1
|
||||||
|
? "Destination"
|
||||||
|
: `Milestone ${index}`;
|
||||||
|
const km = Number(milestone.distanceKm ?? 0);
|
||||||
|
return (
|
||||||
|
<Box key={milestone.id ?? `${milestone.yardId}-${index}`}>
|
||||||
|
{index > 0 && (
|
||||||
|
<Group gap={8} pl={18} py={6}>
|
||||||
|
<ThemeIcon size={22} radius="xl" variant="light" color="gray">
|
||||||
|
<ArrowRight size={12} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" fw={600}>
|
||||||
|
{km} km
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<Badge size="sm" variant="light" color={index === 0 ? "teal" : "gray"}>
|
||||||
|
{role}
|
||||||
|
</Badge>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Divider />
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
Total distance
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={700}>
|
||||||
|
{total} km
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function RoutesPage() {
|
export default function RoutesPage() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
@@ -78,12 +173,14 @@ export default function RoutesPage() {
|
|||||||
if (!query) return routesQuery.data ?? [];
|
if (!query) return routesQuery.data ?? [];
|
||||||
return (routesQuery.data ?? []).filter((route) => {
|
return (routesQuery.data ?? []).filter((route) => {
|
||||||
const searchable = [
|
const searchable = [
|
||||||
route.name,
|
formatRouteLabel(route),
|
||||||
route.originYard?.label,
|
route.originYard?.label,
|
||||||
route.originYard?.code,
|
route.originYard?.code,
|
||||||
route.destinationYard?.label,
|
route.destinationYard?.label,
|
||||||
route.destinationYard?.code,
|
route.destinationYard?.code,
|
||||||
...routeStops(route),
|
...(route.milestones ?? []).map(
|
||||||
|
(m) => m.yard?.label ?? m.yard?.code ?? m.yardId,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")
|
.join(" ")
|
||||||
@@ -99,7 +196,7 @@ export default function RoutesPage() {
|
|||||||
}, [filteredRoutes, pagination.pageIndex, pagination.pageSize]);
|
}, [filteredRoutes, pagination.pageIndex, pagination.pageSize]);
|
||||||
|
|
||||||
const allRoutes = routesQuery.data ?? [];
|
const allRoutes = routesQuery.data ?? [];
|
||||||
const activeCount = allRoutes.filter((route) => route.isActive).length;
|
const availableCount = allRoutes.filter((r) => r.status === "AVAILABLE").length;
|
||||||
|
|
||||||
const yardOptions = useMemo(
|
const yardOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -110,6 +207,16 @@ export default function RoutesPage() {
|
|||||||
[yardsQuery.data],
|
[yardsQuery.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const formTotalKm = useMemo(
|
||||||
|
() =>
|
||||||
|
form.milestones.reduce(
|
||||||
|
(sum, row, index) =>
|
||||||
|
index === 0 ? sum : sum + Number(row.distanceKm || 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
[form.milestones],
|
||||||
|
);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setFormOpen(false);
|
setFormOpen(false);
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
@@ -125,41 +232,52 @@ export default function RoutesPage() {
|
|||||||
const openEdit = (route: RouteRecord) => {
|
const openEdit = (route: RouteRecord) => {
|
||||||
setEditing(route);
|
setEditing(route);
|
||||||
setForm({
|
setForm({
|
||||||
name: route.name,
|
status: route.status,
|
||||||
milestones: (route.milestones ?? [])
|
milestones: [...(route.milestones ?? [])]
|
||||||
.sort((left, right) => left.sequenceNo - right.sequenceNo)
|
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||||
.map((milestone) => milestone.yardId),
|
.map((m, index) => ({
|
||||||
|
yardId: m.yardId,
|
||||||
|
distanceKm: String(index === 0 ? 0 : (m.distanceKm ?? "")),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setMilestone = (index: number, yardId: string) => {
|
const setMilestone = (index: number, patch: Partial<MilestoneFormRow>) => {
|
||||||
setForm((current) => ({
|
setForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
milestones: current.milestones.map((value, currentIndex) =>
|
milestones: current.milestones.map((row, i) =>
|
||||||
currentIndex === index ? yardId : value,
|
i === index ? { ...row, ...patch } : row,
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMilestone = () => {
|
const addMilestone = () => {
|
||||||
setForm((current) => ({ ...current, milestones: [...current.milestones, ""] }));
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
milestones: [...current.milestones, { yardId: "", distanceKm: "" }],
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeMilestone = (index: number) => {
|
const removeMilestone = (index: number) => {
|
||||||
setForm((current) => ({
|
setForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
milestones: current.milestones.filter((_, currentIndex) => currentIndex !== index),
|
milestones: current.milestones.filter((_, i) => i !== index),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildPayload = () => ({
|
||||||
|
status: form.status,
|
||||||
|
milestones: form.milestones.map((row, index) => ({
|
||||||
|
yardId: row.yardId,
|
||||||
|
distanceKm:
|
||||||
|
index === 0 ? 0 : row.distanceKm ? Number(row.distanceKm) : undefined,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent) => {
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!form.name.trim()) {
|
if (form.milestones.length < 2 || form.milestones.some((row) => !row.yardId)) {
|
||||||
toast({ title: "Save failed", description: "Route name is required", variant: "destructive" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (form.milestones.length < 2 || form.milestones.some((yardId) => !yardId)) {
|
|
||||||
toast({
|
toast({
|
||||||
title: "Save failed",
|
title: "Save failed",
|
||||||
description: "Select at least an origin and destination yard",
|
description: "Select at least an origin and destination yard",
|
||||||
@@ -167,13 +285,20 @@ export default function RoutesPage() {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
for (let i = 1; i < form.milestones.length; i++) {
|
||||||
|
const km = Number(form.milestones[i].distanceKm);
|
||||||
|
if (!form.milestones[i].distanceKm || Number.isNaN(km) || km < 0) {
|
||||||
|
toast({
|
||||||
|
title: "Save failed",
|
||||||
|
description: `Enter segment KM for stop ${i + 1}`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = buildPayload();
|
||||||
name: form.name.trim(),
|
|
||||||
milestones: form.milestones.map((yardId) => ({ yardId })),
|
|
||||||
isActive: editing?.isActive ?? true,
|
|
||||||
};
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
await updateMutation.mutateAsync({ id: editing.id, data: payload });
|
await updateMutation.mutateAsync({ id: editing.id, data: payload });
|
||||||
toast({ title: "Route updated" });
|
toast({ title: "Route updated" });
|
||||||
@@ -190,9 +315,19 @@ export default function RoutesPage() {
|
|||||||
const handleDeactivate = async (route: RouteRecord) => {
|
const handleDeactivate = async (route: RouteRecord) => {
|
||||||
try {
|
try {
|
||||||
await deactivateMutation.mutateAsync(route.id);
|
await deactivateMutation.mutateAsync(route.id);
|
||||||
toast({ title: "Route deactivated" });
|
toast({ title: "Route marked stop working" });
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: "Deactivate failed", description: "Could not deactivate route", variant: "destructive" });
|
toast({ title: "Update failed", description: "Could not update route status", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = async (route: RouteRecord, status: RouteStatus) => {
|
||||||
|
try {
|
||||||
|
await updateMutation.mutateAsync({ id: route.id, data: { status } });
|
||||||
|
setViewing((current) => (current?.id === route.id ? { ...current, status } : current));
|
||||||
|
toast({ title: "Status updated" });
|
||||||
|
} catch (error) {
|
||||||
|
toast({ title: "Update failed", description: normalizeRouteError(error), variant: "destructive" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -200,10 +335,14 @@ export default function RoutesPage() {
|
|||||||
|
|
||||||
const availableOptionsForIndex = (index: number) => {
|
const availableOptionsForIndex = (index: number) => {
|
||||||
const selectedByOthers = new Set(
|
const selectedByOthers = new Set(
|
||||||
form.milestones.filter((value, currentIndex) => currentIndex !== index && value),
|
form.milestones
|
||||||
|
.filter((row, i) => i !== index && row.yardId)
|
||||||
|
.map((row) => row.yardId),
|
||||||
);
|
);
|
||||||
return yardOptions.filter(
|
return yardOptions.filter(
|
||||||
(option) => option.value === form.milestones[index] || !selectedByOthers.has(option.value),
|
(option) =>
|
||||||
|
option.value === form.milestones[index]?.yardId ||
|
||||||
|
!selectedByOthers.has(option.value),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -217,7 +356,16 @@ export default function RoutesPage() {
|
|||||||
const headerClassName = ruleEngineTable.headerCell;
|
const headerClassName = ruleEngineTable.headerCell;
|
||||||
const cellClassName = ruleEngineTable.bodyCell;
|
const cellClassName = ruleEngineTable.bodyCell;
|
||||||
return [
|
return [
|
||||||
{ id: "name", header: "Name", meta: { headerClassName, cellClassName }, cell: ({ row }) => row.original.name },
|
{
|
||||||
|
id: "corridor",
|
||||||
|
header: "Corridor",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{formatRouteLabel(row.original)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "origin",
|
id: "origin",
|
||||||
header: "Origin",
|
header: "Origin",
|
||||||
@@ -231,18 +379,24 @@ export default function RoutesPage() {
|
|||||||
cell: ({ row }) => yardLabel(row.original.destinationYard),
|
cell: ({ row }) => yardLabel(row.original.destinationYard),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "milestones",
|
id: "distance",
|
||||||
header: "Milestones",
|
header: "Total KM",
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => Math.max((row.original.milestones?.length ?? 0) - 2, 0),
|
cell: ({ row }) => `${totalRouteDistanceKm(row.original)} km`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "milestones",
|
||||||
|
header: "Stops",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => row.original.milestones?.length ?? 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge color={row.original.isActive ? "edr-green" : "gray"} variant="light" size="sm">
|
<Badge color={statusColor(row.original.status)} variant="light" size="sm">
|
||||||
{row.original.isActive ? "Active" : "Inactive"}
|
{statusLabel(row.original.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -262,11 +416,11 @@ export default function RoutesPage() {
|
|||||||
<Edit size={16} />
|
<Edit size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label="Deactivate">
|
<Tooltip label="Mark stop working">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color="red"
|
||||||
disabled={!row.original.isActive || deactivateMutation.isPending}
|
disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending}
|
||||||
onClick={() => handleDeactivate(row.original)}
|
onClick={() => handleDeactivate(row.original)}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
@@ -282,7 +436,7 @@ export default function RoutesPage() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Routes"
|
title="Routes"
|
||||||
subtitle="Define rail corridors and their ordered yard stops used by train scheduling."
|
subtitle="Define rail corridors, segment distances, and operational status for train scheduling."
|
||||||
action={
|
action={
|
||||||
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
|
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
|
||||||
Add route
|
Add route
|
||||||
@@ -294,10 +448,10 @@ export default function RoutesPage() {
|
|||||||
loading={routesQuery.isLoading}
|
loading={routesQuery.isLoading}
|
||||||
items={[
|
items={[
|
||||||
{ label: "Total routes", value: allRoutes.length, icon: RouteIcon },
|
{ label: "Total routes", value: allRoutes.length, icon: RouteIcon },
|
||||||
{ label: "Active", value: activeCount, icon: CircleCheck, color: "edr-green" },
|
{ label: "Available", value: availableCount, icon: CircleCheck, color: "edr-green" },
|
||||||
{
|
{
|
||||||
label: "Inactive",
|
label: "Unavailable",
|
||||||
value: allRoutes.length - activeCount,
|
value: allRoutes.length - availableCount,
|
||||||
icon: Ban,
|
icon: Ban,
|
||||||
color: "gray",
|
color: "gray",
|
||||||
},
|
},
|
||||||
@@ -310,7 +464,7 @@ export default function RoutesPage() {
|
|||||||
<FleetToolbar
|
<FleetToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder="Search routes…"
|
searchPlaceholder="Search corridors…"
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
/>
|
/>
|
||||||
@@ -359,16 +513,13 @@ export default function RoutesPage() {
|
|||||||
<Card key={route.id} radius="lg" padding="lg" withBorder>
|
<Card key={route.id} radius="lg" padding="lg" withBorder>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text fw={600}>{route.name}</Text>
|
<Text fw={600}>{formatRouteLabel(route)}</Text>
|
||||||
<Badge color={route.isActive ? "edr-green" : "gray"} variant="light" size="sm">
|
<Badge color={statusColor(route.status)} variant="light" size="sm">
|
||||||
{route.isActive ? "Active" : "Inactive"}
|
{statusLabel(route.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{yardLabel(route.originYard)} → {yardLabel(route.destinationYard)}
|
{totalRouteDistanceKm(route)} km · {route.milestones?.length ?? 0} stops
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{Math.max((route.milestones?.length ?? 0) - 2, 0)} intermediate milestones
|
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap={6} justify="flex-end">
|
<Group gap={6} justify="flex-end">
|
||||||
<Button variant="light" size="compact-sm" onClick={() => setViewing(route)}>
|
<Button variant="light" size="compact-sm" onClick={() => setViewing(route)}>
|
||||||
@@ -405,26 +556,25 @@ export default function RoutesPage() {
|
|||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<TextInput
|
{editing && (
|
||||||
label="Name"
|
<Select
|
||||||
value={form.name}
|
label="Status"
|
||||||
onChange={(e) => {
|
data={ROUTE_STATUS_OPTIONS}
|
||||||
// Capture the value before the state updater runs — React may
|
value={form.status}
|
||||||
// recycle the synthetic event, nulling currentTarget by the time
|
onChange={(value) =>
|
||||||
// the updater executes ("Cannot read properties of null").
|
value && setForm((current) => ({ ...current, status: value as RouteStatus }))
|
||||||
const name = e.currentTarget.value;
|
}
|
||||||
setForm((current) => ({ ...current, name }));
|
/>
|
||||||
}}
|
)}
|
||||||
/>
|
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
Stops
|
Stops & segment distances
|
||||||
</Text>
|
</Text>
|
||||||
<Button type="button" variant="light" size="compact-sm" onClick={addMilestone}>
|
<Button type="button" variant="light" size="compact-sm" onClick={addMilestone}>
|
||||||
Add milestone
|
Add milestone
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
{form.milestones.map((yardId, index) => {
|
{form.milestones.map((row, index) => {
|
||||||
const role =
|
const role =
|
||||||
index === 0
|
index === 0
|
||||||
? "Origin"
|
? "Origin"
|
||||||
@@ -432,18 +582,32 @@ export default function RoutesPage() {
|
|||||||
? "Destination"
|
? "Destination"
|
||||||
: "Milestone";
|
: "Milestone";
|
||||||
return (
|
return (
|
||||||
<Group key={`${role}-${index}`} align="flex-end" wrap="nowrap">
|
<Group key={`${role}-${index}`} align="flex-end" wrap="nowrap" gap="sm">
|
||||||
<Text w={100} size="sm" fw={500}>
|
<Text w={90} size="sm" fw={500}>
|
||||||
{role}
|
{role}
|
||||||
</Text>
|
</Text>
|
||||||
<Select
|
<Select
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
data={availableOptionsForIndex(index)}
|
data={availableOptionsForIndex(index)}
|
||||||
value={yardId || null}
|
value={row.yardId || null}
|
||||||
onChange={(value) => value && setMilestone(index, value)}
|
onChange={(value) => value && setMilestone(index, { yardId: value })}
|
||||||
placeholder="Select yard"
|
placeholder="Select yard"
|
||||||
searchable
|
searchable
|
||||||
/>
|
/>
|
||||||
|
{index > 0 ? (
|
||||||
|
<NumberInput
|
||||||
|
w={120}
|
||||||
|
label="KM"
|
||||||
|
min={0}
|
||||||
|
decimalScale={2}
|
||||||
|
value={row.distanceKm ? Number(row.distanceKm) : ""}
|
||||||
|
onChange={(value) =>
|
||||||
|
setMilestone(index, { distanceKm: String(value ?? "") })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box w={120} />
|
||||||
|
)}
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color="red"
|
||||||
@@ -455,6 +619,9 @@ export default function RoutesPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Total route distance: <strong>{formTotalKm} km</strong>
|
||||||
|
</Text>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" type="button" onClick={resetForm}>
|
<Button variant="default" type="button" onClick={resetForm}>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -470,44 +637,37 @@ export default function RoutesPage() {
|
|||||||
<Modal
|
<Modal
|
||||||
opened={Boolean(viewing)}
|
opened={Boolean(viewing)}
|
||||||
onClose={() => setViewing(null)}
|
onClose={() => setViewing(null)}
|
||||||
title={<Text fw={600}>Route details</Text>}
|
title={<Text fw={600}>{viewing ? formatRouteLabel(viewing) : "Route details"}</Text>}
|
||||||
radius="lg"
|
radius="lg"
|
||||||
centered
|
centered
|
||||||
|
size="md"
|
||||||
>
|
>
|
||||||
{viewing ? (
|
{viewing ? (
|
||||||
<Stack gap="sm">
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between" align="flex-end">
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
Status
|
||||||
|
</Text>
|
||||||
|
<Badge mt={4} color={statusColor(viewing.status)} variant="light">
|
||||||
|
{statusLabel(viewing.status)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
w={200}
|
||||||
|
label="Update status"
|
||||||
|
data={ROUTE_STATUS_OPTIONS}
|
||||||
|
value={viewing.status}
|
||||||
|
onChange={(value) =>
|
||||||
|
value && handleStatusChange(viewing, value as RouteStatus)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500} mb={8}>
|
||||||
Name
|
Road timeline
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<RouteTimeline route={viewing} />
|
||||||
{viewing.name}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
Status
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{viewing.isActive ? "Active" : "Inactive"}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
Stops
|
|
||||||
</Text>
|
|
||||||
<Stack gap={6} mt={6}>
|
|
||||||
{routeStops(viewing).map((stop, index, stops) => (
|
|
||||||
<Text key={`${stop}-${index}`} size="sm" c="dimmed">
|
|
||||||
{index === 0
|
|
||||||
? "Origin"
|
|
||||||
: index === stops.length - 1
|
|
||||||
? "Destination"
|
|
||||||
: `Milestone ${index}`}
|
|
||||||
: {stop}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ interface CargoNode extends RuleEngineRecord {
|
|||||||
cargoTypeName?: string;
|
cargoTypeName?: string;
|
||||||
code?: string;
|
code?: string;
|
||||||
parentGroupId?: string | null;
|
parentGroupId?: string | null;
|
||||||
showFreeTextBox?: boolean;
|
|
||||||
requiresDirectorApproval?: boolean;
|
requiresDirectorApproval?: boolean;
|
||||||
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
|
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
|
||||||
unitOfMeasure?: string | null;
|
unitOfMeasure?: string | null;
|
||||||
@@ -80,7 +79,6 @@ const FORM_FIELDS: FormFieldDef[] = [
|
|||||||
{ label: "Per item (break-bulk)", value: "PER_ITEM" },
|
{ label: "Per item (break-bulk)", value: "PER_ITEM" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
|
|
||||||
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||||
{ name: "isActive", label: "Active", type: "boolean" },
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
];
|
];
|
||||||
@@ -474,19 +472,6 @@ function CargoRow({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
{node.showFreeTextBox ? (
|
|
||||||
<Tooltip label="Shows a free-text box on booking" withArrow>
|
|
||||||
<Badge
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
color="blue"
|
|
||||||
radius="sm"
|
|
||||||
leftSection={<FileText size={11} />}
|
|
||||||
>
|
|
||||||
Free text
|
|
||||||
</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
) : null}
|
|
||||||
{node.unitOfMeasure ? (
|
{node.unitOfMeasure ? (
|
||||||
<Tooltip label="How bookings measure this cargo" withArrow>
|
<Tooltip label="How bookings measure this cargo" withArrow>
|
||||||
<Badge size="xs" variant="light" color="teal" radius="sm">
|
<Badge size="xs" variant="light" color="teal" radius="sm">
|
||||||
|
|||||||
@@ -177,7 +177,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
optional: true,
|
optional: true,
|
||||||
placeholder: "Select parent cargo type (optional)",
|
placeholder: "Select parent cargo type (optional)",
|
||||||
},
|
},
|
||||||
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
|
|
||||||
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||||
{ name: "isActive", label: "Active", type: "boolean" },
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
} from "@/components/trainScheduling/scheduleVisuals";
|
} from "@/components/trainScheduling/scheduleVisuals";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { formatRouteLabel } from "@/services/routes.service";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type { TrainScheduleListItem } from "@/types/trainScheduling";
|
import type { TrainScheduleListItem } from "@/types/trainScheduling";
|
||||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||||
@@ -88,7 +89,9 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
const schedulesQuery = useQuery(
|
const schedulesQuery = useQuery(
|
||||||
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
|
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(
|
const locomotivesQuery = useQuery(
|
||||||
api.trainScheduling.availableLocomotives.queryOptions({
|
api.trainScheduling.availableLocomotives.queryOptions({
|
||||||
input: { routeId: routeId || undefined },
|
input: { routeId: routeId || undefined },
|
||||||
@@ -97,10 +100,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
||||||
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
|
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
|
||||||
|
|
||||||
const activeRoutes = useMemo(
|
const activeRoutes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]);
|
||||||
() => (routesQuery.data ?? []).filter((r) => r.isActive),
|
|
||||||
[routesQuery.data],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
|
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
|
||||||
|
|
||||||
@@ -529,7 +529,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
<Select
|
<Select
|
||||||
label="Route"
|
label="Route"
|
||||||
placeholder="Select route"
|
placeholder="Select route"
|
||||||
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
|
data={activeRoutes.map((r) => ({ value: r.id, label: formatRouteLabel(r) }))}
|
||||||
value={routeId || null}
|
value={routeId || null}
|
||||||
onChange={(v) => setRouteId(v ?? "")}
|
onChange={(v) => setRouteId(v ?? "")}
|
||||||
searchable
|
searchable
|
||||||
|
|||||||
@@ -1122,8 +1122,14 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
routes: {
|
routes: {
|
||||||
list: endpoint<void, RouteRecord[]>("routes", "list", () =>
|
list: endpoint<{ status?: import("./routes.service").RouteStatus } | void, RouteRecord[]>(
|
||||||
routesService.getAll().then((r) => r.data),
|
"routes",
|
||||||
|
"list",
|
||||||
|
(input) =>
|
||||||
|
routesService
|
||||||
|
.getAll(input?.status ? { status: input.status } : undefined)
|
||||||
|
.then((r) => r.data),
|
||||||
|
(input) => ["routes", input?.status ?? "all"],
|
||||||
),
|
),
|
||||||
|
|
||||||
yards: endpoint<void, YardRef[]>(
|
yards: endpoint<void, YardRef[]>(
|
||||||
|
|||||||
@@ -301,6 +301,100 @@ export const bookingsService = {
|
|||||||
|
|
||||||
governmentExpedite: (id: string) =>
|
governmentExpedite: (id: string) =>
|
||||||
postBooking<BookingDetail>(B.GOVERNMENT_EXPEDITE(id)),
|
postBooking<BookingDetail>(B.GOVERNMENT_EXPEDITE(id)),
|
||||||
|
|
||||||
|
getClearance: async (id: string): Promise<Freight.ClearanceView> => {
|
||||||
|
const response = await client.get(B.CLEARANCE(id));
|
||||||
|
return unwrap(response.data) as Freight.ClearanceView;
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadDeclaration: async (
|
||||||
|
id: string,
|
||||||
|
files: Record<string, File | null>,
|
||||||
|
): Promise<BookingDetail> => {
|
||||||
|
const form = new FormData();
|
||||||
|
for (const [key, file] of Object.entries(files)) {
|
||||||
|
if (file) form.append(key, file);
|
||||||
|
}
|
||||||
|
const response = await client.post(B.CLEARANCE_DECLARATION(id), form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
return unwrap(response.data) as BookingDetail;
|
||||||
|
},
|
||||||
|
|
||||||
|
adviseDuty: async (
|
||||||
|
id: string,
|
||||||
|
payload: {
|
||||||
|
dutyRequired: boolean;
|
||||||
|
amount?: number;
|
||||||
|
currency?: string;
|
||||||
|
declarationSerial?: string;
|
||||||
|
attachment?: File | null;
|
||||||
|
},
|
||||||
|
): Promise<BookingDetail> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("dutyRequired", String(payload.dutyRequired));
|
||||||
|
if (payload.amount != null) form.append("amount", String(payload.amount));
|
||||||
|
if (payload.currency) form.append("currency", payload.currency);
|
||||||
|
if (payload.declarationSerial) {
|
||||||
|
form.append("declarationSerial", payload.declarationSerial);
|
||||||
|
}
|
||||||
|
if (payload.attachment) form.append("attachment", payload.attachment);
|
||||||
|
const response = await client.post(B.CLEARANCE_DUTY(id), form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
return unwrap(response.data) as BookingDetail;
|
||||||
|
},
|
||||||
|
|
||||||
|
finalizePreClearance: (id: string) =>
|
||||||
|
postBooking<BookingDetail>(B.CLEARANCE_FINALIZE_PRE(id)),
|
||||||
|
|
||||||
|
uploadTransitPermit: async (id: string, file: File): Promise<BookingDetail> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const response = await client.post(B.CLEARANCE_TRANSIT_PERMIT(id), form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
return unwrap(response.data) as BookingDetail;
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadDeliveryOrder: async (id: string, file: File): Promise<BookingDetail> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
return unwrap(response.data) as BookingDetail;
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadReleaseOrder: async (
|
||||||
|
id: string,
|
||||||
|
file: File,
|
||||||
|
vesselDepartureDate: string,
|
||||||
|
): Promise<{ hold?: boolean; holdReason?: string }> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
form.append("vesselDepartureDate", vesselDepartureDate);
|
||||||
|
const response = await client.post(B.CLEARANCE_RELEASE_ORDER(id), form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
return unwrap(response.data) as { hold?: boolean; holdReason?: string };
|
||||||
|
},
|
||||||
|
|
||||||
|
requestRoAmendment: (id: string, note?: string) =>
|
||||||
|
postBooking<BookingDetail>(B.CLEARANCE_RO_AMENDMENT(id), { note }),
|
||||||
|
|
||||||
|
confirmExportRelease: (id: string) =>
|
||||||
|
postBooking<BookingDetail>(B.CLEARANCE_EXPORT_RELEASE(id), {}),
|
||||||
|
|
||||||
|
getEtClearanceQueue: async (): Promise<BookingDetail[]> => {
|
||||||
|
const response = await client.get(B.CLEARANCE_ET_QUEUE);
|
||||||
|
return (unwrap(response.data) ?? []) as BookingDetail[];
|
||||||
|
},
|
||||||
|
|
||||||
|
getDjClearanceQueue: async (): Promise<BookingDetail[]> => {
|
||||||
|
const response = await client.get(B.CLEARANCE_DJ_QUEUE);
|
||||||
|
return (unwrap(response.data) ?? []) as BookingDetail[];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
async function ensurePdfBlob(blob: Blob): Promise<Blob> {
|
async function ensurePdfBlob(blob: Blob): Promise<Blob> {
|
||||||
|
|||||||
@@ -239,15 +239,32 @@ export const contractsService = {
|
|||||||
return unwrap(response.data) as Freight.IContract;
|
return unwrap(response.data) as Freight.IContract;
|
||||||
},
|
},
|
||||||
|
|
||||||
adviseContractDuty: (
|
adviseContractDuty: async (
|
||||||
id: string,
|
id: string,
|
||||||
payload: {
|
payload: {
|
||||||
dutyRequired: boolean;
|
dutyRequired: boolean;
|
||||||
amount?: number;
|
amount?: number;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
declarationSerial?: string;
|
declarationSerial?: string;
|
||||||
|
attachment?: File | null;
|
||||||
},
|
},
|
||||||
) => postContract<Freight.IContract>(C.CLEARANCE_DUTY(id), payload),
|
): Promise<Freight.IContract> => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("dutyRequired", String(payload.dutyRequired));
|
||||||
|
if (payload.amount != null) form.append("amount", String(payload.amount));
|
||||||
|
if (payload.currency) form.append("currency", payload.currency);
|
||||||
|
if (payload.declarationSerial) {
|
||||||
|
form.append("declarationSerial", payload.declarationSerial);
|
||||||
|
}
|
||||||
|
if (payload.attachment) form.append("attachment", payload.attachment);
|
||||||
|
const response = await client.post(C.CLEARANCE_DUTY(id), form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
return unwrap(response.data) as Freight.IContract;
|
||||||
|
},
|
||||||
|
|
||||||
|
finalizePreClearance: (id: string) =>
|
||||||
|
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE_PRE(id)),
|
||||||
|
|
||||||
uploadContractTransitPermit: async (
|
uploadContractTransitPermit: async (
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { api as apiClient } from '../auth/http';
|
|||||||
|
|
||||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||||
|
|
||||||
|
export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
|
||||||
|
|
||||||
export interface YardRef {
|
export interface YardRef {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -14,32 +16,54 @@ export interface RouteMilestone {
|
|||||||
routeId: string;
|
routeId: string;
|
||||||
yardId: string;
|
yardId: string;
|
||||||
sequenceNo: number;
|
sequenceNo: number;
|
||||||
|
distanceKm?: number | null;
|
||||||
yard?: YardRef | null;
|
yard?: YardRef | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteRecord {
|
export interface RouteRecord {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
status: RouteStatus;
|
||||||
originYardId: string;
|
originYardId: string;
|
||||||
destinationYardId: string;
|
destinationYardId: string;
|
||||||
isActive: boolean;
|
|
||||||
originYard?: YardRef | null;
|
originYard?: YardRef | null;
|
||||||
destinationYard?: YardRef | null;
|
destinationYard?: YardRef | null;
|
||||||
milestones?: RouteMilestone[];
|
milestones?: RouteMilestone[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveRoutePayload {
|
export interface SaveRoutePayload {
|
||||||
name: string;
|
milestones: Array<{ yardId: string; distanceKm?: number }>;
|
||||||
milestones: Array<{ yardId: string }>;
|
status?: RouteStatus;
|
||||||
isActive?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatRouteLabel(route: RouteRecord): string {
|
||||||
|
const origin =
|
||||||
|
route.originYard?.code ?? route.originYard?.label ?? 'Origin';
|
||||||
|
const dest =
|
||||||
|
route.destinationYard?.code ?? route.destinationYard?.label ?? 'Destination';
|
||||||
|
return `${origin} → ${dest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totalRouteDistanceKm(route: RouteRecord): number {
|
||||||
|
return (route.milestones ?? []).reduce(
|
||||||
|
(sum, m) => sum + Number(m.distanceKm ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROUTE_STATUS_OPTIONS: Array<{ value: RouteStatus; label: string }> = [
|
||||||
|
{ value: 'AVAILABLE', label: 'Available' },
|
||||||
|
{ value: 'MAINTENANCE', label: 'Maintenance' },
|
||||||
|
{ value: 'DAMAGED', label: 'Damaged' },
|
||||||
|
{ value: 'STOP_WORKING', label: 'Stop working' },
|
||||||
|
];
|
||||||
|
|
||||||
interface YardListResponse {
|
interface YardListResponse {
|
||||||
data: YardRef[];
|
data: YardRef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const routesService = {
|
export const routesService = {
|
||||||
getAll: () => apiClient.get<RouteRecord[]>(URL_CONSTANTS.ROUTES.BASE),
|
getAll: (params?: { status?: RouteStatus; search?: string }) =>
|
||||||
|
apiClient.get<RouteRecord[]>(URL_CONSTANTS.ROUTES.BASE, { params }),
|
||||||
getById: (id: string) => apiClient.get<RouteRecord>(URL_CONSTANTS.ROUTES.BY_ID(id)),
|
getById: (id: string) => apiClient.get<RouteRecord>(URL_CONSTANTS.ROUTES.BY_ID(id)),
|
||||||
create: (data: SaveRoutePayload) => apiClient.post(URL_CONSTANTS.ROUTES.BASE, data),
|
create: (data: SaveRoutePayload) => apiClient.post(URL_CONSTANTS.ROUTES.BASE, data),
|
||||||
update: (id: string, data: Partial<SaveRoutePayload>) =>
|
update: (id: string, data: Partial<SaveRoutePayload>) =>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import ContractViewPage from "./pages/contracts/ContractViewPage";
|
|||||||
import ContractsList from "./pages/contracts/ContractsList";
|
import ContractsList from "./pages/contracts/ContractsList";
|
||||||
import NewContractPage from "./pages/contracts/NewContractPage";
|
import NewContractPage from "./pages/contracts/NewContractPage";
|
||||||
import NewShipmentPage from "./pages/contracts/NewShipmentPage";
|
import NewShipmentPage from "./pages/contracts/NewShipmentPage";
|
||||||
|
import NewShipmentRequestPage from "./pages/contracts/NewShipmentRequestPage";
|
||||||
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
import CheckPaymentPage from "./pages/payments/CheckPaymentPage";
|
||||||
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
||||||
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||||
@@ -276,6 +277,10 @@ const App = () => {
|
|||||||
path="/contracts/:id/edit"
|
path="/contracts/:id/edit"
|
||||||
element={<NewContractPage mode="edit" />}
|
element={<NewContractPage mode="edit" />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/contracts/:id/shipment-requests/new"
|
||||||
|
element={<NewShipmentRequestPage />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/contracts/:id/bookings/new"
|
path="/contracts/:id/bookings/new"
|
||||||
element={<NewShipmentPage />}
|
element={<NewShipmentPage />}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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: "You",
|
||||||
|
gl_et: "GL Ethiopia",
|
||||||
|
gl_dj: "GL Djibouti",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ClearanceWorkflowFilesSection({
|
||||||
|
files,
|
||||||
|
onView,
|
||||||
|
onDownload,
|
||||||
|
title = "Customs documents",
|
||||||
|
}: {
|
||||||
|
files: Freight.ClearanceWorkflowFile[];
|
||||||
|
onView: (file: { name: string; url: string; mimeType?: string }) => void;
|
||||||
|
onDownload?: (file: { id: string; name: string }) => void;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<Paper withBorder radius="lg" p="lg">
|
||||||
|
<Text fw={700} size="sm" mb="md">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<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) => {
|
||||||
|
const file = item.file;
|
||||||
|
if (!file) return null;
|
||||||
|
const canPreview = isViewable({ name: file.name, url: file.url });
|
||||||
|
return (
|
||||||
|
<Paper key={item.code} withBorder radius="md" p="sm">
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<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: file.url })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
{onDownload ? (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Download size={13} />}
|
||||||
|
onClick={() => onDownload({ id: file.id, name: file.name })}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Button, Modal, Text, type ButtonProps } from "@mantine/core";
|
||||||
|
import { AlertCircle, Upload } from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { ContractClearancePanel } from "@/pages/contracts/ContractClearancePanel";
|
||||||
|
import { ModalSafeWrapper } from "./ModalSafeWrapper";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
|
||||||
|
interface ContractClearanceActionProps {
|
||||||
|
contractId: string;
|
||||||
|
label?: string;
|
||||||
|
size?: ButtonProps["size"];
|
||||||
|
urgent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContractClearanceAction({
|
||||||
|
contractId,
|
||||||
|
label: labelProp,
|
||||||
|
size = "xs",
|
||||||
|
urgent = false,
|
||||||
|
}: ContractClearanceActionProps) {
|
||||||
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
|
||||||
|
const { data: clearance } = useQuery({
|
||||||
|
...api.contracts.getClearance.queryOptions({ input: { id: contractId } }),
|
||||||
|
enabled: opened,
|
||||||
|
});
|
||||||
|
|
||||||
|
const label = useMemo(() => {
|
||||||
|
if (labelProp) return labelProp;
|
||||||
|
const docs = clearance?.documents ?? [];
|
||||||
|
const queried = docs.filter(
|
||||||
|
(d) => d.uploadedBy === "customer" && d.reviewStatus === "QUERIED",
|
||||||
|
).length;
|
||||||
|
if (queried > 0) return "Update clearance";
|
||||||
|
return urgent ? "Upload clearance" : "Manage clearance";
|
||||||
|
}, [labelProp, clearance, urgent]);
|
||||||
|
|
||||||
|
const Icon = urgent || label.includes("Update") ? AlertCircle : Upload;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalSafeWrapper>
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
radius="md"
|
||||||
|
fw={700}
|
||||||
|
fz={13}
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Icon size={14} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
open();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
title={
|
||||||
|
<Text fw={700} fz={16}>
|
||||||
|
Clearance documents
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
size="xl"
|
||||||
|
radius="md"
|
||||||
|
centered
|
||||||
|
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||||
|
>
|
||||||
|
<ContractClearancePanel contractId={contractId} bare />
|
||||||
|
</Modal>
|
||||||
|
</ModalSafeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Button, Group, type ButtonProps } from "@mantine/core";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
||||||
|
import { ContractClearanceAction } from "./ContractClearanceAction";
|
||||||
|
import { deriveContractCustomerAction } from "./deriveContractCustomerAction";
|
||||||
|
|
||||||
|
interface ContractCustomerActionProps {
|
||||||
|
contract: Freight.IContract;
|
||||||
|
bookings: Freight.IBooking[];
|
||||||
|
size?: ButtonProps["size"];
|
||||||
|
/** Extra props for list-row button styling (ContractsList). */
|
||||||
|
listStyle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContractCustomerAction({
|
||||||
|
contract,
|
||||||
|
bookings,
|
||||||
|
size = "xs",
|
||||||
|
listStyle = false,
|
||||||
|
}: ContractCustomerActionProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const action = deriveContractCustomerAction(contract, bookings);
|
||||||
|
|
||||||
|
const buttonStyles = listStyle
|
||||||
|
? {
|
||||||
|
root: {
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 13,
|
||||||
|
paddingInline: 14,
|
||||||
|
whiteSpace: "nowrap" as const,
|
||||||
|
boxShadow: action.primary
|
||||||
|
? "0 1px 2px rgba(14,163,113,0.25)"
|
||||||
|
: "none",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (action.type === "clearance") {
|
||||||
|
return (
|
||||||
|
<ContractClearanceAction
|
||||||
|
contractId={action.contractId}
|
||||||
|
label={action.label}
|
||||||
|
size={size}
|
||||||
|
urgent={action.urgent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === "pay") {
|
||||||
|
return <PayNowButton booking={action.booking} label={action.label} size={size} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = action.icon;
|
||||||
|
const variant = action.primary ? "filled" : "light";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
radius="md"
|
||||||
|
h={listStyle ? 34 : undefined}
|
||||||
|
variant={variant}
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Icon size={15} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(action.to);
|
||||||
|
}}
|
||||||
|
styles={buttonStyles}
|
||||||
|
fw={listStyle ? undefined : 700}
|
||||||
|
fz={listStyle ? undefined : 13}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Action column cell: doc button + primary customer action. */
|
||||||
|
export function ContractCustomerActionCell({
|
||||||
|
contract,
|
||||||
|
bookings,
|
||||||
|
docButton,
|
||||||
|
}: {
|
||||||
|
contract: Freight.IContract;
|
||||||
|
bookings: Freight.IBooking[];
|
||||||
|
docButton: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Group gap={8} wrap="nowrap" justify="flex-end">
|
||||||
|
{docButton}
|
||||||
|
<ContractCustomerAction contract={contract} bookings={bookings} size="sm" listStyle />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Box } from "@mantine/core";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap inline row actions that open Mantine modals. Modals portal to document
|
||||||
|
* body but React synthetic events still bubble through the component tree —
|
||||||
|
* without this, clicks inside the modal can trigger a parent row's navigate.
|
||||||
|
*/
|
||||||
|
export function ModalSafeWrapper({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box component="span" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
CreditCard,
|
||||||
|
Eye,
|
||||||
|
FileSignature,
|
||||||
|
PackagePlus,
|
||||||
|
PencilLine,
|
||||||
|
RotateCcw,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { getContractBookingAction } from "@/pages/contracts/contract-booking-action";
|
||||||
|
|
||||||
|
const CLEARANCE_IN_PROGRESS_STATUSES = [
|
||||||
|
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||||
|
"CLEARANCE_UNDER_REVIEW",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function contractNeedsClearanceAction(c: Freight.IContract): {
|
||||||
|
show: boolean;
|
||||||
|
urgent: boolean;
|
||||||
|
} {
|
||||||
|
const clearance = (c as { clearanceStatus?: string }).clearanceStatus;
|
||||||
|
const ready =
|
||||||
|
clearance === "CLEARANCE_READY_FOR_BOOKING" ||
|
||||||
|
clearance === "SELF_CLEARED" ||
|
||||||
|
clearance === "ACTIVE_SHIPMENT_IN_PROGRESS";
|
||||||
|
if (ready) return { show: false, urgent: false };
|
||||||
|
|
||||||
|
const awaiting =
|
||||||
|
c.status === "AWAITING_CLEARANCE_DOCUMENTS" ||
|
||||||
|
clearance === "AWAITING_DOCUMENTS";
|
||||||
|
const inProgress =
|
||||||
|
CLEARANCE_IN_PROGRESS_STATUSES.includes(c.status) ||
|
||||||
|
clearance === "AWAITING_DOCUMENTS" ||
|
||||||
|
clearance === "DOCUMENTS_UNDER_REVIEW";
|
||||||
|
|
||||||
|
return { show: inProgress, urgent: awaiting };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContractCustomerAction =
|
||||||
|
| {
|
||||||
|
type: "sign";
|
||||||
|
label: string;
|
||||||
|
to: string;
|
||||||
|
primary: boolean;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "navigate";
|
||||||
|
label: string;
|
||||||
|
to: string;
|
||||||
|
primary: boolean;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "clearance";
|
||||||
|
contractId: string;
|
||||||
|
label: string;
|
||||||
|
primary: boolean;
|
||||||
|
icon: LucideIcon;
|
||||||
|
urgent: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "pay";
|
||||||
|
booking: Freight.IBooking;
|
||||||
|
label: string;
|
||||||
|
primary: boolean;
|
||||||
|
icon: LucideIcon;
|
||||||
|
};
|
||||||
|
|
||||||
|
function findPayableBookingForContract(
|
||||||
|
contractId: string,
|
||||||
|
bookings: Freight.IBooking[],
|
||||||
|
): Freight.IBooking | null {
|
||||||
|
return (
|
||||||
|
bookings.find((b) => {
|
||||||
|
if (b.contractId !== contractId) return false;
|
||||||
|
if (b.paymentStatus === "PAID") return false;
|
||||||
|
const isGeneral = b.bookingType === "GENERAL_CONTRACT";
|
||||||
|
return isGeneral
|
||||||
|
? b.status === "FULLY_EXECUTED"
|
||||||
|
: b.status === "SELECTED_FOR_BATCH";
|
||||||
|
}) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single best customer action for a contract row (list / home). */
|
||||||
|
export function deriveContractCustomerAction(
|
||||||
|
contract: Freight.IContract,
|
||||||
|
bookings: Freight.IBooking[],
|
||||||
|
): ContractCustomerAction {
|
||||||
|
const id = contract.id;
|
||||||
|
|
||||||
|
if (contract.status === "CONTRACT_READY") {
|
||||||
|
return {
|
||||||
|
type: "sign",
|
||||||
|
label: "View & sign",
|
||||||
|
to: `/contracts/${id}/view`,
|
||||||
|
primary: true,
|
||||||
|
icon: FileSignature,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contract.status === "CHANGES_REQUESTED") {
|
||||||
|
return {
|
||||||
|
type: "navigate",
|
||||||
|
label: "Edit & resubmit",
|
||||||
|
to: `/contracts/${id}`,
|
||||||
|
primary: true,
|
||||||
|
icon: PencilLine,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payable = findPayableBookingForContract(id, bookings);
|
||||||
|
if (payable) {
|
||||||
|
return {
|
||||||
|
type: "pay",
|
||||||
|
booking: payable,
|
||||||
|
label: "Pay now",
|
||||||
|
primary: true,
|
||||||
|
icon: CreditCard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const clr = contractNeedsClearanceAction(contract);
|
||||||
|
if (clr.show) {
|
||||||
|
return {
|
||||||
|
type: "clearance",
|
||||||
|
contractId: id,
|
||||||
|
label: clr.urgent ? "Upload clearance" : "Update clearance",
|
||||||
|
primary: true,
|
||||||
|
icon: Upload,
|
||||||
|
urgent: clr.urgent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
contract.customsClearingEnabled &&
|
||||||
|
["CLEARANCE_READY_FOR_BOOKING", "ACTIVE_SHIPMENT_IN_PROGRESS"].includes(
|
||||||
|
contract.status,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
type: "navigate",
|
||||||
|
label: "View",
|
||||||
|
to: `/contracts/${id}`,
|
||||||
|
primary: false,
|
||||||
|
icon: Eye,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingAction = getContractBookingAction(contract, bookings);
|
||||||
|
if (bookingAction.kind === "book") {
|
||||||
|
return {
|
||||||
|
type: "navigate",
|
||||||
|
label: "Book shipment",
|
||||||
|
to: bookingAction.to,
|
||||||
|
primary: true,
|
||||||
|
icon: PackagePlus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (bookingAction.kind === "rebook") {
|
||||||
|
return {
|
||||||
|
type: "navigate",
|
||||||
|
label: "Re-book shipment",
|
||||||
|
to: bookingAction.to,
|
||||||
|
primary: true,
|
||||||
|
icon: RotateCcw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (bookingAction.kind === "request") {
|
||||||
|
return {
|
||||||
|
type: "navigate",
|
||||||
|
label: "Request shipment",
|
||||||
|
to: bookingAction.to,
|
||||||
|
primary: true,
|
||||||
|
icon: PackagePlus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "navigate",
|
||||||
|
label: "View",
|
||||||
|
to: `/contracts/${id}`,
|
||||||
|
primary: false,
|
||||||
|
icon: Eye,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -132,6 +132,9 @@ export const URL_CONSTANTS = {
|
|||||||
BOOKING_DUTY_SLIP: (bookingId: string) =>
|
BOOKING_DUTY_SLIP: (bookingId: string) =>
|
||||||
`/api/contracts/bookings/${bookingId}/duty-slip`,
|
`/api/contracts/bookings/${bookingId}/duty-slip`,
|
||||||
CAPACITY: (id: string) => `/api/contracts/${id}/capacity`,
|
CAPACITY: (id: string) => `/api/contracts/${id}/capacity`,
|
||||||
|
BOOKING_REQUESTS: (id: string) => `/api/contracts/${id}/booking-requests`,
|
||||||
|
BOOKING_REQUEST_CANCEL: (reqId: string) =>
|
||||||
|
`/api/contracts/booking-requests/${reqId}/cancel`,
|
||||||
},
|
},
|
||||||
|
|
||||||
TRAIN_SCHEDULING: {
|
TRAIN_SCHEDULING: {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||||
import {
|
import {
|
||||||
|
ActionNeededSection,
|
||||||
FreightVolumeSection,
|
FreightVolumeSection,
|
||||||
HelloSection,
|
HelloSection,
|
||||||
InvoicesSection,
|
InvoicesSection,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
ShipmentsSection,
|
ShipmentsSection,
|
||||||
StatsSection,
|
StatsSection,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
|
import { deriveActionItems } from "./actions";
|
||||||
import { useMyPortalData } from "./hooks";
|
import { useMyPortalData } from "./hooks";
|
||||||
|
|
||||||
export default function MyPortalPage() {
|
export default function MyPortalPage() {
|
||||||
@@ -28,6 +30,7 @@ export default function MyPortalPage() {
|
|||||||
recentContracts,
|
recentContracts,
|
||||||
activeContractsCount,
|
activeContractsCount,
|
||||||
allBookings,
|
allBookings,
|
||||||
|
allContracts,
|
||||||
activeBookings,
|
activeBookings,
|
||||||
newActiveThisWeek,
|
newActiveThisWeek,
|
||||||
outstandingInvoices,
|
outstandingInvoices,
|
||||||
@@ -90,6 +93,11 @@ export default function MyPortalPage() {
|
|||||||
dashboardLoading={dashboardQuery.isPending}
|
dashboardLoading={dashboardQuery.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* <ActionNeededSection
|
||||||
|
items={deriveActionItems(allContracts, allBookings)}
|
||||||
|
contracts={allContracts}
|
||||||
|
/> */}
|
||||||
|
|
||||||
{/* Contracts + shipments side by side — the two primary tables. */}
|
{/* Contracts + shipments side by side — the two primary tables. */}
|
||||||
<Grid align="stretch">
|
<Grid align="stretch">
|
||||||
<Grid.Col span={{ base: 12, lg: 6 }}>
|
<Grid.Col span={{ base: 12, lg: 6 }}>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { contractNeedsClearanceAction } from "@/components/customer-actions/deriveContractCustomerAction";
|
||||||
|
|
||||||
/** A pending customer action surfaced on the home "needs attention" card. */
|
/** A pending customer action surfaced on the home "needs attention" card. */
|
||||||
export interface ActionItem {
|
export interface ActionItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,46 +17,11 @@ export interface ActionItem {
|
|||||||
urgent?: boolean;
|
urgent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contract statuses that mean clearance is in progress (Path A or B). A queried
|
|
||||||
// document flips the contract back to AWAITING_CLEARANCE_DOCUMENTS, but the panel
|
|
||||||
// also allows re-upload while UNDER_REVIEW — so surface both as actionable.
|
|
||||||
const CLEARANCE_IN_PROGRESS_STATUSES = [
|
|
||||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
|
||||||
"CLEARANCE_UNDER_REVIEW",
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a contract has a clearance step that needs the customer to upload /
|
|
||||||
* re-upload documents. Uses contract status AND clearanceStatus so a query is
|
|
||||||
* caught even if only one field reflects it. Excludes the ready / completed gates.
|
|
||||||
*/
|
|
||||||
function contractNeedsClearance(c: Freight.IContract): {
|
|
||||||
show: boolean;
|
|
||||||
urgent: boolean;
|
|
||||||
} {
|
|
||||||
const clearance = (c as { clearanceStatus?: string }).clearanceStatus;
|
|
||||||
const ready =
|
|
||||||
clearance === "CLEARANCE_READY_FOR_BOOKING" ||
|
|
||||||
clearance === "SELF_CLEARED" ||
|
|
||||||
clearance === "ACTIVE_SHIPMENT_IN_PROGRESS";
|
|
||||||
if (ready) return { show: false, urgent: false };
|
|
||||||
|
|
||||||
const awaiting =
|
|
||||||
c.status === "AWAITING_CLEARANCE_DOCUMENTS" ||
|
|
||||||
clearance === "AWAITING_DOCUMENTS";
|
|
||||||
const inProgress =
|
|
||||||
CLEARANCE_IN_PROGRESS_STATUSES.includes(c.status) ||
|
|
||||||
clearance === "AWAITING_DOCUMENTS" ||
|
|
||||||
clearance === "DOCUMENTS_UNDER_REVIEW";
|
|
||||||
|
|
||||||
return { show: inProgress, urgent: awaiting };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive the list of pending customer actions from the customer's contracts and
|
* Derive the list of pending customer actions from the customer's contracts and
|
||||||
* bookings. A contract in AWAITING_CLEARANCE_DOCUMENTS (initial upload or a
|
* bookings. A contract in AWAITING_CLEARANCE_DOCUMENTS (initial upload or a
|
||||||
* re-upload after a query) is flagged urgent so the home card shows an upload
|
* re-upload after a query) is flagged urgent so the home card shows an upload
|
||||||
* button. See {@link contractNeedsClearance}.
|
* button. See {@link contractNeedsClearanceAction}.
|
||||||
*/
|
*/
|
||||||
export function deriveActionItems(
|
export function deriveActionItems(
|
||||||
contracts: Freight.IContract[],
|
contracts: Freight.IContract[],
|
||||||
@@ -73,7 +40,7 @@ export function deriveActionItems(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const clr = contractNeedsClearance(c);
|
const clr = contractNeedsClearanceAction(c);
|
||||||
if (clr.show) {
|
if (clr.show) {
|
||||||
items.push({
|
items.push({
|
||||||
id: `clearance-${c.id}`,
|
id: `clearance-${c.id}`,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
|
||||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||||
import { ContractClearancePanel } from "@/pages/contracts/ContractClearancePanel";
|
import { ContractClearancePanel } from "@/pages/contracts/ContractClearancePanel";
|
||||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||||
@@ -246,43 +247,45 @@ export function ActionNeededSection({
|
|||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Modal
|
<ModalSafeWrapper>
|
||||||
opened={clearanceId !== null}
|
<Modal
|
||||||
onClose={() => setClearanceId(null)}
|
opened={clearanceId !== null}
|
||||||
title={
|
onClose={() => setClearanceId(null)}
|
||||||
<Text fw={700} fz={16}>
|
title={
|
||||||
Clearance documents
|
<Text fw={700} fz={16}>
|
||||||
</Text>
|
Clearance documents
|
||||||
}
|
</Text>
|
||||||
size="xl"
|
|
||||||
radius="md"
|
|
||||||
centered
|
|
||||||
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
|
||||||
>
|
|
||||||
{clearanceId && (
|
|
||||||
<ContractClearancePanel contractId={clearanceId} bare />
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<PaymentMethodModal
|
|
||||||
opened={payItem !== null}
|
|
||||||
onClose={() => {
|
|
||||||
if (!payMutation.isPending) {
|
|
||||||
setPayItem(null);
|
|
||||||
payMutation.reset();
|
|
||||||
}
|
}
|
||||||
}}
|
size="xl"
|
||||||
currency={undefined}
|
radius="md"
|
||||||
processing={payMutation.isPending}
|
centered
|
||||||
error={
|
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||||
payMutation.isError
|
>
|
||||||
? payMutation.error instanceof Error
|
{clearanceId && (
|
||||||
? payMutation.error.message
|
<ContractClearancePanel contractId={clearanceId} bare />
|
||||||
: "Could not start payment. Please try again."
|
)}
|
||||||
: null
|
</Modal>
|
||||||
}
|
|
||||||
onConfirm={(method) => payMutation.mutate(method)}
|
<PaymentMethodModal
|
||||||
/>
|
opened={payItem !== null}
|
||||||
|
onClose={() => {
|
||||||
|
if (!payMutation.isPending) {
|
||||||
|
setPayItem(null);
|
||||||
|
payMutation.reset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
currency={undefined}
|
||||||
|
processing={payMutation.isPending}
|
||||||
|
error={
|
||||||
|
payMutation.isError
|
||||||
|
? payMutation.error instanceof Error
|
||||||
|
? payMutation.error.message
|
||||||
|
: "Could not start payment. Please try again."
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onConfirm={(method) => payMutation.mutate(method)}
|
||||||
|
/>
|
||||||
|
</ModalSafeWrapper>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ArrowRight, FileSignature, Package, Plus, RefreshCw } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
import { ContractCustomerAction } from "@/components/customer-actions/ContractCustomerAction";
|
||||||
import {
|
import {
|
||||||
ContractDocButton,
|
ContractDocButton,
|
||||||
ContractStatusBadge,
|
ContractStatusBadge,
|
||||||
} from "@/pages/contracts/contract-ui";
|
} from "@/pages/contracts/contract-ui";
|
||||||
import { getContractBookingAction } from "@/pages/contracts/contract-booking-action";
|
|
||||||
import { Card } from "./Card";
|
import { Card } from "./Card";
|
||||||
import { EmptyState } from "./EmptyState";
|
import { EmptyState } from "./EmptyState";
|
||||||
|
|
||||||
@@ -64,8 +64,6 @@ export const RecentContractsSection = memo(function RecentContractsSection({
|
|||||||
{contracts.map((c) => {
|
{contracts.map((c) => {
|
||||||
const isGeneral = c.contractKind === "GENERAL";
|
const isGeneral = c.contractKind === "GENERAL";
|
||||||
const isContainer = c.freightType === "CONTAINER";
|
const isContainer = c.freightType === "CONTAINER";
|
||||||
const canSign = c.status === "CONTRACT_READY";
|
|
||||||
const bookingAction = getContractBookingAction(c, bookings);
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
key={c.id}
|
key={c.id}
|
||||||
@@ -96,52 +94,11 @@ export const RecentContractsSection = memo(function RecentContractsSection({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
<ContractStatusBadge status={c.status} />
|
<ContractStatusBadge status={c.status} />
|
||||||
{canSign ? (
|
<ContractCustomerAction
|
||||||
<Button
|
contract={c}
|
||||||
size="xs"
|
bookings={bookings}
|
||||||
radius="md"
|
size="xs"
|
||||||
color="edr-green"
|
/>
|
||||||
leftSection={<FileSignature size={13} />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate(`/contracts/${c.id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Sign
|
|
||||||
</Button>
|
|
||||||
) : bookingAction.kind !== "none" ? (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
radius="md"
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={
|
|
||||||
bookingAction.kind === "rebook" ? (
|
|
||||||
<RefreshCw size={13} />
|
|
||||||
) : (
|
|
||||||
<Package size={13} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate(bookingAction.to);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{bookingAction.kind === "rebook" ? "Re-book" : "Book"}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
radius="md"
|
|
||||||
variant="default"
|
|
||||||
rightSection={<ArrowRight size={13} />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate(`/contracts/${c.id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Open
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, Anchor, Button, FileInput, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
|
import { AlertTriangle, Download, Receipt, Upload } from "lucide-react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
|
import { ClearancePhaseStepper } from "../contracts/ClearancePhaseStepper";
|
||||||
|
|
||||||
|
const BORDER = "#E6ECF2";
|
||||||
|
|
||||||
|
export function BookingClearanceWorkflowBanner({
|
||||||
|
booking,
|
||||||
|
}: {
|
||||||
|
booking: Freight.IBooking;
|
||||||
|
}) {
|
||||||
|
const isPhased =
|
||||||
|
booking.customsClearingEnabled &&
|
||||||
|
booking.contractKind === "GENERAL";
|
||||||
|
|
||||||
|
const { data: clearance, refetch } = useQuery({
|
||||||
|
queryKey: ["booking-clearance", booking.id],
|
||||||
|
queryFn: () => bookingsService.getClearance(booking.id),
|
||||||
|
enabled: isPhased,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isPhased || !clearance) return null;
|
||||||
|
|
||||||
|
const dutyPaid = clearance.milestones?.some(
|
||||||
|
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
|
||||||
|
);
|
||||||
|
const dutyPending =
|
||||||
|
clearance.dutyRequired &&
|
||||||
|
clearance.dutyAdvice &&
|
||||||
|
!dutyPaid;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text fw={700} size="sm">
|
||||||
|
Clearance progress
|
||||||
|
</Text>
|
||||||
|
<ClearancePhaseStepper
|
||||||
|
clearance={clearance as Freight.ContractClearanceView}
|
||||||
|
tradeDirection={booking.tradeDirection}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
|
||||||
|
{clearance.roHold && clearance.roHoldReason ? (
|
||||||
|
<Alert color="orange" icon={<AlertTriangle size={16} />} title="Release Order on hold">
|
||||||
|
{clearance.roHoldReason}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{clearance.nextAction?.actor === "CUSTOMER" ? (
|
||||||
|
<Alert color="blue" variant="light">
|
||||||
|
{clearance.nextAction.action}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{dutyPending && clearance.dutyAdvice ? (
|
||||||
|
<DutyAdvicePanel
|
||||||
|
dutyAdvice={clearance.dutyAdvice}
|
||||||
|
bookingId={booking.id}
|
||||||
|
onUploaded={() => void refetch()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{clearance.operationReady ? (
|
||||||
|
<Alert color="green" variant="light">
|
||||||
|
Clearance is complete. You may proceed to request your operation date.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DutyAdvicePanel({
|
||||||
|
dutyAdvice,
|
||||||
|
bookingId,
|
||||||
|
onUploaded,
|
||||||
|
}: {
|
||||||
|
dutyAdvice: NonNullable<Freight.ClearanceView["dutyAdvice"]>;
|
||||||
|
bookingId: string;
|
||||||
|
onUploaded: () => void;
|
||||||
|
}) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<GroupLabel icon={Receipt} text="Duty / tax payment" />
|
||||||
|
<Text size="sm">
|
||||||
|
Amount due:{" "}
|
||||||
|
<strong>
|
||||||
|
{dutyAdvice.amount.toLocaleString()} {dutyAdvice.currency}
|
||||||
|
</strong>
|
||||||
|
{dutyAdvice.declarationSerial
|
||||||
|
? ` · Payment code: ${dutyAdvice.declarationSerial}`
|
||||||
|
: null}
|
||||||
|
</Text>
|
||||||
|
{dutyAdvice.noticeFile ? (
|
||||||
|
<Anchor
|
||||||
|
href={fileViewUrl(dutyAdvice.noticeFile.id, true)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Download size={14} />
|
||||||
|
Download duty notice ({dutyAdvice.noticeFile.name})
|
||||||
|
</Group>
|
||||||
|
</Anchor>
|
||||||
|
) : null}
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Pay the amount above, then upload your payment slip so clearance can continue.
|
||||||
|
</Text>
|
||||||
|
<FileInput label="Payment slip" value={file} onChange={setFile} size="sm" />
|
||||||
|
<Button
|
||||||
|
color="orange"
|
||||||
|
loading={loading}
|
||||||
|
disabled={!file}
|
||||||
|
leftSection={<Upload size={16} />}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!file) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await bookingsService.uploadBookingClearanceDutySlip(bookingId, file);
|
||||||
|
toast.success("Payment slip uploaded");
|
||||||
|
onUploaded();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Submit payment slip
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<Icon size={16} />
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import type { Freight } from "@edr/types";
|
|||||||
|
|
||||||
import { ClearanceFlow } from "@/pages/bookings/clearance/ClearanceFlow";
|
import { ClearanceFlow } from "@/pages/bookings/clearance/ClearanceFlow";
|
||||||
import { useClearanceFlow } from "@/pages/bookings/clearance/useClearanceFlow";
|
import { useClearanceFlow } from "@/pages/bookings/clearance/useClearanceFlow";
|
||||||
|
import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner";
|
||||||
|
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
@@ -43,7 +44,8 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<Group justify="space-between" align="center" mb="md">
|
<BookingClearanceWorkflowBanner booking={booking} />
|
||||||
|
<Group justify="space-between" align="center" mb="md" mt="md">
|
||||||
<CardTitle>Clearance documents</CardTitle>
|
<CardTitle>Clearance documents</CardTitle>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
BOOKING_DOCS_SETTING,
|
BOOKING_DOCS_SETTING,
|
||||||
BookingFormInputValues,
|
BookingFormInputValues,
|
||||||
bookingFormSchema,
|
bookingFormSchema,
|
||||||
|
filterBookableServices,
|
||||||
getRouteDirection,
|
getRouteDirection,
|
||||||
initialBookingFormValues,
|
initialBookingFormValues,
|
||||||
type BookingDocuments,
|
type BookingDocuments,
|
||||||
@@ -355,6 +356,7 @@ export default function EditBookingPage() {
|
|||||||
const originYard = form.watch("originYard");
|
const originYard = form.watch("originYard");
|
||||||
const destinationYard = form.watch("destinationYard");
|
const destinationYard = form.watch("destinationYard");
|
||||||
const serviceTypeId = form.watch("serviceTypeId");
|
const serviceTypeId = form.watch("serviceTypeId");
|
||||||
|
const operationType = form.watch("operationType");
|
||||||
const firstMileEnabled = form.watch("firstMile.enabled");
|
const firstMileEnabled = form.watch("firstMile.enabled");
|
||||||
const lastMileEnabled = form.watch("lastMile.enabled");
|
const lastMileEnabled = form.watch("lastMile.enabled");
|
||||||
const customsClearingEnabled = form.watch("customsClearingEnabled");
|
const customsClearingEnabled = form.watch("customsClearingEnabled");
|
||||||
@@ -364,6 +366,12 @@ export default function EditBookingPage() {
|
|||||||
() => referenceData?.service.find((s) => s.id === serviceTypeId),
|
() => referenceData?.service.find((s) => s.id === serviceTypeId),
|
||||||
[serviceTypeId, referenceData],
|
[serviceTypeId, referenceData],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const bookableServices = useMemo(
|
||||||
|
() => filterBookableServices(referenceData?.service, operationType),
|
||||||
|
[referenceData, operationType],
|
||||||
|
);
|
||||||
|
|
||||||
const showFirstMile = Boolean(
|
const showFirstMile = Boolean(
|
||||||
selectedService?.includesFirstMile && firstMileEnabled,
|
selectedService?.includesFirstMile && firstMileEnabled,
|
||||||
);
|
);
|
||||||
@@ -636,9 +644,10 @@ export default function EditBookingPage() {
|
|||||||
error={fieldState.error}
|
error={fieldState.error}
|
||||||
label="Service Type *"
|
label="Service Type *"
|
||||||
placeholder="Select service type..."
|
placeholder="Select service type..."
|
||||||
data={(referenceData?.service ?? [])
|
data={bookableServices.map((s) => ({
|
||||||
.filter((s) => s.canBeBookedAlone)
|
value: s.id,
|
||||||
.map((s) => ({ value: s.id, label: s.serviceName }))}
|
label: s.serviceName,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -422,9 +422,8 @@ export default function NewBookingPage() {
|
|||||||
|
|
||||||
const cargoTypeId = data.cargoType === "bulk" ? childId : undefined;
|
const cargoTypeId = data.cargoType === "bulk" ? childId : undefined;
|
||||||
|
|
||||||
const cargoFreeText = bulkChild?.show_free_text_box
|
const cargoFreeText =
|
||||||
? data.cargoFreeText
|
data.cargoType === "bulk" ? data.cargoFreeText : undefined;
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const serviceType = referenceData?.service.find(
|
const serviceType = referenceData?.service.find(
|
||||||
(s) => s.id === data.serviceTypeId,
|
(s) => s.id === data.serviceTypeId,
|
||||||
|
|||||||
@@ -582,6 +582,24 @@ export function operationToProfileType(
|
|||||||
return "freight_forwarder";
|
return "freight_forwarder";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BookableService = Freight.BookingReferenceData["service"][number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Services the customer may pick in the wizard. Intercity (domestic) corridors
|
||||||
|
* have no customs clearance, so customs-bundled services are excluded.
|
||||||
|
*/
|
||||||
|
export function filterBookableServices(
|
||||||
|
services: BookableService[] | undefined,
|
||||||
|
operationType: OperationType | undefined,
|
||||||
|
): BookableService[] {
|
||||||
|
if (!services) return [];
|
||||||
|
return services.filter((s) => {
|
||||||
|
if (!s.canBeBookedAlone) return false;
|
||||||
|
if (operationType === "intercity" && s.includesCustoms) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function calcWagons(containers: ContainerConfig[]) {
|
export function calcWagons(containers: ContainerConfig[]) {
|
||||||
const Ft20Wagons = containers
|
const Ft20Wagons = containers
|
||||||
.filter((c) => c.type === "20ft")
|
.filter((c) => c.type === "20ft")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { ReactNode } from "react";
|
|||||||
import { Check, FileText, Info, Layers, Train, Truck } from "lucide-react";
|
import { Check, FileText, Info, Layers, Train, Truck } from "lucide-react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import { BookingFormInputValues, type BookingFormValues } from "./schema";
|
import { BookingFormInputValues, type BookingFormValues, filterBookableServices } from "./schema";
|
||||||
import {
|
import {
|
||||||
fieldStyles,
|
fieldStyles,
|
||||||
OptionFieldError,
|
OptionFieldError,
|
||||||
@@ -31,6 +31,7 @@ export function Step2ServiceType({
|
|||||||
referenceData?: Freight.BookingReferenceData;
|
referenceData?: Freight.BookingReferenceData;
|
||||||
}) {
|
}) {
|
||||||
const serviceTypeId = form.watch("serviceTypeId");
|
const serviceTypeId = form.watch("serviceTypeId");
|
||||||
|
const operationType = form.watch("operationType");
|
||||||
const serviceType = referenceData?.service.find(
|
const serviceType = referenceData?.service.find(
|
||||||
(s) => s.id === serviceTypeId,
|
(s) => s.id === serviceTypeId,
|
||||||
);
|
);
|
||||||
@@ -74,6 +75,20 @@ export function Step2ServiceType({
|
|||||||
|
|
||||||
const showServiceSections =
|
const showServiceSections =
|
||||||
serviceType != null || includesFirstMile || includesLastMile;
|
serviceType != null || includesFirstMile || includesLastMile;
|
||||||
|
|
||||||
|
const bookableServices = filterBookableServices(
|
||||||
|
referenceData?.service,
|
||||||
|
operationType,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentId = form.getValues("serviceTypeId");
|
||||||
|
if (!currentId) return;
|
||||||
|
if (!bookableServices.some((s) => s.id === currentId)) {
|
||||||
|
form.setValue("serviceTypeId", "", { shouldValidate: true });
|
||||||
|
}
|
||||||
|
}, [operationType, bookableServices, form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StepCard>
|
<StepCard>
|
||||||
<StepHeader
|
<StepHeader
|
||||||
@@ -88,9 +103,7 @@ export function Step2ServiceType({
|
|||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{referenceData?.service
|
{bookableServices.map((s) => (
|
||||||
.filter((s) => s.canBeBookedAlone)
|
|
||||||
.map((s) => (
|
|
||||||
<ServiceTypeCard
|
<ServiceTypeCard
|
||||||
key={s.id}
|
key={s.id}
|
||||||
selected={field.value === s.id}
|
selected={field.value === s.id}
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ export function Step5CargoDetails({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedCommodity?.show_free_text_box && (
|
{selectedCommodity && (
|
||||||
<Controller
|
<Controller
|
||||||
name="cargoFreeText"
|
name="cargoFreeText"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { CreditCard } from "lucide-react";
|
|||||||
|
|
||||||
import { Freight } from "@edr/types";
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
|
||||||
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
|
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
|
||||||
import { priceTotal } from "../BookingDetailPage/utils";
|
import { priceTotal } from "../BookingDetailPage/utils";
|
||||||
import { useBookingPayment } from "./useBookingPayment";
|
import { useBookingPayment } from "./useBookingPayment";
|
||||||
@@ -29,7 +30,7 @@ export function PayNowButton({
|
|||||||
const pricing = booking.pricingBreakdown;
|
const pricing = booking.pricingBreakdown;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ModalSafeWrapper>
|
||||||
<Button
|
<Button
|
||||||
size={size}
|
size={size}
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -56,6 +57,6 @@ export function PayNowButton({
|
|||||||
error={pay.error}
|
error={pay.error}
|
||||||
onConfirm={pay.confirm}
|
onConfirm={pay.confirm}
|
||||||
/>
|
/>
|
||||||
</>
|
</ModalSafeWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ const BRAND_GREEN = "var(--freight-brand, #0A6F4D)";
|
|||||||
const PHASE_LABELS: Record<string, string> = {
|
const PHASE_LABELS: Record<string, string> = {
|
||||||
CUSTOMER_INTAKE: "Customer docs",
|
CUSTOMER_INTAKE: "Customer docs",
|
||||||
GL_ET_REVIEW: "GL ET review",
|
GL_ET_REVIEW: "GL ET review",
|
||||||
GL_DJ_COLLECTION: "GL Djibouti",
|
GL_DJ_COLLECTION: "GL Djibouti DO",
|
||||||
GL_ET_OUTPUT: "Declaration",
|
GL_ET_OUTPUT: "Declaration",
|
||||||
CUSTOMER_DUTY: "Duty / tax",
|
CUSTOMER_DUTY: "Duty / customer pays",
|
||||||
GL_ET_POST_CLEARANCE: "ET clearance",
|
GL_ET_POST_CLEARANCE: "Transit & finalize",
|
||||||
GL_DJ_LOADING: "Loading",
|
GL_DJ_LOADING: "Loading",
|
||||||
POST_TRANSIT: "Transit",
|
POST_TRANSIT: "Transit",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import type { Freight } from "@edr/types";
|
|||||||
import { isViewable } from "@edr/ui-common";
|
import { isViewable } from "@edr/ui-common";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
|
import { ClearanceWorkflowFilesSection } from "@/components/contracts/ClearanceWorkflowFilesSection";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents";
|
import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents";
|
||||||
|
|
||||||
@@ -360,6 +361,16 @@ export function ContractClearancePanel({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(clearance?.workflowFiles?.length ?? 0) > 0 ? (
|
||||||
|
<Box mt="lg">
|
||||||
|
<ClearanceWorkflowFilesSection
|
||||||
|
files={clearance!.workflowFiles!}
|
||||||
|
title="Customs workflow documents"
|
||||||
|
onView={(f) => view(f)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Ad-hoc / additional documents. */}
|
{/* Ad-hoc / additional documents. */}
|
||||||
{canUpload && (
|
{canUpload && (
|
||||||
<Box mt="lg">
|
<Box mt="lg">
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Button, FileInput, Paper, Stack, Text } from "@mantine/core";
|
import { Alert, Anchor, Button, FileInput, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
import { AlertTriangle, Receipt, Upload } from "lucide-react";
|
import { AlertTriangle, Download, Receipt, Upload } from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
|
import { ClearanceWorkflowFilesSection } from "@/components/contracts/ClearanceWorkflowFilesSection";
|
||||||
import { ClearancePhaseStepper } from "./ClearancePhaseStepper";
|
import { ClearancePhaseStepper } from "./ClearancePhaseStepper";
|
||||||
|
|
||||||
const BORDER = "#E6ECF2";
|
const BORDER = "#E6ECF2";
|
||||||
@@ -26,14 +28,13 @@ export function ContractClearanceWorkflowBanner({
|
|||||||
|
|
||||||
if (!isPhased || !clearance) return null;
|
if (!isPhased || !clearance) return null;
|
||||||
|
|
||||||
|
const dutyPaid = clearance.milestones?.some(
|
||||||
|
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
|
||||||
|
);
|
||||||
const dutyPending =
|
const dutyPending =
|
||||||
clearance.dutyRequired &&
|
clearance.dutyRequired &&
|
||||||
clearance.milestones?.some(
|
clearance.dutyAdvice &&
|
||||||
(m) => m.milestoneCode === "DUTY_TAXES_ADVISED" && m.status === "COMPLETED",
|
!dutyPaid;
|
||||||
) &&
|
|
||||||
!clearance.milestones?.some(
|
|
||||||
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||||
@@ -59,8 +60,20 @@ export function ContractClearanceWorkflowBanner({
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{dutyPending ? (
|
{dutyPending && clearance.dutyAdvice ? (
|
||||||
<DutySlipUpload contractId={contract.id} onUploaded={() => void refetch()} />
|
<DutyAdvicePanel
|
||||||
|
dutyAdvice={clearance.dutyAdvice}
|
||||||
|
contractId={contract.id}
|
||||||
|
onUploaded={() => void refetch()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(clearance.workflowFiles?.length ?? 0) > 0 ? (
|
||||||
|
<ClearanceWorkflowFilesSection
|
||||||
|
files={clearance.workflowFiles!}
|
||||||
|
title="Uploaded customs documents"
|
||||||
|
onView={({ name, url }) => window.open(url, "_blank", "noopener")}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{clearance.bookingReady ? (
|
{clearance.bookingReady ? (
|
||||||
@@ -73,10 +86,12 @@ export function ContractClearanceWorkflowBanner({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DutySlipUpload({
|
function DutyAdvicePanel({
|
||||||
|
dutyAdvice,
|
||||||
contractId,
|
contractId,
|
||||||
onUploaded,
|
onUploaded,
|
||||||
}: {
|
}: {
|
||||||
|
dutyAdvice: NonNullable<Freight.ContractClearanceView["dutyAdvice"]>;
|
||||||
contractId: string;
|
contractId: string;
|
||||||
onUploaded: () => void;
|
onUploaded: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -87,8 +102,30 @@ function DutySlipUpload({
|
|||||||
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
|
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<GroupLabel icon={Receipt} text="Duty / tax payment" />
|
<GroupLabel icon={Receipt} text="Duty / tax payment" />
|
||||||
|
<Text size="sm">
|
||||||
|
Amount due:{" "}
|
||||||
|
<strong>
|
||||||
|
{dutyAdvice.amount.toLocaleString()} {dutyAdvice.currency}
|
||||||
|
</strong>
|
||||||
|
{dutyAdvice.declarationSerial
|
||||||
|
? ` · Payment code: ${dutyAdvice.declarationSerial}`
|
||||||
|
: null}
|
||||||
|
</Text>
|
||||||
|
{dutyAdvice.noticeFile ? (
|
||||||
|
<Anchor
|
||||||
|
href={fileViewUrl(dutyAdvice.noticeFile.id, true)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Download size={14} />
|
||||||
|
Download duty notice ({dutyAdvice.noticeFile.name})
|
||||||
|
</Group>
|
||||||
|
</Anchor>
|
||||||
|
) : null}
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Upload your duty/tax payment slip so clearance can continue.
|
Pay the amount above, then upload your payment slip so clearance can continue.
|
||||||
</Text>
|
</Text>
|
||||||
<FileInput
|
<FileInput
|
||||||
label="Payment slip"
|
label="Payment slip"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ChevronRight,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
FileBadge,
|
FileBadge,
|
||||||
@@ -47,6 +48,7 @@ import { Modal } from "@mantine/core";
|
|||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { isViewable, type ViewableFile } from "@edr/ui-common";
|
import { isViewable, type ViewableFile } from "@edr/ui-common";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
import { clearanceWorkflowFileLabel } from "@edr/types";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
@@ -55,7 +57,9 @@ import toast from "react-hot-toast";
|
|||||||
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
||||||
import { ContractClearancePanel } from "./ContractClearancePanel";
|
import { ContractClearancePanel } from "./ContractClearancePanel";
|
||||||
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
|
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
|
||||||
|
import { ClearanceWorkflowFilesSection } from "@/components/contracts/ClearanceWorkflowFilesSection";
|
||||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||||
|
import { getContractBookingAction } from "./contract-booking-action";
|
||||||
import {
|
import {
|
||||||
BORDER,
|
BORDER,
|
||||||
ContractStatusBadge,
|
ContractStatusBadge,
|
||||||
@@ -174,6 +178,19 @@ export default function ContractDetailPage() {
|
|||||||
(d) => d.uploadedBy === "customer" && d.reviewStatus === "QUERIED",
|
(d) => d.uploadedBy === "customer" && d.reviewStatus === "QUERIED",
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
|
const showShipmentRequests =
|
||||||
|
!!contract &&
|
||||||
|
contract.contractKind === "GENERAL" &&
|
||||||
|
contract.customsClearingEnabled;
|
||||||
|
const { data: shipmentRequests = [] } = useQuery({
|
||||||
|
queryKey: ["contract-booking-requests", id],
|
||||||
|
queryFn: () => contractsService.listBookingRequests(id!),
|
||||||
|
enabled: !!id && showShipmentRequests,
|
||||||
|
});
|
||||||
|
const activeShipmentRequests = shipmentRequests.filter(
|
||||||
|
(r) => r.status === "PENDING" || r.status === "ACCEPTED",
|
||||||
|
);
|
||||||
|
|
||||||
const contractBookings = useMemo(
|
const contractBookings = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(bookingsPage?.items ?? []).filter(
|
(bookingsPage?.items ?? []).filter(
|
||||||
@@ -254,6 +271,8 @@ export default function ContractDetailPage() {
|
|||||||
contract.status === "CLEARANCE_READY_FOR_BOOKING";
|
contract.status === "CLEARANCE_READY_FOR_BOOKING";
|
||||||
const canBookShipment =
|
const canBookShipment =
|
||||||
!customsPath && PATH_A_BOOKABLE.includes(contract.status);
|
!customsPath && PATH_A_BOOKABLE.includes(contract.status);
|
||||||
|
const bookingAction = getContractBookingAction(contract, contractBookings);
|
||||||
|
const canRequestShipment = bookingAction.kind === "request";
|
||||||
// Customs + clearance finalized: GL is preparing the booking — surface a
|
// Customs + clearance finalized: GL is preparing the booking — surface a
|
||||||
// status notice instead of any action.
|
// status notice instead of any action.
|
||||||
const glPreparingBooking = customsPath && clearanceFinalized;
|
const glPreparingBooking = customsPath && clearanceFinalized;
|
||||||
@@ -333,6 +352,17 @@ export default function ContractDetailPage() {
|
|||||||
Download PDF
|
Download PDF
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{canRequestShipment && (
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
leftSection={<PackagePlus size={16} />}
|
||||||
|
onClick={() => navigate(bookingAction.to)}
|
||||||
|
>
|
||||||
|
Request shipment
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{canBookShipment && (
|
{canBookShipment && (
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
@@ -893,6 +923,22 @@ export default function ContractDetailPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{(clearanceView?.workflowFiles?.length ?? 0) > 0 ? (
|
||||||
|
<ClearanceWorkflowFilesSection
|
||||||
|
files={clearanceView!.workflowFiles!}
|
||||||
|
onView={view}
|
||||||
|
onDownload={async (f) => {
|
||||||
|
try {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = fileViewUrl(f.id, true);
|
||||||
|
a.download = f.name;
|
||||||
|
a.click();
|
||||||
|
} catch {
|
||||||
|
toast.error("Could not download file.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -900,6 +946,98 @@ export default function ContractDetailPage() {
|
|||||||
|
|
||||||
{/* ── Bookings tab ──────────────────────────────────────────── */}
|
{/* ── Bookings tab ──────────────────────────────────────────── */}
|
||||||
<Tabs.Panel value="bookings">
|
<Tabs.Panel value="bookings">
|
||||||
|
{showShipmentRequests && (
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
p="lg"
|
||||||
|
mb="md"
|
||||||
|
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center" mb="md">
|
||||||
|
<SectionLabel>Shipment requests</SectionLabel>
|
||||||
|
{canRequestShipment && (
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<PackagePlus size={14} />}
|
||||||
|
onClick={() => navigate(bookingAction.to)}
|
||||||
|
>
|
||||||
|
New request
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{activeShipmentRequests.length === 0 ? (
|
||||||
|
<Text fz={13} c="dimmed">
|
||||||
|
{canRequestShipment
|
||||||
|
? "No pending shipment requests. Submit a request when you are ready to ship."
|
||||||
|
: "Shipment requests appear here once the contract is active."}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap={10}>
|
||||||
|
{activeShipmentRequests.map((req) => (
|
||||||
|
<Group
|
||||||
|
key={req.id}
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${BORDER}`,
|
||||||
|
cursor: req.createdBookingId ? "pointer" : "default",
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (req.createdBookingId) {
|
||||||
|
navigate(`/bookings/${req.createdBookingId}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<CalendarClock
|
||||||
|
size={16}
|
||||||
|
color={MUTED}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text fz={14} fw={700} style={{ color: INK }} truncate>
|
||||||
|
{req.reference}
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="dimmed">
|
||||||
|
{req.scheduledDate
|
||||||
|
? `Preferred date: ${req.scheduledDate}`
|
||||||
|
: "No preferred date"}
|
||||||
|
{req.notes ? ` · ${req.notes}` : ""}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
color={
|
||||||
|
req.status === "ACCEPTED"
|
||||||
|
? "teal"
|
||||||
|
: req.status === "REJECTED"
|
||||||
|
? "red"
|
||||||
|
: "yellow"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{req.status === "ACCEPTED" && req.createdBookingId
|
||||||
|
? "Booking created"
|
||||||
|
: req.status}
|
||||||
|
</Badge>
|
||||||
|
{req.createdBookingId ? (
|
||||||
|
<ChevronRight size={16} color={MUTED} />
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
<Card
|
<Card
|
||||||
withBorder
|
withBorder
|
||||||
radius="lg"
|
radius="lg"
|
||||||
@@ -1142,7 +1280,8 @@ function DocFileRow({
|
|||||||
file: ContractFile;
|
file: ContractFile;
|
||||||
onView: (f: ViewableFile) => void;
|
onView: (f: ViewableFile) => void;
|
||||||
}) {
|
}) {
|
||||||
const kind = labelForDocCode(file.code);
|
const kind =
|
||||||
|
clearanceWorkflowFileLabel(file.code) ?? labelForDocCode(file.code);
|
||||||
const { ext, color } = fileTypeChip(file.name, file.mimeType);
|
const { ext, color } = fileTypeChip(file.name, file.mimeType);
|
||||||
const viewable = isViewable({
|
const viewable = isViewable({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
|
|||||||
@@ -19,27 +19,20 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Eye,
|
|
||||||
FileSignature,
|
|
||||||
FileStack,
|
FileStack,
|
||||||
Inbox,
|
Inbox,
|
||||||
Package,
|
Package,
|
||||||
PackagePlus,
|
|
||||||
PencilLine,
|
|
||||||
Plus,
|
Plus,
|
||||||
RotateCcw,
|
|
||||||
Search,
|
Search,
|
||||||
Timer,
|
Timer,
|
||||||
Upload,
|
|
||||||
Weight,
|
Weight,
|
||||||
X,
|
X,
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { ContractCustomerAction } from "@/components/customer-actions/ContractCustomerAction";
|
||||||
import type { ContractListFilter } from "@/services/contracts.service";
|
import type { ContractListFilter } from "@/services/contracts.service";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { getContractBookingAction } from "./contract-booking-action";
|
|
||||||
import { usePagination } from "@edr/ui-common";
|
import { usePagination } from "@edr/ui-common";
|
||||||
import {
|
import {
|
||||||
BORDER,
|
BORDER,
|
||||||
@@ -60,89 +53,6 @@ function primaryRoute(contract: Freight.IContract) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Customs (Path B) statuses where the customer still needs to upload / manage
|
|
||||||
// clearance docs. Once finalized (CLEARANCE_READY_FOR_BOOKING) the row falls
|
|
||||||
// through to the Book action instead.
|
|
||||||
const PATH_B_CLEARANCE_STATUSES = [
|
|
||||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
|
||||||
"CLEARANCE_UNDER_REVIEW",
|
|
||||||
];
|
|
||||||
|
|
||||||
interface RowAction {
|
|
||||||
label: string;
|
|
||||||
to: string;
|
|
||||||
primary: boolean;
|
|
||||||
icon: LucideIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The single most relevant next action for a customer's contract row. */
|
|
||||||
function getCustomerRowAction(
|
|
||||||
contract: Freight.IContract,
|
|
||||||
bookings: Freight.IBooking[],
|
|
||||||
): RowAction {
|
|
||||||
const id = contract.id;
|
|
||||||
if (contract.status === "CONTRACT_READY") {
|
|
||||||
return {
|
|
||||||
label: "View & sign",
|
|
||||||
to: `/contracts/${id}/view`,
|
|
||||||
primary: true,
|
|
||||||
icon: FileSignature,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (contract.status === "CHANGES_REQUESTED") {
|
|
||||||
return {
|
|
||||||
label: "Edit & resubmit",
|
|
||||||
to: `/contracts/${id}`,
|
|
||||||
primary: true,
|
|
||||||
icon: PencilLine,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
contract.customsClearingEnabled &&
|
|
||||||
PATH_B_CLEARANCE_STATUSES.includes(contract.status)
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
label: "Upload clearance",
|
|
||||||
to: `/contracts/${id}/clearance`,
|
|
||||||
primary: true,
|
|
||||||
icon: Upload,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Customs (Path B), clearance finalized: Global Logistics creates the booking
|
|
||||||
// on the customer's behalf — the customer only views the contract.
|
|
||||||
if (
|
|
||||||
contract.customsClearingEnabled &&
|
|
||||||
["CLEARANCE_READY_FOR_BOOKING", "ACTIVE_SHIPMENT_IN_PROGRESS"].includes(
|
|
||||||
contract.status,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
label: "View",
|
|
||||||
to: `/contracts/${id}`,
|
|
||||||
primary: false,
|
|
||||||
icon: Eye,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const booking = getContractBookingAction(contract, bookings);
|
|
||||||
if (booking.kind === "book") {
|
|
||||||
return {
|
|
||||||
label: "Book shipment",
|
|
||||||
to: booking.to,
|
|
||||||
primary: true,
|
|
||||||
icon: PackagePlus,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (booking.kind === "rebook") {
|
|
||||||
return {
|
|
||||||
label: "Re-book shipment",
|
|
||||||
to: booking.to,
|
|
||||||
primary: true,
|
|
||||||
icon: RotateCcw,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { label: "View", to: `/contracts/${id}`, primary: false, icon: Eye };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContractsList() {
|
export default function ContractsList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
@@ -473,7 +383,6 @@ export default function ContractsList() {
|
|||||||
const tradeLabel = dir
|
const tradeLabel = dir
|
||||||
? dir.charAt(0) + dir.slice(1).toLowerCase()
|
? dir.charAt(0) + dir.slice(1).toLowerCase()
|
||||||
: "—";
|
: "—";
|
||||||
const action = getCustomerRowAction(c, bookings);
|
|
||||||
return (
|
return (
|
||||||
<Table.Tr
|
<Table.Tr
|
||||||
key={c.id}
|
key={c.id}
|
||||||
@@ -565,31 +474,12 @@ export default function ContractsList() {
|
|||||||
contract={c}
|
contract={c}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
<Button
|
<ContractCustomerAction
|
||||||
|
contract={c}
|
||||||
|
bookings={bookings}
|
||||||
size="sm"
|
size="sm"
|
||||||
radius="md"
|
listStyle
|
||||||
h={34}
|
/>
|
||||||
variant={action.primary ? "filled" : "light"}
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<action.icon size={15} />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate(action.to);
|
|
||||||
}}
|
|
||||||
styles={{
|
|
||||||
root: {
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 13,
|
|
||||||
paddingInline: 14,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
boxShadow: action.primary
|
|
||||||
? "0 1px 2px rgba(14,163,113,0.25)"
|
|
||||||
: "none",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ import { formatRateUnit } from "./new-contract-form/unit-rates";
|
|||||||
import {
|
import {
|
||||||
ShipmentFormInputValues,
|
ShipmentFormInputValues,
|
||||||
ShipmentFormValues,
|
ShipmentFormValues,
|
||||||
|
createShipmentFormSchema,
|
||||||
initialShipmentFormValues,
|
initialShipmentFormValues,
|
||||||
shipmentFormSchema,
|
|
||||||
} from "./new-shipment-form/schema";
|
} from "./new-shipment-form/schema";
|
||||||
import { computeShipmentTotal } from "./new-shipment-form/total";
|
import { computeShipmentTotal } from "./new-shipment-form/total";
|
||||||
import { ContractCapacityNotice } from "./new-shipment-form/ContractCapacityNotice";
|
import { ContractCapacityNotice } from "./new-shipment-form/ContractCapacityNotice";
|
||||||
@@ -58,37 +58,11 @@ type ShipmentForm = ReturnType<
|
|||||||
export default function NewShipmentPage() {
|
export default function NewShipmentPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// Holds the validated values awaiting price confirmation. When set, the price
|
|
||||||
// modal is open. The customer confirms (books) or rejects (back to the form
|
|
||||||
// to edit and re-book).
|
|
||||||
const [pendingValues, setPendingValues] = useState<ShipmentFormValues | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: contract, isLoading } = useQuery(
|
const { data: contract, isLoading } = useQuery(
|
||||||
api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }),
|
api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const form = useForm<ShipmentFormInputValues, any, ShipmentFormValues>({
|
|
||||||
defaultValues: initialShipmentFormValues,
|
|
||||||
resolver: zodResolver(shipmentFormSchema),
|
|
||||||
mode: "onChange",
|
|
||||||
});
|
|
||||||
|
|
||||||
const submitMutation = useMutation({
|
|
||||||
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
|
||||||
api.contracts.createBookingUnderContract.call({ id: id!, dto }),
|
|
||||||
onSuccess: (booking) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: api.contracts.get.queryKey({ id: id! }),
|
|
||||||
});
|
|
||||||
navigate(`/bookings/${booking.id}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Center mih={400} p="xl">
|
<Center mih={400} p="xl">
|
||||||
@@ -112,9 +86,6 @@ export default function NewShipmentPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Customs (Path B) contracts are booked by Global Logistics on behalf of the
|
|
||||||
// customer — the customer never books one himself. Block the form entirely and
|
|
||||||
// point back to the clearance workspace.
|
|
||||||
if (contract.customsClearingEnabled) {
|
if (contract.customsClearingEnabled) {
|
||||||
return (
|
return (
|
||||||
<Box p="xl">
|
<Box p="xl">
|
||||||
@@ -140,10 +111,60 @@ export default function NewShipmentPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return <NewShipmentBookingForm contract={contract} contractId={id!} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bulkUnitOfMeasure(
|
||||||
|
contract: Freight.IContract,
|
||||||
|
): "PER_TON" | "PER_ITEM" {
|
||||||
|
const hasPerItem = contract.pricingBreakdown?.lineItems?.some(
|
||||||
|
(li) => li.unit === "per_item",
|
||||||
|
);
|
||||||
|
return hasPerItem ? "PER_ITEM" : "PER_TON";
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewShipmentBookingForm({
|
||||||
|
contract,
|
||||||
|
contractId,
|
||||||
|
}: {
|
||||||
|
contract: Freight.IContract;
|
||||||
|
contractId: string;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [pendingValues, setPendingValues] = useState<ShipmentFormValues | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm<ShipmentFormInputValues, any, ShipmentFormValues>({
|
||||||
|
defaultValues: initialShipmentFormValues,
|
||||||
|
resolver: zodResolver(
|
||||||
|
createShipmentFormSchema({
|
||||||
|
isContainer: contract.freightType === "CONTAINER",
|
||||||
|
isHazardous: contract.isHazardous ?? false,
|
||||||
|
isReefer: contract.isReefer ?? false,
|
||||||
|
unitOfMeasure: bulkUnitOfMeasure(contract),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
mode: "onChange",
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitMutation = useMutation({
|
||||||
|
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||||
|
api.contracts.createBookingUnderContract.call({ id: contractId, dto }),
|
||||||
|
onSuccess: (booking) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.contracts.get.queryKey({ id: contractId }),
|
||||||
|
});
|
||||||
|
navigate(`/bookings/${booking.id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function buildDto(
|
function buildDto(
|
||||||
values: ShipmentFormValues,
|
values: ShipmentFormValues,
|
||||||
): Freight.CreateBookingUnderContractDto {
|
): Freight.CreateBookingUnderContractDto {
|
||||||
const isContainer = contract!.freightType === "CONTAINER";
|
const isContainer = contract.freightType === "CONTAINER";
|
||||||
return {
|
return {
|
||||||
...(values.contractRouteId
|
...(values.contractRouteId
|
||||||
? { contractRouteId: values.contractRouteId }
|
? { contractRouteId: values.contractRouteId }
|
||||||
@@ -168,7 +189,7 @@ export default function NewShipmentPage() {
|
|||||||
: {
|
: {
|
||||||
bulkLines: [
|
bulkLines: [
|
||||||
{
|
{
|
||||||
cargoTypeId: contract!.cargoScope?.[0]?.cargoTypeId ?? null,
|
cargoTypeId: contract.cargoScope?.[0]?.cargoTypeId ?? null,
|
||||||
cargoWeightTons: values.cargoWeightTons
|
cargoWeightTons: values.cargoWeightTons
|
||||||
? Number(values.cargoWeightTons)
|
? Number(values.cargoWeightTons)
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -177,6 +198,8 @@ export default function NewShipmentPage() {
|
|||||||
: undefined,
|
: undefined,
|
||||||
hazardousQuantity:
|
hazardousQuantity:
|
||||||
Number(values.bulkHazardousQuantity || 0) || undefined,
|
Number(values.bulkHazardousQuantity || 0) || undefined,
|
||||||
|
reeferQuantity:
|
||||||
|
Number(values.bulkReeferQuantity || 0) || undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -568,8 +591,9 @@ function ScheduleStep({
|
|||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Box>
|
<Box>
|
||||||
<StepLabel>Shipment day *</StepLabel>
|
<StepLabel>Shipment day *</StepLabel>
|
||||||
<Box mt={10}>
|
<Box mt={10} w="100%">
|
||||||
<OperationDatePicker
|
<OperationDatePicker
|
||||||
|
fullWidth
|
||||||
availableDays={availableDays ?? []}
|
availableDays={availableDays ?? []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
@@ -721,6 +745,24 @@ function CargoStep({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{contract.isReefer && (
|
||||||
|
<Controller
|
||||||
|
name="bulkReeferQuantity"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextInput
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
label="Refrigerated quantity"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
radius={10}
|
||||||
|
styles={fieldStyles}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</StepCard>
|
</StepCard>
|
||||||
);
|
);
|
||||||
@@ -805,12 +847,13 @@ function ContainerLineEditor({
|
|||||||
<Controller
|
<Controller
|
||||||
name={`containers.${index}.hazardousQuantity`}
|
name={`containers.${index}.hazardousQuantity`}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field }) => (
|
render={({ field, fieldState }) => (
|
||||||
<TextInput
|
<TextInput
|
||||||
{...field}
|
{...field}
|
||||||
type="number"
|
type="number"
|
||||||
label="Hazardous qty"
|
label="Hazardous qty"
|
||||||
min={0}
|
min={0}
|
||||||
|
error={fieldState.error?.message}
|
||||||
radius={10}
|
radius={10}
|
||||||
styles={fieldStyles}
|
styles={fieldStyles}
|
||||||
/>
|
/>
|
||||||
@@ -821,12 +864,13 @@ function ContainerLineEditor({
|
|||||||
<Controller
|
<Controller
|
||||||
name={`containers.${index}.reeferQuantity`}
|
name={`containers.${index}.reeferQuantity`}
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field }) => (
|
render={({ field, fieldState }) => (
|
||||||
<TextInput
|
<TextInput
|
||||||
{...field}
|
{...field}
|
||||||
type="number"
|
type="number"
|
||||||
label="Reefer qty"
|
label="Reefer qty"
|
||||||
min={0}
|
min={0}
|
||||||
|
error={fieldState.error?.message}
|
||||||
radius={10}
|
radius={10}
|
||||||
styles={fieldStyles}
|
styles={fieldStyles}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
NumberInput,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { ArrowLeft, Send } from "lucide-react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
import { OperationDatePicker } from "@edr/ui-common";
|
||||||
|
|
||||||
|
const BORDER = "#E6ECF2";
|
||||||
|
|
||||||
|
export default function NewShipmentRequestPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [scheduledDate, setScheduledDate] = useState<Date | null>(null);
|
||||||
|
const [quantity, setQuantity] = useState<number | string>(1);
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
const { data: contract, isLoading } = useQuery({
|
||||||
|
queryKey: ["contract", id],
|
||||||
|
queryFn: () => contractsService.get(id!),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: capacity } = useQuery({
|
||||||
|
queryKey: ["contract-capacity", id],
|
||||||
|
queryFn: () => contractsService.getCapacity(id!),
|
||||||
|
enabled: Boolean(id) && contract?.contractKind === "GENERAL",
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = useMutation({
|
||||||
|
mutationFn: (dto: Freight.CreateBookingRequestDto) =>
|
||||||
|
contractsService.submitBookingRequest(id!, dto),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Shipment request submitted");
|
||||||
|
navigate(`/contracts/${id}`);
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message || "Could not submit request"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !contract) {
|
||||||
|
return (
|
||||||
|
<Group justify="center" py={80}>
|
||||||
|
<Loader color="teal" />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isContainer = contract.freightType === "CONTAINER";
|
||||||
|
const route = contract.routes?.[0];
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const dto: Freight.CreateBookingRequestDto = {
|
||||||
|
contractRouteId: route?.id,
|
||||||
|
scheduledDate: scheduledDate?.toISOString(),
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isContainer) {
|
||||||
|
const size = contract.cargoScope?.[0]?.containerSize ?? "20FT";
|
||||||
|
dto.containers = [
|
||||||
|
{
|
||||||
|
containerSize: size,
|
||||||
|
quantity: Number(quantity) || 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
dto.bulk = {
|
||||||
|
cargoTypeId: contract.cargoScope?.[0]?.cargoTypeId ?? null,
|
||||||
|
cargoWeightTons: Number(quantity) || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
submit.mutate(dto);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ padding: "28px 32px 40px", maxWidth: 720, margin: "0 auto" }}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Group gap="md">
|
||||||
|
<Button variant="subtle" color="gray" onClick={() => navigate(`/contracts/${id}`)}>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<Title order={3}>Request shipment</Title>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{contract.reference} — Global Logistics will review and create your booking.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="xl" style={{ borderColor: BORDER }}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<OperationDatePicker
|
||||||
|
label="Preferred shipment date"
|
||||||
|
value={scheduledDate}
|
||||||
|
onChange={setScheduledDate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label={isContainer ? "Number of containers" : "Cargo weight (tons)"}
|
||||||
|
value={quantity}
|
||||||
|
onChange={setQuantity}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{capacity?.length ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Remaining capacity is shown on the contract — GL will validate your request.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Notes (optional)"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="teal"
|
||||||
|
leftSection={<Send size={16} />}
|
||||||
|
loading={submit.isPending}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
Submit shipment request
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ export const TERMINAL_BOOKING_STATUSES = [
|
|||||||
/** Path A statuses where a customer (no customs) may book against the contract. */
|
/** Path A statuses where a customer (no customs) may book against the contract. */
|
||||||
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
|
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
|
||||||
|
|
||||||
export type ContractBookingActionKind = "book" | "rebook" | "none";
|
export type ContractBookingActionKind = "book" | "rebook" | "request" | "none";
|
||||||
|
|
||||||
export interface ContractBookingAction {
|
export interface ContractBookingAction {
|
||||||
kind: ContractBookingActionKind;
|
kind: ContractBookingActionKind;
|
||||||
@@ -35,8 +35,19 @@ export function getContractBookingAction(
|
|||||||
contract: Freight.IContract,
|
contract: Freight.IContract,
|
||||||
bookings: Freight.IBooking[],
|
bookings: Freight.IBooking[],
|
||||||
): ContractBookingAction {
|
): ContractBookingAction {
|
||||||
// Customs (Path B) contracts are booked by Global Logistics on behalf of the
|
// GENERAL + customs: customer submits a shipment request; GL creates the booking.
|
||||||
// customer — the customer never gets a Book button for them.
|
if (
|
||||||
|
contract.customsClearingEnabled &&
|
||||||
|
contract.contractKind === "GENERAL" &&
|
||||||
|
contract.status === "CONTRACT_ACTIVE"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "request",
|
||||||
|
to: `/contracts/${contract.id}/shipment-requests/new`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other customs (ONE_TIME): booked by GL — no customer action.
|
||||||
if (contract.customsClearingEnabled) return { kind: "none", to: "" };
|
if (contract.customsClearingEnabled) return { kind: "none", to: "" };
|
||||||
if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" };
|
if (!PATH_A_BOOKABLE.includes(contract.status)) return { kind: "none", to: "" };
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ export {
|
|||||||
operationToTradeDirection,
|
operationToTradeDirection,
|
||||||
operationToProfileType,
|
operationToProfileType,
|
||||||
getRouteDirection,
|
getRouteDirection,
|
||||||
|
filterBookableServices,
|
||||||
} from "@/pages/bookings/new-booking-form/schema";
|
} from "@/pages/bookings/new-booking-form/schema";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user