mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 20:05:41 +00:00
feat: implement document clearance workflow for bookings
- Added ClearanceCard component to display and manage clearance documents in ReadonlyBookingView. - Introduced new API endpoints for clearance operations: getClearance, submitClearanceDocuments, and proceedToOperation. - Created BookingDocumentReview entity and migration for document review status tracking. - Developed GlClearancePage for Global Logistics to review and manage document submissions. - Implemented utility functions for determining clearance setting codes based on trade direction and freight type. - Added tests for booking transition clearance logic and clearance utility functions.
This commit is contained in:
@@ -0,0 +1,66 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-document GL review for the post-counter-sign clearance gate. One row per
|
||||||
|
* required clearance document; GL marks each APPROVED or QUERIED before the
|
||||||
|
* booking can proceed to operations.
|
||||||
|
*/
|
||||||
|
export class CreateBookingDocumentReview1820000000002
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'CreateBookingDocumentReview1820000000002';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
schema: 'freight',
|
||||||
|
name: 'booking_document_review',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||||
|
{ name: 'booking_id', type: 'uuid' },
|
||||||
|
{ name: 'setting_code', type: 'varchar', length: '128' },
|
||||||
|
{ name: 'file_key', type: 'varchar', length: '128' },
|
||||||
|
{ name: 'file_record_id', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'status', type: 'varchar', length: '20', default: "'PENDING'" },
|
||||||
|
{ name: 'note', type: 'text', isNullable: true },
|
||||||
|
{ name: 'reviewed_by_staff_id', type: 'uuid', isNullable: true },
|
||||||
|
{ name: 'reviewed_at', type: 'timestamptz', isNullable: true },
|
||||||
|
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
],
|
||||||
|
foreignKeys: [
|
||||||
|
{
|
||||||
|
columnNames: ['booking_id'],
|
||||||
|
referencedSchema: 'freight',
|
||||||
|
referencedTableName: 'bookings',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'freight.booking_document_review',
|
||||||
|
new TableIndex({ name: 'idx_booking_document_review_booking', columnNames: ['booking_id'] }),
|
||||||
|
);
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'freight.booking_document_review',
|
||||||
|
new TableIndex({ name: 'idx_booking_document_review_status', columnNames: ['status'] }),
|
||||||
|
);
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'freight.booking_document_review',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'uq_booking_document_review_doc',
|
||||||
|
columnNames: ['booking_id', 'setting_code', 'file_key'],
|
||||||
|
isUnique: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('freight.booking_document_review', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { FileRecord } from '../files/entities/file.entity';
|
|||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
|
import { clearanceSettingCode } from './clearance.util';
|
||||||
import { ContractViewDto } from './dto/contract-view.dto';
|
import { ContractViewDto } from './dto/contract-view.dto';
|
||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
||||||
@@ -222,19 +223,31 @@ export class BookingContractService {
|
|||||||
|
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
// Whether a document-clearance gate applies (IMPORT/EXPORT bookings). When it
|
||||||
|
// does, the counter-signed booking goes to AWAITING_DOCUMENTS for the customer
|
||||||
|
// to upload clearance documents instead of straight into the batch pipeline.
|
||||||
|
const includesCustoms = booking.serviceType?.includesCustoms ?? false;
|
||||||
|
const clearanceCode = clearanceSettingCode(
|
||||||
|
booking.tradeDirection,
|
||||||
|
booking.freightType,
|
||||||
|
includesCustoms,
|
||||||
|
);
|
||||||
|
|
||||||
if (role === 'CUSTOMER') {
|
if (role === 'CUSTOMER') {
|
||||||
updates.status = 'SIGNED_CUSTOMER';
|
updates.status = 'SIGNED_CUSTOMER';
|
||||||
updates.customerSignedAt = now;
|
updates.customerSignedAt = now;
|
||||||
} else {
|
} else {
|
||||||
updates.status = 'FULLY_EXECUTED';
|
|
||||||
updates.fullyExecutedAt = now;
|
updates.fullyExecutedAt = now;
|
||||||
updates.marketingApprovedAt = now;
|
updates.marketingApprovedAt = now;
|
||||||
updates.marketingApprovedById = options.signerUserId ?? null;
|
updates.marketingApprovedById = options.signerUserId ?? null;
|
||||||
updates.lockedAt = now;
|
updates.lockedAt = now;
|
||||||
|
updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED';
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, updates as never);
|
const updated = await this.bookingsRepository.update(bookingId, updates as never);
|
||||||
if (role === 'STAFF' && updated?.trainScheduleId) {
|
// Only the non-clearance (legacy/domestic) path enters the batch pipeline now;
|
||||||
|
// clearance bookings enter operations after the GL document gate.
|
||||||
|
if (role === 'STAFF' && !clearanceCode && updated?.trainScheduleId) {
|
||||||
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
|
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -57,6 +57,26 @@ export function computeNextStep(
|
|||||||
action: 'AWAIT_PAYMENT',
|
action: 'AWAIT_PAYMENT',
|
||||||
description: 'Awaiting customer payment',
|
description: 'Awaiting customer payment',
|
||||||
};
|
};
|
||||||
|
case 'AWAITING_DOCUMENTS':
|
||||||
|
return {
|
||||||
|
action: 'UPLOAD_DOCUMENTS',
|
||||||
|
description: 'Upload the clearance documents for your shipment',
|
||||||
|
};
|
||||||
|
case 'DOCUMENTS_UNDER_REVIEW':
|
||||||
|
return {
|
||||||
|
action: 'AWAIT_DOCUMENT_REVIEW',
|
||||||
|
description: 'Global Logistics is reviewing your documents',
|
||||||
|
};
|
||||||
|
case 'CLEARANCE_READY':
|
||||||
|
return {
|
||||||
|
action: 'PROCEED_TO_OPERATION',
|
||||||
|
description: 'Clearance is ready — proceed to operation',
|
||||||
|
};
|
||||||
|
case 'OPERATION_REQUESTED':
|
||||||
|
return {
|
||||||
|
action: 'AWAIT_OPERATION',
|
||||||
|
description: 'Operation requested; an operator will take it forward',
|
||||||
|
};
|
||||||
case 'PAID':
|
case 'PAID':
|
||||||
return {
|
return {
|
||||||
action: 'START_TRANSIT',
|
action: 'START_TRANSIT',
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { BookingTransitionService } from './booking-transition.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focused tests for the clearance 100%-approved gate in finalizeClearance.
|
||||||
|
* Uses minimal stubs for the service's collaborators.
|
||||||
|
*/
|
||||||
|
describe('BookingTransitionService — finalizeClearance gate', () => {
|
||||||
|
const booking = {
|
||||||
|
id: 'b-1',
|
||||||
|
status: 'DOCUMENTS_UNDER_REVIEW',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
serviceType: { includesCustoms: false }, // no output set → only the input gate
|
||||||
|
};
|
||||||
|
|
||||||
|
// Input set has two required docs.
|
||||||
|
const inputSetting = {
|
||||||
|
code: 'clearance_import_container_without_customs',
|
||||||
|
fields: [
|
||||||
|
{ fileKey: 'commercial_invoice', isRequired: true },
|
||||||
|
{ fileKey: 'packing_list', isRequired: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeService(reviews: Array<{ settingCode: string; fileKey: string; status: string }>) {
|
||||||
|
const bookingsRepository = {
|
||||||
|
findDocumentReviews: jest.fn().mockResolvedValue(reviews),
|
||||||
|
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
|
||||||
|
};
|
||||||
|
const bookingsService = {
|
||||||
|
findById: jest.fn().mockResolvedValue(booking),
|
||||||
|
};
|
||||||
|
const fileUploadSettingsService = {
|
||||||
|
getByCode: jest.fn().mockResolvedValue(inputSetting),
|
||||||
|
};
|
||||||
|
const filesService = { findByResource: jest.fn().mockResolvedValue([]) };
|
||||||
|
|
||||||
|
const service = new BookingTransitionService(
|
||||||
|
bookingsRepository as never,
|
||||||
|
{} as never, // ruleEngineService
|
||||||
|
{} as never, // pricingService
|
||||||
|
{} as never, // contractService
|
||||||
|
filesService as never,
|
||||||
|
fileUploadSettingsService as never,
|
||||||
|
bookingsService as never,
|
||||||
|
);
|
||||||
|
return { service, bookingsRepository };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rejects when a required document is not APPROVED', async () => {
|
||||||
|
const { service } = makeService([
|
||||||
|
{
|
||||||
|
settingCode: inputSetting.code,
|
||||||
|
fileKey: 'commercial_invoice',
|
||||||
|
status: 'APPROVED',
|
||||||
|
},
|
||||||
|
// packing_list is still PENDING (missing approval)
|
||||||
|
]);
|
||||||
|
await expect(service.finalizeClearance('b-1')).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves to CLEARANCE_READY when all required documents are APPROVED', async () => {
|
||||||
|
const { service, bookingsRepository } = makeService([
|
||||||
|
{ settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' },
|
||||||
|
{ settingCode: inputSetting.code, fileKey: 'packing_list', status: 'APPROVED' },
|
||||||
|
]);
|
||||||
|
await service.finalizeClearance('b-1');
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||||
|
'b-1',
|
||||||
|
expect.objectContaining({ status: 'CLEARANCE_READY' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,10 +8,13 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre
|
|||||||
|
|
||||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||||
import { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
|
import { clearanceCodesForBooking } from './clearance.util';
|
||||||
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
|
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
|
||||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
@@ -25,6 +28,8 @@ export class BookingTransitionService {
|
|||||||
private readonly ruleEngineService: RuleEngineService,
|
private readonly ruleEngineService: RuleEngineService,
|
||||||
private readonly pricingService: BookingPricingService,
|
private readonly pricingService: BookingPricingService,
|
||||||
private readonly contractService: BookingContractService,
|
private readonly contractService: BookingContractService,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||||
@Inject(forwardRef(() => BookingsService))
|
@Inject(forwardRef(() => BookingsService))
|
||||||
private readonly bookingsService: BookingsService,
|
private readonly bookingsService: BookingsService,
|
||||||
) {}
|
) {}
|
||||||
@@ -446,6 +451,289 @@ export class BookingTransitionService {
|
|||||||
return this.bookingsService.findById(updated!.id);
|
return this.bookingsService.findById(updated!.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Document clearance gate (post counter-sign) ───────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The clearance document grid for a booking: each required field from the
|
||||||
|
* resolved customer-input set (and the GL-output set for customs) with its
|
||||||
|
* uploaded file and GL review status. Drives both portals' clearance UI.
|
||||||
|
*/
|
||||||
|
async getClearanceView(bookingId: string): Promise<{
|
||||||
|
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;
|
||||||
|
}> {
|
||||||
|
const booking = await this.bookingsService.findById(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: Awaited<
|
||||||
|
ReturnType<BookingTransitionService['getClearanceView']>
|
||||||
|
>['documents'] = [];
|
||||||
|
|
||||||
|
const pushSetting = async (
|
||||||
|
code: string | null,
|
||||||
|
uploadedBy: 'customer' | 'gl',
|
||||||
|
) => {
|
||||||
|
if (!code) return;
|
||||||
|
let setting;
|
||||||
|
try {
|
||||||
|
setting = await this.fileUploadSettingsService.getByCode(code);
|
||||||
|
} catch {
|
||||||
|
return; // setting not seeded — skip gracefully
|
||||||
|
}
|
||||||
|
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');
|
||||||
|
|
||||||
|
// Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
|
||||||
|
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);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: booking.status,
|
||||||
|
includesCustoms,
|
||||||
|
inputCode,
|
||||||
|
outputCode,
|
||||||
|
documents,
|
||||||
|
allApproved,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when every REQUIRED field of the booking's customer-input clearance set
|
||||||
|
* has an APPROVED review row. The 100% gate before clearance can be finalized.
|
||||||
|
*/
|
||||||
|
private async isClearanceFullyApproved(booking: Booking): Promise<boolean> {
|
||||||
|
const { inputCode } = clearanceCodesForBooking(booking);
|
||||||
|
if (!inputCode) return true; // no gate applies (e.g. domestic)
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer uploads clearance documents. Each multipart file's fieldname is the
|
||||||
|
* field's fileKey (or custom_<n> for ad-hoc). Saves FileRecords, refreshes the
|
||||||
|
* per-document review rows to PENDING, and moves the booking into review.
|
||||||
|
*/
|
||||||
|
async submitClearanceDocuments(
|
||||||
|
bookingId: string,
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']);
|
||||||
|
const { inputCode } = clearanceCodesForBooking(booking);
|
||||||
|
if (!inputCode) {
|
||||||
|
throw new BadRequestException('This booking has no document-clearance step');
|
||||||
|
}
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new BadRequestException('No documents uploaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const record = await this.filesService.upsertByCode({
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: file.fieldname,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
|
||||||
|
const settingCode = file.fieldname.startsWith('custom_')
|
||||||
|
? 'custom'
|
||||||
|
: inputCode;
|
||||||
|
await this.bookingsRepository.upsertDocumentReviewPending({
|
||||||
|
bookingId,
|
||||||
|
settingCode,
|
||||||
|
fileKey: file.fieldname,
|
||||||
|
fileRecordId: record.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'DOCUMENTS_UNDER_REVIEW',
|
||||||
|
} as never);
|
||||||
|
return this.bookingsService.findById(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL reviews a single document: APPROVED or QUERIED (with a note). */
|
||||||
|
async reviewDocument(
|
||||||
|
bookingId: string,
|
||||||
|
fileKey: string,
|
||||||
|
status: 'APPROVED' | 'QUERIED',
|
||||||
|
staffId: string,
|
||||||
|
note?: string,
|
||||||
|
): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
||||||
|
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
|
||||||
|
|
||||||
|
const existing = await this.bookingsRepository.findDocumentReviews(bookingId);
|
||||||
|
const match = existing.find((r) => r.fileKey === fileKey);
|
||||||
|
const settingCode =
|
||||||
|
match?.settingCode ??
|
||||||
|
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
|
||||||
|
|
||||||
|
if (status === 'QUERIED' && !note?.trim()) {
|
||||||
|
throw new BadRequestException('A note is required when querying a document');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bookingsRepository.setDocumentReviewStatus(
|
||||||
|
bookingId,
|
||||||
|
settingCode,
|
||||||
|
fileKey,
|
||||||
|
status,
|
||||||
|
staffId,
|
||||||
|
note,
|
||||||
|
);
|
||||||
|
if (status === 'QUERIED') {
|
||||||
|
await this.bookingsRepository.createReviewNote(
|
||||||
|
bookingId,
|
||||||
|
`Document "${fileKey}" queried: ${note}`,
|
||||||
|
'CHANGES_REQUESTED',
|
||||||
|
staffId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.bookingsService.findById(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */
|
||||||
|
async uploadClearanceOutputDocuments(
|
||||||
|
bookingId: string,
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
||||||
|
const { outputCode } = clearanceCodesForBooking(booking);
|
||||||
|
if (!outputCode) {
|
||||||
|
throw new BadRequestException('This booking has no customs output documents');
|
||||||
|
}
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new BadRequestException('No documents uploaded');
|
||||||
|
}
|
||||||
|
for (const file of files) {
|
||||||
|
await this.filesService.upsertByCode({
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: file.fieldname,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.bookingsService.findById(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL confirms clearance: requires every customer document APPROVED (100% gate)
|
||||||
|
* and, for customs, the required output documents present → CLEARANCE_READY.
|
||||||
|
*/
|
||||||
|
async finalizeClearance(bookingId: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
||||||
|
|
||||||
|
const approved = await this.isClearanceFullyApproved(booking);
|
||||||
|
if (!approved) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'All required documents must be approved before clearance can be finalized',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { outputCode } = clearanceCodesForBooking(booking);
|
||||||
|
if (outputCode) {
|
||||||
|
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
|
||||||
|
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||||
|
const uploaded = new Set(files.map((f) => f.code));
|
||||||
|
const missing = (setting.fields ?? []).filter(
|
||||||
|
(f) => f.isRequired && !uploaded.has(f.fileKey),
|
||||||
|
);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Upload all required customs output documents first: ${missing
|
||||||
|
.map((m) => m.fileLabel)
|
||||||
|
.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'CLEARANCE_READY',
|
||||||
|
} as never);
|
||||||
|
return this.bookingsService.findById(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer proceeds to operation once clearance is ready → OPERATION_REQUESTED. */
|
||||||
|
async requestOperation(bookingId: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
assertBookingStatus(booking, ['CLEARANCE_READY']);
|
||||||
|
await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'OPERATION_REQUESTED',
|
||||||
|
} as never);
|
||||||
|
return this.bookingsService.findById(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
async enrichBookingResponse(booking: Booking): Promise<Booking & {
|
async enrichBookingResponse(booking: Booking): Promise<Booking & {
|
||||||
latestChangeRequestNote?: string | null;
|
latestChangeRequestNote?: string | null;
|
||||||
contractSummary?: string | null;
|
contractSummary?: string | null;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
RejectBookingDto,
|
RejectBookingDto,
|
||||||
RejectStepDto,
|
RejectStepDto,
|
||||||
RequestChangesDto,
|
RequestChangesDto,
|
||||||
|
ReviewDocumentDto,
|
||||||
StaffRejectDto,
|
StaffRejectDto,
|
||||||
} from './dto/request-changes.dto';
|
} from './dto/request-changes.dto';
|
||||||
import { ContractViewDto } from './dto/contract-view.dto';
|
import { ContractViewDto } from './dto/contract-view.dto';
|
||||||
@@ -328,6 +329,86 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Document clearance (post counter-sign) ────────────────────────────────
|
||||||
|
|
||||||
|
@Get(':id/clearance')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Document-clearance grid (required docs + upload + GL review status)',
|
||||||
|
})
|
||||||
|
getClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.transitionService.getClearanceView(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/documents')
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Customer uploads clearance documents (fieldname = document key)',
|
||||||
|
})
|
||||||
|
async submitClearanceDocuments(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
) {
|
||||||
|
const booking = await this.transitionService.submitClearanceDocuments(
|
||||||
|
id,
|
||||||
|
files ?? [],
|
||||||
|
);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/proceed')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Customer proceeds to operation (CLEARANCE_READY → OPERATION_REQUESTED)',
|
||||||
|
})
|
||||||
|
async proceedToOperation(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const booking = await this.transitionService.requestOperation(id);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/review')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
|
||||||
|
@ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' })
|
||||||
|
async reviewClearanceDocument(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: ReviewDocumentDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
const booking = await this.transitionService.reviewDocument(
|
||||||
|
id,
|
||||||
|
dto.fileKey,
|
||||||
|
dto.status,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
dto.note,
|
||||||
|
);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/output-documents')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' })
|
||||||
|
async uploadClearanceOutput(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
) {
|
||||||
|
const booking = await this.transitionService.uploadClearanceOutputDocuments(
|
||||||
|
id,
|
||||||
|
files ?? [],
|
||||||
|
);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/finalize')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'GL finalizes clearance (requires 100% approved) → CLEARANCE_READY',
|
||||||
|
})
|
||||||
|
async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const booking = await this.transitionService.finalizeClearance(id);
|
||||||
|
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' })
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { CompaniesModule } from '../companies/companies.module';
|
|||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
import { MinioModule } from '../minio/minio.module';
|
import { MinioModule } from '../minio/minio.module';
|
||||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||||
|
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||||
import { SignaturesModule } from '../signatures/signatures.module';
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
import { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingPaymentService } from './booking-payment.service';
|
import { BookingPaymentService } from './booking-payment.service';
|
||||||
@@ -21,6 +22,7 @@ import { ConsolidationService } from './consolidation.service';
|
|||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||||
|
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||||
import { BookingContainer } from './entities/booking-container.entity';
|
import { BookingContainer } from './entities/booking-container.entity';
|
||||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||||
@@ -41,6 +43,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
BookingContainer,
|
BookingContainer,
|
||||||
BookingCargoModifier,
|
BookingCargoModifier,
|
||||||
BookingApprovalStep,
|
BookingApprovalStep,
|
||||||
|
BookingDocumentReview,
|
||||||
BookingRateSnapshot,
|
BookingRateSnapshot,
|
||||||
BookingReviewNote,
|
BookingReviewNote,
|
||||||
BookingContractSignature,
|
BookingContractSignature,
|
||||||
@@ -52,6 +55,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
// CustomersModule,
|
// CustomersModule,
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
|
FileUploadSettingsModule,
|
||||||
SignaturesModule,
|
SignaturesModule,
|
||||||
ExchangeModule.forRootAsync({
|
ExchangeModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQuer
|
|||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||||
|
import {
|
||||||
|
BookingDocumentReview,
|
||||||
|
DocumentReviewStatus,
|
||||||
|
} from './entities/booking-document-review.entity';
|
||||||
import { BookingContainer } from './entities/booking-container.entity';
|
import { BookingContainer } from './entities/booking-container.entity';
|
||||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||||
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
||||||
@@ -313,6 +317,82 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
return pending === 0;
|
return pending === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Clearance document reviews ────────────────────────────────────────────
|
||||||
|
|
||||||
|
findDocumentReviews(bookingId: string): Promise<BookingDocumentReview[]> {
|
||||||
|
return this.dataSource.getRepository(BookingDocumentReview).find({
|
||||||
|
where: { bookingId },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findDocumentReview(
|
||||||
|
bookingId: string,
|
||||||
|
settingCode: string,
|
||||||
|
fileKey: string,
|
||||||
|
): Promise<BookingDocumentReview | null> {
|
||||||
|
return this.dataSource.getRepository(BookingDocumentReview).findOne({
|
||||||
|
where: { bookingId, settingCode, fileKey },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a document-review row to PENDING for a freshly uploaded file. Resets
|
||||||
|
* any prior QUERIED/APPROVED state so the GL re-reviews the new upload.
|
||||||
|
*/
|
||||||
|
async upsertDocumentReviewPending(input: {
|
||||||
|
bookingId: string;
|
||||||
|
settingCode: string;
|
||||||
|
fileKey: string;
|
||||||
|
fileRecordId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const repo = this.dataSource.getRepository(BookingDocumentReview);
|
||||||
|
const existing = await repo.findOne({
|
||||||
|
where: {
|
||||||
|
bookingId: input.bookingId,
|
||||||
|
settingCode: input.settingCode,
|
||||||
|
fileKey: input.fileKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
await repo.update(existing.id, {
|
||||||
|
fileRecordId: input.fileRecordId,
|
||||||
|
status: 'PENDING',
|
||||||
|
note: null,
|
||||||
|
reviewedByStaffId: null,
|
||||||
|
reviewedAt: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await repo.save(repo.create({ ...input, status: 'PENDING' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL marks a document APPROVED or QUERIED (with an optional note). */
|
||||||
|
async setDocumentReviewStatus(
|
||||||
|
bookingId: string,
|
||||||
|
settingCode: string,
|
||||||
|
fileKey: string,
|
||||||
|
status: DocumentReviewStatus,
|
||||||
|
staffId: string,
|
||||||
|
note?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const repo = this.dataSource.getRepository(BookingDocumentReview);
|
||||||
|
const existing = await repo.findOne({
|
||||||
|
where: { bookingId, settingCode, fileKey },
|
||||||
|
});
|
||||||
|
const patch = {
|
||||||
|
status,
|
||||||
|
note: note ?? null,
|
||||||
|
reviewedByStaffId: staffId,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (existing) {
|
||||||
|
await repo.update(existing.id, patch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await repo.save(repo.create({ bookingId, settingCode, fileKey, ...patch }));
|
||||||
|
}
|
||||||
|
|
||||||
/** Persist cargo modifiers linked to rate snapshots. */
|
/** Persist cargo modifiers linked to rate snapshots. */
|
||||||
async createCargoModifiers(
|
async createCargoModifiers(
|
||||||
rows: Array<{
|
rows: Array<{
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
clearanceSettingCode,
|
||||||
|
clearanceOutputSettingCode,
|
||||||
|
} from './clearance.util';
|
||||||
|
|
||||||
|
describe('clearance.util — clearanceSettingCode', () => {
|
||||||
|
it('resolves import container with/without customs', () => {
|
||||||
|
expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe(
|
||||||
|
'clearance_import_container_with_customs',
|
||||||
|
);
|
||||||
|
expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe(
|
||||||
|
'clearance_import_container_without_customs',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves export bulk with/without customs', () => {
|
||||||
|
expect(clearanceSettingCode('EXPORT', 'BULK', true)).toBe(
|
||||||
|
'clearance_export_bulk_with_customs',
|
||||||
|
);
|
||||||
|
expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe(
|
||||||
|
'clearance_export_bulk_without_customs',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for DOMESTIC (no clearance gate)', () => {
|
||||||
|
expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull();
|
||||||
|
expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearance.util — clearanceOutputSettingCode', () => {
|
||||||
|
it('returns a container output code only for customs container bookings', () => {
|
||||||
|
expect(clearanceOutputSettingCode('IMPORT', 'CONTAINER', true)).toBe(
|
||||||
|
'clearance_output_import_container',
|
||||||
|
);
|
||||||
|
expect(clearanceOutputSettingCode('EXPORT', 'CONTAINER', true)).toBe(
|
||||||
|
'clearance_output_export_container',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null without customs', () => {
|
||||||
|
expect(clearanceOutputSettingCode('IMPORT', 'CONTAINER', false)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for bulk (no container output set) and domestic', () => {
|
||||||
|
expect(clearanceOutputSettingCode('IMPORT', 'BULK', true)).toBeNull();
|
||||||
|
expect(clearanceOutputSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
70
apps/edr-freight-api/src/modules/bookings/clearance.util.ts
Normal file
70
apps/edr-freight-api/src/modules/bookings/clearance.util.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves which seeded clearance FileUploadSetting applies to a booking, from
|
||||||
|
* its trade direction, freight type and whether its service includes customs.
|
||||||
|
* Mirrors the codes seeded in file-upload-settings.seeder.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Op = 'import' | 'export';
|
||||||
|
type Freight = 'container' | 'bulk';
|
||||||
|
|
||||||
|
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
|
||||||
|
function operationFor(tradeDirection: string): Op | null {
|
||||||
|
if (tradeDirection === 'IMPORT') return 'import';
|
||||||
|
if (tradeDirection === 'EXPORT') return 'export';
|
||||||
|
return null; // DOMESTIC / intercity — no clearance gate
|
||||||
|
}
|
||||||
|
|
||||||
|
function freightFor(freightType: string): Freight {
|
||||||
|
return freightType === 'BULK' ? 'bulk' : 'container';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The customer-input clearance setting code, or null when no gate applies. */
|
||||||
|
export function clearanceSettingCode(
|
||||||
|
tradeDirection: string,
|
||||||
|
freightType: string,
|
||||||
|
includesCustoms: boolean,
|
||||||
|
): string | null {
|
||||||
|
const op = operationFor(tradeDirection);
|
||||||
|
if (!op) return null;
|
||||||
|
const freight = freightFor(freightType);
|
||||||
|
const customs = includesCustoms ? 'with_customs' : 'without_customs';
|
||||||
|
return `clearance_${op}_${freight}_${customs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The GL-output (customs output) setting code; only container customs sets exist. */
|
||||||
|
export function clearanceOutputSettingCode(
|
||||||
|
tradeDirection: string,
|
||||||
|
freightType: string,
|
||||||
|
includesCustoms: boolean,
|
||||||
|
): string | null {
|
||||||
|
if (!includesCustoms) return null;
|
||||||
|
const op = operationFor(tradeDirection);
|
||||||
|
if (!op) return null;
|
||||||
|
// Only container customs output sets are seeded for this phase.
|
||||||
|
if (freightFor(freightType) !== 'container') return null;
|
||||||
|
return `clearance_output_${op}_container`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: resolve both codes for a loaded booking (with its serviceType). */
|
||||||
|
export function clearanceCodesForBooking(booking: Booking): {
|
||||||
|
inputCode: string | null;
|
||||||
|
outputCode: string | null;
|
||||||
|
includesCustoms: boolean;
|
||||||
|
} {
|
||||||
|
const includesCustoms = booking.serviceType?.includesCustoms ?? false;
|
||||||
|
return {
|
||||||
|
inputCode: clearanceSettingCode(
|
||||||
|
booking.tradeDirection,
|
||||||
|
booking.freightType,
|
||||||
|
includesCustoms,
|
||||||
|
),
|
||||||
|
outputCode: clearanceOutputSettingCode(
|
||||||
|
booking.tradeDirection,
|
||||||
|
booking.freightType,
|
||||||
|
includesCustoms,
|
||||||
|
),
|
||||||
|
includesCustoms,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsOptional, IsString, MinLength } from 'class-validator';
|
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
export class RequestChangesDto {
|
export class RequestChangesDto {
|
||||||
@ApiProperty({ description: 'Staff note explaining what the customer must fix' })
|
@ApiProperty({ description: 'Staff note explaining what the customer must fix' })
|
||||||
@@ -43,3 +43,19 @@ export class RejectBookingDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ReviewDocumentDto {
|
||||||
|
@ApiProperty({ description: 'The document fileKey being reviewed' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
fileKey!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['APPROVED', 'QUERIED'] })
|
||||||
|
@IsIn(['APPROVED', 'QUERIED'])
|
||||||
|
status!: 'APPROVED' | 'QUERIED';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Required when querying a document' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
import { Booking } from './booking.entity';
|
||||||
|
|
||||||
|
export const DOCUMENT_REVIEW_STATUSES = ['PENDING', 'APPROVED', 'QUERIED'] as const;
|
||||||
|
export type DocumentReviewStatus = (typeof DOCUMENT_REVIEW_STATUSES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-document GL review for the post-counter-sign clearance gate. One row per
|
||||||
|
* required clearance document (keyed by fileKey within a setting). GL marks each
|
||||||
|
* APPROVED or QUERIED (with a note); the booking can only proceed once every
|
||||||
|
* required customer document is APPROVED. A QUERIED row returns to PENDING when
|
||||||
|
* the customer re-uploads that file.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'booking_document_review' })
|
||||||
|
@Index(['bookingId'])
|
||||||
|
@Index(['status'])
|
||||||
|
@Index(['bookingId', 'settingCode', 'fileKey'], { unique: true })
|
||||||
|
export class BookingDocumentReview extends BaseEntity {
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'booking_id' })
|
||||||
|
booking?: Booking;
|
||||||
|
|
||||||
|
/** The clearance setting this document belongs to (e.g. clearance_import_container_with_customs). */
|
||||||
|
@Column({ name: 'setting_code', type: 'varchar', length: 128 })
|
||||||
|
settingCode!: string;
|
||||||
|
|
||||||
|
/** The required document's stable key within the setting (e.g. commercial_invoice). */
|
||||||
|
@Column({ name: 'file_key', type: 'varchar', length: 128 })
|
||||||
|
fileKey!: string;
|
||||||
|
|
||||||
|
/** The uploaded FileRecord backing this review row (null until uploaded). */
|
||||||
|
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
|
||||||
|
fileRecordId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
|
||||||
|
status!: DocumentReviewStatus;
|
||||||
|
|
||||||
|
/** GL note explaining a QUERIED status. */
|
||||||
|
@Column({ name: 'note', type: 'text', nullable: true })
|
||||||
|
note?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true })
|
||||||
|
reviewedByStaffId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
|
||||||
|
reviewedAt?: Date | null;
|
||||||
|
}
|
||||||
@@ -43,6 +43,11 @@ export const BOOKING_STATUSES = [
|
|||||||
'CONSOLIDATED',
|
'CONSOLIDATED',
|
||||||
'CONTRACT_ACTIVE',
|
'CONTRACT_ACTIVE',
|
||||||
'CONTRACT_CLOSED',
|
'CONTRACT_CLOSED',
|
||||||
|
// Post counter-sign document-clearance gate (GL workflow).
|
||||||
|
'AWAITING_DOCUMENTS',
|
||||||
|
'DOCUMENTS_UNDER_REVIEW',
|
||||||
|
'CLEARANCE_READY',
|
||||||
|
'OPERATION_REQUESTED',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
||||||
|
|||||||
@@ -192,6 +192,169 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [
|
|||||||
const COMPANY_ONBOARDING_DESCRIPTION =
|
const COMPANY_ONBOARDING_DESCRIPTION =
|
||||||
"Required documents for external company onboarding, by company nationality.";
|
"Required documents for external company onboarding, by company nationality.";
|
||||||
|
|
||||||
|
// ── Clearance document settings ────────────────────────────────────────────
|
||||||
|
// Operation/clearance documents collected after contract counter-sign, resolved
|
||||||
|
// at runtime from (operationType, freightType, includesCustoms). The `entity`
|
||||||
|
// is "booking_clearance" so the backoffice file-settings editor can filter them.
|
||||||
|
// Two kinds of set per customs category: a CUSTOMER-INPUT set (the customer
|
||||||
|
// uploads) and a GL-OUTPUT set (Global Logistics uploads the customs outputs).
|
||||||
|
|
||||||
|
const JPG_EXTENSIONS = ["jpg", "jpeg", "png", "pdf"];
|
||||||
|
const CLEARANCE_ENTITY = "booking_clearance";
|
||||||
|
|
||||||
|
/** Build a clearance field with sensible defaults; `critical` marks isRequired. */
|
||||||
|
function clearanceField(
|
||||||
|
fileKey: string,
|
||||||
|
fileLabel: string,
|
||||||
|
displayOrder: number,
|
||||||
|
opts?: { required?: boolean; help?: string; extensions?: string[] },
|
||||||
|
): OnboardingField {
|
||||||
|
return {
|
||||||
|
fileKey,
|
||||||
|
fileLabel,
|
||||||
|
helpText: opts?.help ?? "",
|
||||||
|
isRequired: opts?.required ?? true,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: opts?.extensions ?? DOC_EXTENSIONS,
|
||||||
|
maxSizeMb: 10,
|
||||||
|
displayOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Documents shared by every container import category (with/without customs). */
|
||||||
|
const IMPORT_CONTAINER_FIELDS: OnboardingField[] = [
|
||||||
|
clearanceField("commercial_invoice", "Commercial Invoice", 1),
|
||||||
|
clearanceField("packing_list", "Packing List", 2),
|
||||||
|
clearanceField("import_license", "Import License", 3),
|
||||||
|
clearanceField("certificate_of_origin", "Certificate of Origin", 4),
|
||||||
|
clearanceField(
|
||||||
|
"external_freight_cost",
|
||||||
|
"External Freight Cost / Checkup Documentation",
|
||||||
|
5,
|
||||||
|
),
|
||||||
|
clearanceField("bill_of_lading", "Bill of Lading / Railway Bill", 6),
|
||||||
|
clearanceField("vgm", "Verified Gross Mass (VGM)", 7, { required: true }),
|
||||||
|
clearanceField("release_order", "Release Order", 8, { required: true }),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Documents shared by every container export category (with/without customs). */
|
||||||
|
const EXPORT_CONTAINER_FIELDS: OnboardingField[] = [
|
||||||
|
clearanceField("booking_confirmation", "Booking Confirmation", 1),
|
||||||
|
clearanceField("commercial_invoice", "Commercial Invoice", 2),
|
||||||
|
clearanceField("packing_list", "Packing List", 3),
|
||||||
|
clearanceField("shipping_instruction", "Shipping Instruction", 4),
|
||||||
|
clearanceField("bank_permit", "Bank Permit", 5),
|
||||||
|
clearanceField("export_license", "Export License", 6),
|
||||||
|
clearanceField("vgm_letter", "VGM Letter", 7, { required: true }),
|
||||||
|
clearanceField("railway_bill", "Railway Bill", 8),
|
||||||
|
clearanceField("delegation_letter", "Delegation Letter / POA", 9, {
|
||||||
|
required: false,
|
||||||
|
help: "Required only if EDR manages all transit activity.",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Bulk import documents (shorter, transit-focused set). */
|
||||||
|
const IMPORT_BULK_FIELDS: OnboardingField[] = [
|
||||||
|
clearanceField("packing_list", "Packing List", 1, { required: true }),
|
||||||
|
clearanceField("bill_of_loading", "Bill of Loading", 2, { required: true }),
|
||||||
|
clearanceField("port_invoice", "Port Invoice", 3),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Bulk export documents (transit/customs corridor docs). */
|
||||||
|
const EXPORT_BULK_FIELDS: OnboardingField[] = [
|
||||||
|
clearanceField("release_order_djibouti", "Release Order (Djibouti)", 1),
|
||||||
|
clearanceField("port_gate_pass", "Port Gate Pass", 2),
|
||||||
|
clearanceField("port_invoice", "Port Invoice", 3),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** GL-uploaded customs output documents (import container). */
|
||||||
|
const IMPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [
|
||||||
|
clearanceField("im4", "IM4 — Permanent Import Document", 1),
|
||||||
|
clearanceField("im5", "IM5 — Temporary Import Document", 2, {
|
||||||
|
required: false,
|
||||||
|
}),
|
||||||
|
clearanceField("transit_permitted", "Transit Permitted Screenshot", 3, {
|
||||||
|
extensions: JPG_EXTENSIONS,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** GL-uploaded customs output documents (export container). */
|
||||||
|
const EXPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [
|
||||||
|
clearanceField("ex3", "EX3 — Permanent Export Document", 1),
|
||||||
|
clearanceField("ex8", "EX8 — Export Transit Document", 2),
|
||||||
|
clearanceField("export_release", "Export Release", 3),
|
||||||
|
clearanceField("t1", "T1 — Transport Document", 4),
|
||||||
|
];
|
||||||
|
|
||||||
|
const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
|
||||||
|
// ── Customer-input sets ──
|
||||||
|
{
|
||||||
|
code: "clearance_import_container_with_customs",
|
||||||
|
label: "Import container clearance documents (with customs)",
|
||||||
|
entity: CLEARANCE_ENTITY,
|
||||||
|
fields: IMPORT_CONTAINER_FIELDS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "clearance_import_container_without_customs",
|
||||||
|
label: "Import container documents (without customs)",
|
||||||
|
entity: CLEARANCE_ENTITY,
|
||||||
|
fields: IMPORT_CONTAINER_FIELDS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "clearance_export_container_with_customs",
|
||||||
|
label: "Export container clearance documents (with customs)",
|
||||||
|
entity: CLEARANCE_ENTITY,
|
||||||
|
fields: EXPORT_CONTAINER_FIELDS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "clearance_export_container_without_customs",
|
||||||
|
label: "Export container documents (without customs)",
|
||||||
|
entity: CLEARANCE_ENTITY,
|
||||||
|
fields: EXPORT_CONTAINER_FIELDS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "clearance_import_bulk_with_customs",
|
||||||
|
label: "Import bulk clearance documents (with customs)",
|
||||||
|
entity: CLEARANCE_ENTITY,
|
||||||
|
fields: IMPORT_BULK_FIELDS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "clearance_import_bulk_without_customs",
|
||||||
|
label: "Import bulk documents (without customs)",
|
||||||
|
entity: CLEARANCE_ENTITY,
|
||||||
|
fields: IMPORT_BULK_FIELDS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "clearance_export_bulk_with_customs",
|
||||||
|
label: "Export bulk clearance documents (with customs)",
|
||||||
|
entity: CLEARANCE_ENTITY,
|
||||||
|
fields: EXPORT_BULK_FIELDS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "clearance_export_bulk_without_customs",
|
||||||
|
label: "Export bulk documents (without customs)",
|
||||||
|
entity: CLEARANCE_ENTITY,
|
||||||
|
fields: EXPORT_BULK_FIELDS,
|
||||||
|
},
|
||||||
|
// ── GL-output sets (customs only) ──
|
||||||
|
{
|
||||||
|
code: "clearance_output_import_container",
|
||||||
|
label: "Customs output documents (import container)",
|
||||||
|
entity: CLEARANCE_ENTITY,
|
||||||
|
fields: IMPORT_CONTAINER_OUTPUT_FIELDS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "clearance_output_export_container",
|
||||||
|
label: "Customs output documents (export container)",
|
||||||
|
entity: CLEARANCE_ENTITY,
|
||||||
|
fields: EXPORT_CONTAINER_OUTPUT_FIELDS,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CLEARANCE_DESCRIPTION =
|
||||||
|
"Operation/clearance documents collected after contract execution, by operation, freight type and customs.";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FileUploadSettingsSeeder {
|
export class FileUploadSettingsSeeder {
|
||||||
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
|
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
|
||||||
@@ -203,12 +366,25 @@ export class FileUploadSettingsSeeder {
|
|||||||
const settingRepository = manager.getRepository(FileUploadSetting);
|
const settingRepository = manager.getRepository(FileUploadSetting);
|
||||||
const fieldRepository = manager.getRepository(FileUploadField);
|
const fieldRepository = manager.getRepository(FileUploadField);
|
||||||
|
|
||||||
for (const documentSetting of COMPANY_ONBOARDING_DOCUMENTS) {
|
const allSettings: Array<
|
||||||
|
OnboardingDocumentSetting & { description: string }
|
||||||
|
> = [
|
||||||
|
...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
|
||||||
|
...s,
|
||||||
|
description: COMPANY_ONBOARDING_DESCRIPTION,
|
||||||
|
})),
|
||||||
|
...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
|
||||||
|
...s,
|
||||||
|
description: CLEARANCE_DESCRIPTION,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const documentSetting of allSettings) {
|
||||||
await settingRepository.upsert(
|
await settingRepository.upsert(
|
||||||
{
|
{
|
||||||
code: documentSetting.code,
|
code: documentSetting.code,
|
||||||
label: documentSetting.label,
|
label: documentSetting.label,
|
||||||
description: COMPANY_ONBOARDING_DESCRIPTION,
|
description: documentSetting.description,
|
||||||
entity: documentSetting.entity,
|
entity: documentSetting.entity,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -245,7 +421,7 @@ export class FileUploadSettingsSeeder {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
"Ensured company onboarding file upload settings for external companies",
|
"Ensured company onboarding + booking clearance file upload settings",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'),
|
perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'),
|
||||||
perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'),
|
perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'),
|
||||||
perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'),
|
perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'),
|
||||||
|
perm('a1000001-0001-4000-8000-000000000020', 'edr_freight_app:bookings:review_documents', 'Review clearance documents'),
|
||||||
|
perm('a1000001-0001-4000-8000-000000000021', 'edr_freight_app:bookings:upload_clearance_output', 'Upload customs output documents'),
|
||||||
|
perm('a1000001-0001-4000-8000-000000000022', 'edr_freight_app:bookings:finalize_clearance', 'Finalize document clearance'),
|
||||||
perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'),
|
perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'),
|
||||||
perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'),
|
perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'),
|
||||||
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
|
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
|
||||||
@@ -107,6 +110,9 @@ export const FREIGHT_PERMS = {
|
|||||||
signStaff: 'edr_freight_app:bookings:sign_staff',
|
signStaff: 'edr_freight_app:bookings:sign_staff',
|
||||||
operations: 'edr_freight_app:bookings:operations',
|
operations: 'edr_freight_app:bookings:operations',
|
||||||
cancel: 'edr_freight_app:bookings:cancel',
|
cancel: 'edr_freight_app:bookings:cancel',
|
||||||
|
reviewDocuments: 'edr_freight_app:bookings:review_documents',
|
||||||
|
uploadClearanceOutput: 'edr_freight_app:bookings:upload_clearance_output',
|
||||||
|
finalizeClearance: 'edr_freight_app:bookings:finalize_clearance',
|
||||||
},
|
},
|
||||||
trainScheduling: {
|
trainScheduling: {
|
||||||
view: 'edr_freight_app:train_scheduling:view',
|
view: 'edr_freight_app:train_scheduling:view',
|
||||||
@@ -167,6 +173,14 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
...allRuleEngineViewKeys(),
|
...allRuleEngineViewKeys(),
|
||||||
],
|
],
|
||||||
finance: [FREIGHT_PERMS.bookings.view],
|
finance: [FREIGHT_PERMS.bookings.view],
|
||||||
|
// Global Logistics: reviews post-counter-sign clearance documents, uploads
|
||||||
|
// customs output documents, and finalizes the clearance gate.
|
||||||
|
globalLogistics: [
|
||||||
|
FREIGHT_PERMS.bookings.view,
|
||||||
|
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||||
|
FREIGHT_PERMS.bookings.uploadClearanceOutput,
|
||||||
|
FREIGHT_PERMS.bookings.finalizeClearance,
|
||||||
|
],
|
||||||
// Marketing handles intake through contract (same as line staff here).
|
// Marketing handles intake through contract (same as line staff here).
|
||||||
marketing: [
|
marketing: [
|
||||||
FREIGHT_PERMS.bookings.view,
|
FREIGHT_PERMS.bookings.view,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Paperclip,
|
Paperclip,
|
||||||
Send,
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Train,
|
Train,
|
||||||
Truck,
|
Truck,
|
||||||
@@ -27,6 +28,7 @@ import LoginPage from "./pages/auth/LoginPage";
|
|||||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||||
|
import GlClearancePage from "./pages/bookings/GlClearancePage";
|
||||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||||
import CustomersPage from "./pages/customers/CustomersPage";
|
import CustomersPage from "./pages/customers/CustomersPage";
|
||||||
@@ -109,6 +111,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
{
|
{
|
||||||
title: "Operations",
|
title: "Operations",
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
label: "Document Clearance",
|
||||||
|
href: "/dashboard/clearance",
|
||||||
|
icon: <ShieldCheck />,
|
||||||
|
permission: FREIGHT_PERMS.bookings.reviewDocuments,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Train Schedules",
|
label: "Train Schedules",
|
||||||
href: "/dashboard/operations/train-scheduling-v2",
|
href: "/dashboard/operations/train-scheduling-v2",
|
||||||
@@ -376,6 +384,14 @@ const App = () => {
|
|||||||
path="booking-requests/:id/contract"
|
path="booking-requests/:id/contract"
|
||||||
element={<BookingContractPage />}
|
element={<BookingContractPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="clearance"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.bookings.reviewDocuments}>
|
||||||
|
<GlClearancePage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export const FREIGHT_PERMS = {
|
|||||||
signStaff: "edr_freight_app:bookings:sign_staff",
|
signStaff: "edr_freight_app:bookings:sign_staff",
|
||||||
operations: "edr_freight_app:bookings:operations",
|
operations: "edr_freight_app:bookings:operations",
|
||||||
cancel: "edr_freight_app:bookings:cancel",
|
cancel: "edr_freight_app:bookings:cancel",
|
||||||
|
reviewDocuments: "edr_freight_app:bookings:review_documents",
|
||||||
|
uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output",
|
||||||
|
finalizeClearance: "edr_freight_app:bookings:finalize_clearance",
|
||||||
},
|
},
|
||||||
trainScheduling: {
|
trainScheduling: {
|
||||||
view: "edr_freight_app:train_scheduling:view",
|
view: "edr_freight_app:train_scheduling:view",
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
FileButton,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
ShieldCheck,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
|
||||||
|
const REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW";
|
||||||
|
|
||||||
|
export default function GlClearancePage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Bookings currently awaiting GL document review.
|
||||||
|
const { data: list, isLoading } = useQuery({
|
||||||
|
queryKey: ["gl-clearance", "list"],
|
||||||
|
queryFn: () => bookingsService.list({ status: REVIEW_STATUS, pageSize: 100 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookings = list?.items ?? [];
|
||||||
|
const activeId = selectedId ?? bookings[0]?.id ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box p="lg">
|
||||||
|
<Group gap={10} mb="lg">
|
||||||
|
<ShieldCheck size={22} color="#0A6F4D" />
|
||||||
|
<Text fw={800} fz="22px" c="#10202F">
|
||||||
|
Document Clearance
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||||
|
<Card withBorder radius="md" p="sm" style={{ width: 300, flexShrink: 0 }}>
|
||||||
|
<Text fz="13px" fw={700} c="#10202F" mb="xs">
|
||||||
|
Awaiting review ({bookings.length})
|
||||||
|
</Text>
|
||||||
|
{isLoading && (
|
||||||
|
<Text fz="13px" c="dimmed">
|
||||||
|
Loading…
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!isLoading && bookings.length === 0 && (
|
||||||
|
<Text fz="13px" c="dimmed">
|
||||||
|
No bookings awaiting document review.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Stack gap={6}>
|
||||||
|
{bookings.map((b) => (
|
||||||
|
<button
|
||||||
|
key={b.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(b.id)}
|
||||||
|
style={{
|
||||||
|
textAlign: "left",
|
||||||
|
border: `1px solid ${b.id === activeId ? "#0A6F4D" : "#E6ECF2"}`,
|
||||||
|
background: b.id === activeId ? "#F4FBF7" : "#fff",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "8px 10px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text fz="13px" fw={600} c="#10202F">
|
||||||
|
{b.reference}
|
||||||
|
</Text>
|
||||||
|
<Text fz="11.5px" c="dimmed">
|
||||||
|
{b.tradeDirection} · {b.freightType}
|
||||||
|
</Text>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{activeId ? (
|
||||||
|
<ClearanceReviewPanel
|
||||||
|
bookingId={activeId}
|
||||||
|
onChanged={() =>
|
||||||
|
qc.invalidateQueries({ queryKey: ["gl-clearance", "list"] })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card withBorder radius="md" p="xl">
|
||||||
|
<Text c="dimmed">Select a booking to review its documents.</Text>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClearanceReviewPanel({
|
||||||
|
bookingId,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
bookingId: string;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||||
|
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({});
|
||||||
|
|
||||||
|
const { data: clearance, isLoading } = useQuery({
|
||||||
|
queryKey: ["gl-clearance", bookingId],
|
||||||
|
queryFn: () => bookingsService.getClearance(bookingId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["gl-clearance", bookingId] });
|
||||||
|
onChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
const reviewMutation = useMutation({
|
||||||
|
mutationFn: (p: {
|
||||||
|
fileKey: string;
|
||||||
|
status: "APPROVED" | "QUERIED";
|
||||||
|
note?: string;
|
||||||
|
}) => bookingsService.reviewClearanceDocument(bookingId, p),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Document updated");
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Could not update document"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputMutation = useMutation({
|
||||||
|
mutationFn: () => bookingsService.uploadClearanceOutput(bookingId, outputFiles),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Output documents uploaded");
|
||||||
|
setOutputFiles({});
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Upload failed"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalizeMutation = useMutation({
|
||||||
|
mutationFn: () => bookingsService.finalizeClearance(bookingId),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Clearance finalized");
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
onError: (e) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : "Could not finalize clearance"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerDocs = useMemo(
|
||||||
|
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
|
||||||
|
[clearance],
|
||||||
|
);
|
||||||
|
const glDocs = useMemo(
|
||||||
|
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
|
||||||
|
[clearance],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading || !clearance) {
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="md" p="xl">
|
||||||
|
<Text c="dimmed">Loading clearance…</Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Card withBorder radius="md" p="lg">
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Text fw={700} c="#10202F">
|
||||||
|
Customer documents
|
||||||
|
</Text>
|
||||||
|
{clearance.allApproved ? (
|
||||||
|
<Group gap={6} c="#0A6F4D">
|
||||||
|
<CheckCircle2 size={16} />
|
||||||
|
<Text fz="12.5px" fw={600} c="#0A6F4D">
|
||||||
|
All approved
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Group gap={6} c="#2E5B96">
|
||||||
|
<Clock size={16} />
|
||||||
|
<Text fz="12.5px" fw={600} c="#2E5B96">
|
||||||
|
Review pending
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Stack gap={12}>
|
||||||
|
{customerDocs.map((doc) => (
|
||||||
|
<DocReviewRow
|
||||||
|
key={`${doc.settingCode}:${doc.fileKey}`}
|
||||||
|
doc={doc}
|
||||||
|
note={queryNotes[doc.fileKey] ?? ""}
|
||||||
|
onNote={(v) =>
|
||||||
|
setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))
|
||||||
|
}
|
||||||
|
onApprove={() =>
|
||||||
|
reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" })
|
||||||
|
}
|
||||||
|
onQuery={() =>
|
||||||
|
reviewMutation.mutate({
|
||||||
|
fileKey: doc.fileKey,
|
||||||
|
status: "QUERIED",
|
||||||
|
note: queryNotes[doc.fileKey],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
busy={reviewMutation.isPending}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{clearance.outputCode && (
|
||||||
|
<Card withBorder radius="md" p="lg">
|
||||||
|
<Text fw={700} c="#10202F" mb="md">
|
||||||
|
Customs output documents
|
||||||
|
</Text>
|
||||||
|
<Stack gap={10}>
|
||||||
|
{glDocs.map((doc) => (
|
||||||
|
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<FileText size={16} color="#2E5B96" />
|
||||||
|
<Text fz="13px" c="#10202F" truncate>
|
||||||
|
{doc.label}
|
||||||
|
{doc.required ? " *" : ""}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
{doc.file ? (
|
||||||
|
<a href={doc.file.url} target="_blank" rel="noreferrer">
|
||||||
|
<Download size={15} />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Text fz="12px" c="#9AA8B5">
|
||||||
|
Not uploaded
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<FileButton
|
||||||
|
onChange={(f) =>
|
||||||
|
f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f }))
|
||||||
|
}
|
||||||
|
accept="application/pdf,image/*"
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Upload size={13} />}
|
||||||
|
>
|
||||||
|
{outputFiles[doc.fileKey] ? "Selected" : "Upload"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Upload size={15} />}
|
||||||
|
disabled={Object.keys(outputFiles).length === 0}
|
||||||
|
loading={outputMutation.isPending}
|
||||||
|
onClick={() => outputMutation.mutate()}
|
||||||
|
>
|
||||||
|
Upload output documents
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{finalizeMutation.isError && (
|
||||||
|
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||||
|
{finalizeMutation.error instanceof Error
|
||||||
|
? finalizeMutation.error.message
|
||||||
|
: "Could not finalize clearance."}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
|
disabled={!clearance.allApproved}
|
||||||
|
loading={finalizeMutation.isPending}
|
||||||
|
onClick={() => finalizeMutation.mutate()}
|
||||||
|
>
|
||||||
|
Finalize clearance
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocReviewRow({
|
||||||
|
doc,
|
||||||
|
note,
|
||||||
|
onNote,
|
||||||
|
onApprove,
|
||||||
|
onQuery,
|
||||||
|
busy,
|
||||||
|
}: {
|
||||||
|
doc: Freight.ClearanceDocument;
|
||||||
|
note: string;
|
||||||
|
onNote: (v: string) => void;
|
||||||
|
onApprove: () => void;
|
||||||
|
onQuery: () => void;
|
||||||
|
busy: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box className="rounded-xl" style={{ border: "1px solid #E6ECF2", padding: 12 }}>
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<FileText size={18} color="#2E5B96" />
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text fz="13.5px" fw={600} c="#10202F" truncate>
|
||||||
|
{doc.label}
|
||||||
|
{doc.required ? " *" : ""}
|
||||||
|
</Text>
|
||||||
|
<Text fz="12px" c="dimmed" truncate>
|
||||||
|
{doc.file ? doc.file.name : "Not uploaded"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
{doc.reviewStatus === "APPROVED" && (
|
||||||
|
<Text fz="12px" fw={600} c="#0A6F4D">
|
||||||
|
Approved
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{doc.reviewStatus === "QUERIED" && (
|
||||||
|
<Text fz="12px" fw={600} c="#C0392B">
|
||||||
|
Queried
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{doc.file && (
|
||||||
|
<a href={doc.file.url} target="_blank" rel="noreferrer">
|
||||||
|
<Download size={15} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{doc.file && (
|
||||||
|
<Group gap={8} mt={10} align="flex-end" wrap="nowrap">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Query note (required to query)"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => onNote(e.currentTarget.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
radius="md"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
disabled={busy || !note.trim()}
|
||||||
|
onClick={onQuery}
|
||||||
|
>
|
||||||
|
Query
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="edr-green"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onApprove}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { api as client } from "../auth/http";
|
|||||||
import { unwrap } from "@/utils/endpoint";
|
import { unwrap } from "@/utils/endpoint";
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
const B = URL_CONSTANTS.BOOKINGS;
|
const B = URL_CONSTANTS.BOOKINGS;
|
||||||
|
|
||||||
@@ -169,6 +170,36 @@ export const bookingsService = {
|
|||||||
await client.delete(B.BY_ID(id));
|
await client.delete(B.BY_ID(id));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Document clearance (GL workflow) ──
|
||||||
|
getClearance: async (id: string): Promise<Freight.ClearanceView> => {
|
||||||
|
const response = await client.get(`/bookings/${id}/clearance`);
|
||||||
|
return unwrap(response.data) as Freight.ClearanceView;
|
||||||
|
},
|
||||||
|
|
||||||
|
reviewClearanceDocument: (
|
||||||
|
id: string,
|
||||||
|
payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },
|
||||||
|
) => postBooking<BookingDetail>(`/bookings/${id}/clearance/review`, payload),
|
||||||
|
|
||||||
|
uploadClearanceOutput: 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(
|
||||||
|
`/bookings/${id}/clearance/output-documents`,
|
||||||
|
form,
|
||||||
|
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||||
|
);
|
||||||
|
return unwrap(response.data) as BookingDetail;
|
||||||
|
},
|
||||||
|
|
||||||
|
finalizeClearance: (id: string) =>
|
||||||
|
postBooking<BookingDetail>(`/bookings/${id}/clearance/finalize`),
|
||||||
|
|
||||||
staffAccept: (id: string) => postBooking<BookingDetail>(B.STAFF_ACCEPT(id)),
|
staffAccept: (id: string) => postBooking<BookingDetail>(B.STAFF_ACCEPT(id)),
|
||||||
|
|
||||||
requestChanges: (id: string, note: string) =>
|
requestChanges: (id: string, note: string) =>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { paymentsService, type PaymentMethod } from "@/services/payments.service
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { ActivityCard } from "./components/ActivityCard";
|
import { ActivityCard } from "./components/ActivityCard";
|
||||||
|
import { ClearanceCard } from "./components/ClearanceCard";
|
||||||
import { ContainersCard } from "./components/ContainersCard";
|
import { ContainersCard } from "./components/ContainersCard";
|
||||||
import { ContractCard } from "./components/ContractCard";
|
import { ContractCard } from "./components/ContractCard";
|
||||||
import { DocRow, IconSquare } from "./components/Documents";
|
import { DocRow, IconSquare } from "./components/Documents";
|
||||||
@@ -62,6 +63,12 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||||
const isExpired = status === "EXPIRED";
|
const isExpired = status === "EXPIRED";
|
||||||
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||||
|
const isClearance = [
|
||||||
|
"AWAITING_DOCUMENTS",
|
||||||
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
|
"CLEARANCE_READY",
|
||||||
|
"OPERATION_REQUESTED",
|
||||||
|
].includes(status);
|
||||||
// Paired: a consolidation partner was found and the booking resumed the normal
|
// Paired: a consolidation partner was found and the booking resumed the normal
|
||||||
// flow. Surface the "partner found" reassurance only in the early stages,
|
// flow. Surface the "partner found" reassurance only in the early stages,
|
||||||
// before approval, so it doesn't linger for the rest of the booking's life.
|
// before approval, so it doesn't linger for the rest of the booking's life.
|
||||||
@@ -126,6 +133,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
|
|
||||||
<ContractCard booking={booking} navigate={navigate} />
|
<ContractCard booking={booking} navigate={navigate} />
|
||||||
|
|
||||||
|
{isClearance && <ClearanceCard booking={booking} />}
|
||||||
|
|
||||||
<BodyGrid
|
<BodyGrid
|
||||||
left={
|
left={
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
FileButton,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
Plus,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
import { IconSquare } from "./Documents";
|
||||||
|
|
||||||
|
const GREEN = "#0A6F4D";
|
||||||
|
|
||||||
|
function StatusPill({ doc }: { doc: Freight.ClearanceDocument }) {
|
||||||
|
if (doc.reviewStatus === "APPROVED") {
|
||||||
|
return (
|
||||||
|
<Group gap={6} c={GREEN}>
|
||||||
|
<CheckCircle2 size={15} />
|
||||||
|
<Text fz="12px" fw={600} c={GREEN}>
|
||||||
|
Approved
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (doc.reviewStatus === "QUERIED") {
|
||||||
|
return (
|
||||||
|
<Group gap={6} c="#C0392B">
|
||||||
|
<AlertCircle size={15} />
|
||||||
|
<Text fz="12px" fw={600} c="#C0392B">
|
||||||
|
Queried
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (doc.file) {
|
||||||
|
return (
|
||||||
|
<Group gap={6} c="#2E5B96">
|
||||||
|
<Clock size={15} />
|
||||||
|
<Text fz="12px" fw={600} c="#2E5B96">
|
||||||
|
Pending review
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text fz="12px" fw={600} c="#9AA8B5">
|
||||||
|
Not uploaded
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer-facing clearance section: shows the resolved document grid, lets the
|
||||||
|
* customer (re)upload pending/queried documents plus ad-hoc named documents, and
|
||||||
|
* proceed to operation once Global Logistics marks the booking CLEARANCE_READY.
|
||||||
|
*/
|
||||||
|
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const status = booking.status as string;
|
||||||
|
|
||||||
|
const { data: clearance, isLoading } = useQuery(
|
||||||
|
api.bookings.getClearance.queryOptions({ input: { id: booking.id } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pending uploads keyed by fileKey, plus ad-hoc rows (label + file).
|
||||||
|
const [pending, setPending] = useState<Record<string, File>>({});
|
||||||
|
const [adHoc, setAdHoc] = useState<Array<{ name: string; file: File | null }>>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.bookings.getClearance.queryKey({ id: booking.id }),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.bookings.get.queryKey({ id: booking.id }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
...api.bookings.submitClearanceDocuments.mutationOptions(),
|
||||||
|
onSuccess: () => {
|
||||||
|
setPending({});
|
||||||
|
setAdHoc([]);
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const proceedMutation = useMutation({
|
||||||
|
...api.bookings.proceedToOperation.mutationOptions(),
|
||||||
|
onSuccess: () => refresh(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only the customer-input documents are uploadable here; GL output docs are
|
||||||
|
// shown read-only.
|
||||||
|
const customerDocs = useMemo(
|
||||||
|
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
|
||||||
|
[clearance],
|
||||||
|
);
|
||||||
|
const glDocs = useMemo(
|
||||||
|
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
|
||||||
|
[clearance],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (status === "OPERATION_REQUESTED") {
|
||||||
|
return (
|
||||||
|
<SectionCard>
|
||||||
|
<CardTitle>Operation</CardTitle>
|
||||||
|
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mt="sm">
|
||||||
|
Operation requested. An operator will take your shipment forward.
|
||||||
|
</Alert>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !clearance) {
|
||||||
|
return (
|
||||||
|
<SectionCard>
|
||||||
|
<CardTitle>Clearance documents</CardTitle>
|
||||||
|
<Text fz="13px" c="dimmed" mt="sm">
|
||||||
|
Loading clearance…
|
||||||
|
</Text>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isReady = status === "CLEARANCE_READY";
|
||||||
|
const canUpload =
|
||||||
|
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
const files: Record<string, File | null> = { ...pending };
|
||||||
|
adHoc.forEach((row, i) => {
|
||||||
|
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
|
||||||
|
});
|
||||||
|
if (Object.keys(files).length === 0) return;
|
||||||
|
uploadMutation.mutate({ id: booking.id, files });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard>
|
||||||
|
<Group justify="space-between" align="center" mb="md">
|
||||||
|
<CardTitle>Clearance documents</CardTitle>
|
||||||
|
{clearance.includesCustoms && (
|
||||||
|
<Text fz="12px" fw={600} c="#9AA8B5">
|
||||||
|
Customs clearance
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{isReady ? (
|
||||||
|
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
|
||||||
|
Clearance is ready. You can now proceed to operation.
|
||||||
|
</Alert>
|
||||||
|
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
||||||
|
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
|
||||||
|
Global Logistics is reviewing your documents. Queried documents below
|
||||||
|
need to be re-uploaded.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
|
||||||
|
Upload the documents below to start the clearance review.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack gap={10}>
|
||||||
|
{customerDocs.map((doc) => (
|
||||||
|
<Box
|
||||||
|
key={doc.fileKey}
|
||||||
|
className="rounded-xl"
|
||||||
|
style={{ border: "1px solid #E6ECF2", padding: 12 }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<Box c="#2E5B96">
|
||||||
|
<FileText size={18} />
|
||||||
|
</Box>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text fz="13.5px" fw={600} c="#10202F" truncate>
|
||||||
|
{doc.label}
|
||||||
|
{doc.required ? " *" : ""}
|
||||||
|
</Text>
|
||||||
|
{doc.file && (
|
||||||
|
<Text fz="12px" c="dimmed" truncate>
|
||||||
|
{doc.file.name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Group gap={10} wrap="nowrap">
|
||||||
|
<StatusPill doc={doc} />
|
||||||
|
{doc.file && (
|
||||||
|
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
|
||||||
|
)}
|
||||||
|
{canUpload && doc.reviewStatus !== "APPROVED" && (
|
||||||
|
<FileButton
|
||||||
|
onChange={(f) =>
|
||||||
|
f && setPending((p) => ({ ...p, [doc.fileKey]: f }))
|
||||||
|
}
|
||||||
|
accept="application/pdf,image/*"
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Upload size={13} />}
|
||||||
|
>
|
||||||
|
{pending[doc.fileKey] ? "Selected" : "Upload"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
{doc.reviewStatus === "QUERIED" && doc.note && (
|
||||||
|
<Text fz="12px" c="#C0392B" mt={6}>
|
||||||
|
Query: {doc.note}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{pending[doc.fileKey] && (
|
||||||
|
<Text fz="12px" c={GREEN} mt={6}>
|
||||||
|
Ready to upload: {pending[doc.fileKey].name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* GL output documents (read-only to the customer). */}
|
||||||
|
{glDocs.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
|
||||||
|
Customs output documents
|
||||||
|
</Text>
|
||||||
|
<Stack gap={8}>
|
||||||
|
{glDocs.map((doc) => (
|
||||||
|
<Group
|
||||||
|
key={doc.fileKey}
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
className="rounded-xl"
|
||||||
|
style={{ border: "1px solid #E6ECF2", padding: 10 }}
|
||||||
|
>
|
||||||
|
<Text fz="13px" c="#10202F" truncate>
|
||||||
|
{doc.label}
|
||||||
|
</Text>
|
||||||
|
{doc.file ? (
|
||||||
|
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
|
||||||
|
) : (
|
||||||
|
<Text fz="12px" c="#9AA8B5">
|
||||||
|
Pending
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ad-hoc / additional documents. */}
|
||||||
|
{canUpload && (
|
||||||
|
<Box mt="lg">
|
||||||
|
<Group justify="space-between" align="center" mb={8}>
|
||||||
|
<Text fz="12.5px" fw={700} c="#10202F">
|
||||||
|
Additional documents
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Plus size={13} />}
|
||||||
|
onClick={() => setAdHoc((r) => [...r, { name: "", file: null }])}
|
||||||
|
>
|
||||||
|
Add document
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Stack gap={8}>
|
||||||
|
{adHoc.map((row, i) => (
|
||||||
|
<Group key={i} gap={8} wrap="nowrap">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Document name"
|
||||||
|
value={row.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAdHoc((rows) =>
|
||||||
|
rows.map((r, j) =>
|
||||||
|
j === i ? { ...r, name: e.currentTarget.value } : r,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<FileButton
|
||||||
|
onChange={(f) =>
|
||||||
|
setAdHoc((rows) =>
|
||||||
|
rows.map((r, j) => (j === i ? { ...r, file: f } : r)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
accept="application/pdf,image/*"
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<Button {...props} variant="default" radius="md">
|
||||||
|
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadMutation.isError && (
|
||||||
|
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
|
||||||
|
{uploadMutation.error instanceof Error
|
||||||
|
? uploadMutation.error.message
|
||||||
|
: "Upload failed. Please try again."}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="lg" gap="sm">
|
||||||
|
{canUpload && (
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Upload size={16} />}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
loading={uploadMutation.isPending}
|
||||||
|
disabled={
|
||||||
|
Object.keys(pending).length === 0 &&
|
||||||
|
!adHoc.some((r) => r.file)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Submit documents
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isReady && (
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
|
onClick={() =>
|
||||||
|
proceedMutation.mutate(
|
||||||
|
{ id: booking.id },
|
||||||
|
{ onSuccess: () => navigate(`/bookings/${booking.id}`) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loading={proceedMutation.isPending}
|
||||||
|
>
|
||||||
|
Proceed to operation
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -256,6 +256,25 @@ export const api = {
|
|||||||
bookingsService.uploadDocuments(id, files),
|
bookingsService.uploadDocuments(id, files),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
getClearance: endpoint<{ id: string }, Freight.ClearanceView>(
|
||||||
|
"bookings",
|
||||||
|
"getClearance",
|
||||||
|
({ id }) => bookingsService.getClearance(id),
|
||||||
|
),
|
||||||
|
|
||||||
|
submitClearanceDocuments: endpoint<
|
||||||
|
{ id: string; files: Record<string, File | null> },
|
||||||
|
Freight.IBooking
|
||||||
|
>("bookings", "submitClearanceDocuments", ({ id, files }) =>
|
||||||
|
bookingsService.submitClearanceDocuments(id, files),
|
||||||
|
),
|
||||||
|
|
||||||
|
proceedToOperation: endpoint<{ id: string }, Freight.IBooking>(
|
||||||
|
"bookings",
|
||||||
|
"proceedToOperation",
|
||||||
|
({ id }) => bookingsService.proceedToOperation(id),
|
||||||
|
),
|
||||||
|
|
||||||
checkPayment: endpoint<{ orderId: string }, { status: string }>(
|
checkPayment: endpoint<{ orderId: string }, { status: string }>(
|
||||||
"bookings",
|
"bookings",
|
||||||
"checkPayment",
|
"checkPayment",
|
||||||
|
|||||||
@@ -184,6 +184,33 @@ export const bookingsService = {
|
|||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Document clearance ──
|
||||||
|
getClearance: async (id: string): Promise<Freight.ClearanceView> => {
|
||||||
|
const { data } = await client.get(`/api/bookings/${id}/clearance`);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
submitClearanceDocuments: async (
|
||||||
|
id: string,
|
||||||
|
files: Record<string, File | null>,
|
||||||
|
): Promise<Freight.IBooking> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const [key, file] of Object.entries(files)) {
|
||||||
|
if (file) formData.append(key, file);
|
||||||
|
}
|
||||||
|
const { data } = await client.post(
|
||||||
|
`/api/bookings/${id}/clearance/documents`,
|
||||||
|
formData,
|
||||||
|
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||||
|
);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
proceedToOperation: async (id: string): Promise<Freight.IBooking> => {
|
||||||
|
const { data } = await client.post(`/api/bookings/${id}/clearance/proceed`);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
getContractView: async (id: string): Promise<ContractView> => {
|
getContractView: async (id: string): Promise<ContractView> => {
|
||||||
const { data } = await client.get(B.CONTRACT_VIEW(id));
|
const { data } = await client.get(B.CONTRACT_VIEW(id));
|
||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
|
|||||||
@@ -448,6 +448,34 @@ export interface PricingBreakdown {
|
|||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Document clearance (post counter-sign GL workflow) ──────────────────────
|
||||||
|
|
||||||
|
export type DocumentReviewStatus = "PENDING" | "APPROVED" | "QUERIED";
|
||||||
|
|
||||||
|
/** One row of the clearance document grid (a required doc + its file + review). */
|
||||||
|
export interface ClearanceDocument {
|
||||||
|
fileKey: string;
|
||||||
|
label: string;
|
||||||
|
required: boolean;
|
||||||
|
/** Who supplies this document: the customer, or Global Logistics staff. */
|
||||||
|
uploadedBy: "customer" | "gl";
|
||||||
|
settingCode: string;
|
||||||
|
file: { id: string; name: string; url: string } | null;
|
||||||
|
reviewStatus: DocumentReviewStatus | null;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The clearance view for a booking, driving both portals' clearance UI. */
|
||||||
|
export interface ClearanceView {
|
||||||
|
status: string;
|
||||||
|
includesCustoms: boolean;
|
||||||
|
inputCode: string | null;
|
||||||
|
outputCode: string | null;
|
||||||
|
documents: ClearanceDocument[];
|
||||||
|
/** True once every required customer document is APPROVED (the 100% gate). */
|
||||||
|
allApproved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IInvoice extends BaseEntity {
|
export interface IInvoice extends BaseEntity {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user