This commit is contained in:
marshal
2026-07-01 20:55:17 +03:00
parent 612df8daff
commit 9c18d086d7
112 changed files with 5654 additions and 1370 deletions

View File

@@ -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;`,
);
}
}

View File

@@ -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
`);
}
}

View File

@@ -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)
`);
}
}

View File

@@ -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;`,
);
}
}

View File

@@ -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,
}), }),
); );

View File

@@ -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 };
} }

View File

@@ -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 };
} }

View File

@@ -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 };
} }

View File

@@ -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);

View File

@@ -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' })

View File

@@ -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,

View File

@@ -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[];

View File

@@ -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;
} }

View File

@@ -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;

View File

@@ -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) }),
);
});
});
});

View File

@@ -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;
}
}

View File

@@ -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();

View File

@@ -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);
});
});
}); });

View File

@@ -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;

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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');

View File

@@ -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);

View File

@@ -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')

View File

@@ -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,
], ],

View File

@@ -547,6 +547,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
| 'roHoldReason' | 'roHoldReason'
| 'currentPhase' | 'currentPhase'
| 'status' | 'status'
| 'preClearanceFinalizedAt'
> >
>, >,
): Promise<void> { ): Promise<void> {

View File

@@ -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;
} }

View File

@@ -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;
}

View File

@@ -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;
} }

View File

@@ -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;
} }

View File

@@ -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;
} }

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -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()

View File

@@ -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

View File

@@ -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(

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
}), }),
]); ]);
} }

View File

@@ -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");

View File

@@ -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;

View File

@@ -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;
}) { }) {

View File

@@ -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>
);
}

View File

@@ -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> </>
); );
} }

View File

@@ -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"

View File

@@ -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`);
} }

View File

@@ -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 {

View File

@@ -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

View File

@@ -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: {

View File

@@ -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) =>

View File

@@ -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" };
}

View File

@@ -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,
});
}

View File

@@ -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

View File

@@ -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>
); );

View File

@@ -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) => ({

View File

@@ -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>
); );
} }

View File

@@ -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

View File

@@ -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} />
) : ( ) : (

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -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}

View File

@@ -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">

View File

@@ -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" },
], ],

View File

@@ -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

View File

@@ -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[]>(

View File

@@ -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> {

View File

@@ -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,

View File

@@ -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>) =>

View File

@@ -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 />}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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: {

View File

@@ -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 }}>

View File

@@ -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}`,

View File

@@ -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>
); );
} }

View File

@@ -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>
); );

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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,
}))}
/> />
)} )}
/> />

View File

@@ -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,

View File

@@ -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")

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>
); );
} }

View File

@@ -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",
}; };

View File

@@ -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">

View File

@@ -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"

View File

@@ -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,

View File

@@ -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>

View File

@@ -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}
/> />

View File

@@ -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>
);
}

View File

@@ -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: "" };

View File

@@ -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