mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Implement comprehensive Global Logistics workflow for Unimodal Export and Import processes, including contract verification, documentation submission, compliance management, customs clearance, payment settlement, and last-mile transport handling.
This commit is contained in:
@@ -235,13 +235,55 @@ export class ContractClearanceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GL ET reviews a single document: APPROVED or QUERIED (→ back to upload). */
|
/**
|
||||||
|
* Path A (no customs) — the customer clears the cargo himself and uploads his
|
||||||
|
* own clearance proof, reviewed by Operations rather than GL. True when a
|
||||||
|
* clearance doc set resolves for a non-customs contract.
|
||||||
|
*/
|
||||||
|
private isSelfClear(contract: Contract): boolean {
|
||||||
|
if (contract.customsClearingEnabled) return false;
|
||||||
|
return contractClearanceCodes(contract).inputCode != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GL ET (Path B) reviews a single document: APPROVED or QUERIED. */
|
||||||
async reviewDocument(
|
async reviewDocument(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
fileKey: string,
|
fileKey: string,
|
||||||
status: 'APPROVED' | 'QUERIED',
|
status: 'APPROVED' | 'QUERIED',
|
||||||
staffId: string,
|
staffId: string,
|
||||||
note?: string,
|
note?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
return this.applyReview(contractId, fileKey, status, staffId, 'GL_ET', note);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations (Path A) reviews a customer self-clearance document. Identical
|
||||||
|
* approve/query loop to {@link reviewDocument}; rejects customs (Path B)
|
||||||
|
* contracts, which are GL-reviewed.
|
||||||
|
*/
|
||||||
|
async opsReviewDocument(
|
||||||
|
contractId: string,
|
||||||
|
fileKey: string,
|
||||||
|
status: 'APPROVED' | 'QUERIED',
|
||||||
|
staffId: string,
|
||||||
|
note?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
if (!this.isSelfClear(contract)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Operations review applies only to self-clearance (non-customs) contracts.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyReview(
|
||||||
|
contractId: string,
|
||||||
|
fileKey: string,
|
||||||
|
status: 'APPROVED' | 'QUERIED',
|
||||||
|
staffId: string,
|
||||||
|
reviewerRole: 'GL_ET' | 'OPERATIONS',
|
||||||
|
note?: string,
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||||
@@ -280,7 +322,7 @@ export class ContractClearanceService {
|
|||||||
`Document "${fileKey}" queried: ${note}`,
|
`Document "${fileKey}" queried: ${note}`,
|
||||||
'CHANGES_REQUESTED',
|
'CHANGES_REQUESTED',
|
||||||
staffId,
|
staffId,
|
||||||
'GL_ET',
|
reviewerRole,
|
||||||
);
|
);
|
||||||
// Return the contract to the customer to re-upload the queried document.
|
// Return the contract to the customer to re-upload the queried document.
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
@@ -325,11 +367,17 @@ export class ContractClearanceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GL ET finalizes pre-booking clearance: requires every customer document
|
* GL ET finalizes Path B pre-booking clearance: requires every customer
|
||||||
* APPROVED (and required output docs present) → CLEARANCE_READY_FOR_BOOKING.
|
* document APPROVED and required output docs present → CLEARANCE_READY_FOR_BOOKING
|
||||||
|
* (GL then creates the booking). Rejects self-clearance (Path A) contracts.
|
||||||
*/
|
*/
|
||||||
async finalize(contractId: string): Promise<Contract> {
|
async finalize(contractId: string): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
if (this.isSelfClear(contract)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Self-clearance (Path A) contracts are finalized by Operations, not GL.',
|
||||||
|
);
|
||||||
|
}
|
||||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Cannot finalize clearance on status "${contract.status}".`,
|
`Cannot finalize clearance on status "${contract.status}".`,
|
||||||
@@ -376,8 +424,46 @@ export class ContractClearanceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GL ET queue: contracts awaiting pre-booking document review. Scoped to
|
* Operations finalizes Path A self-clearance: requires every customer document
|
||||||
* CLEARANCE_UNDER_REVIEW (customs contracts only).
|
* APPROVED, then the contract becomes bookable BY THE CUSTOMER. There is no GL
|
||||||
|
* output phase on Path A, so the contract goes straight to FULLY_EXECUTED
|
||||||
|
* (ONE_TIME) / CONTRACT_ACTIVE (GENERAL).
|
||||||
|
*/
|
||||||
|
async opsFinalize(contractId: string): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
if (!this.isSelfClear(contract)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Operations finalize applies only to self-clearance (non-customs) contracts.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Cannot finalize clearance on status "${contract.status}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const approved = await this.isClearanceFullyApproved(contract);
|
||||||
|
if (!approved) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'All required documents must be approved before clearance can be finalized',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED',
|
||||||
|
clearanceStatus: 'SELF_CLEARED',
|
||||||
|
} as never);
|
||||||
|
if (cycle) {
|
||||||
|
await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', {
|
||||||
|
clearanceReadyAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL ET queue: customs (Path B) contracts awaiting pre-booking document review.
|
||||||
*/
|
*/
|
||||||
async queue(
|
async queue(
|
||||||
filter: FilterContractDto,
|
filter: FilterContractDto,
|
||||||
@@ -388,6 +474,22 @@ export class ContractClearanceService {
|
|||||||
page: filter.page ?? 1,
|
page: filter.page ?? 1,
|
||||||
pageSize: filter.pageSize ?? 100,
|
pageSize: filter.pageSize ?? 100,
|
||||||
statuses: ['CLEARANCE_UNDER_REVIEW'],
|
statuses: ['CLEARANCE_UNDER_REVIEW'],
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
sortBy: filter.sortBy,
|
||||||
|
sortOrder: filter.sortOrder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations queue: self-clearance (Path A) contracts awaiting Operations
|
||||||
|
* review of the customer's own clearance documents.
|
||||||
|
*/
|
||||||
|
async opsQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||||
|
return this.contractsRepository.findAllPaginated({
|
||||||
|
page: filter.page ?? 1,
|
||||||
|
pageSize: filter.pageSize ?? 100,
|
||||||
|
statuses: ['CLEARANCE_UNDER_REVIEW'],
|
||||||
|
customsClearingEnabled: false,
|
||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
sortOrder: filter.sortOrder,
|
sortOrder: filter.sortOrder,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,16 +20,28 @@ function freightFor(freightType: string): Freight {
|
|||||||
return freightType === 'BULK' ? 'bulk' : 'container';
|
return freightType === 'BULK' ? 'bulk' : 'container';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The customer-input clearance setting code, or null when no gate applies. */
|
/**
|
||||||
|
* The customer-input clearance setting code, or null when no gate applies.
|
||||||
|
*
|
||||||
|
* - Path B (customs bundled): the customer uploads the documents GL needs to do
|
||||||
|
* the clearance work → `contract_clearance_{op}_{freight}`.
|
||||||
|
* - Path A (no customs): the customer clears the cargo himself and uploads his
|
||||||
|
* own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`,
|
||||||
|
* reviewed by Operations rather than GL.
|
||||||
|
*
|
||||||
|
* DOMESTIC/intercity has no border, so no clearance gate applies on either path.
|
||||||
|
*/
|
||||||
export function contractClearanceSettingCode(
|
export function contractClearanceSettingCode(
|
||||||
tradeDirection: string,
|
tradeDirection: string,
|
||||||
freightType: string,
|
freightType: string,
|
||||||
includesCustoms: boolean,
|
includesCustoms: boolean,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!includesCustoms) return null;
|
|
||||||
const op = operationFor(tradeDirection);
|
const op = operationFor(tradeDirection);
|
||||||
if (!op) return null;
|
if (!op) return null;
|
||||||
const freight = freightFor(freightType);
|
const freight = freightFor(freightType);
|
||||||
|
if (!includesCustoms) {
|
||||||
|
return `contract_clearance_selfclear_${op}_${freight}`;
|
||||||
|
}
|
||||||
return `contract_clearance_${op}_${freight}`;
|
return `contract_clearance_${op}_${freight}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { ContractPricingService } from './contract-pricing.service';
|
|||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
|
import { contractClearanceSettingCode } from './contract-clearance.util';
|
||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { ContractSignerRole } from './entities/contract-signature.entity';
|
import { ContractSignerRole } from './entities/contract-signature.entity';
|
||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
@@ -441,9 +442,14 @@ export class ContractTransitionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Staff/Director/CEO counter-sign → branch on customs:
|
* Staff/Director/CEO counter-sign → branch on the execution path. A customs
|
||||||
* - customs: AWAITING_CLEARANCE_DOCUMENTS + clearance gate opened (Path B)
|
* border (IMPORT/EXPORT) always requires a clearance gate before any shipment;
|
||||||
* - transport: FULLY_EXECUTED (ONE_TIME) / CONTRACT_ACTIVE (GENERAL)
|
* who reviews differs:
|
||||||
|
* - Path B (customs bundled): customer uploads GL-input docs, GL reviews, GL
|
||||||
|
* uploads output, then GL creates the booking.
|
||||||
|
* - Path A (no customs): the customer clears the cargo himself and uploads his
|
||||||
|
* own clearance proof; Operations reviews it; then the CUSTOMER books.
|
||||||
|
* DOMESTIC/intercity has no border, so it goes straight to executed.
|
||||||
*/
|
*/
|
||||||
async counterSign(
|
async counterSign(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
@@ -461,9 +467,19 @@ export class ContractTransitionService {
|
|||||||
lockedAt: now,
|
lockedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (contract.customsClearingEnabled) {
|
// A clearance gate applies whenever a clearance doc set resolves — Path B
|
||||||
// Path B — open a clearance cycle, seed the pre-booking milestones, and
|
// (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC
|
||||||
// route the customer to the document upload.
|
// resolves to null on both paths and skips straight to executed.
|
||||||
|
const clearanceCode = contractClearanceSettingCode(
|
||||||
|
contract.tradeDirection,
|
||||||
|
contract.freightType,
|
||||||
|
contract.customsClearingEnabled ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clearanceCode) {
|
||||||
|
// Open a clearance cycle, seed the pre-booking milestones, and route the
|
||||||
|
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
|
||||||
|
// distinction is enforced at the review/finalize endpoints, not here.
|
||||||
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
||||||
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
||||||
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
||||||
@@ -471,7 +487,7 @@ export class ContractTransitionService {
|
|||||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||||
updates.clearanceCycleNumber = cycleNumber;
|
updates.clearanceCycleNumber = cycleNumber;
|
||||||
} else {
|
} else {
|
||||||
// Path A — transport only; ready for the customer to book.
|
// No clearance gate (DOMESTIC) — ready for the customer to book directly.
|
||||||
updates.status =
|
updates.status =
|
||||||
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
||||||
updates.clearanceStatus = 'NOT_APPLICABLE';
|
updates.clearanceStatus = 'NOT_APPLICABLE';
|
||||||
|
|||||||
@@ -405,6 +405,45 @@ export class ContractsController {
|
|||||||
return this.clearanceService.finalize(id);
|
return this.clearanceService.finalize(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
|
||||||
|
|
||||||
|
@Get('clearance/ops-queue')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Operations queue: self-clearance (non-customs) contracts awaiting review',
|
||||||
|
})
|
||||||
|
opsClearanceQueue(@Query() filter: FilterContractDto) {
|
||||||
|
return this.clearanceService.opsQueue(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/ops-review')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Operations reviews a customer self-clearance document (Approve | Query)',
|
||||||
|
})
|
||||||
|
opsReviewClearanceDocument(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: ReviewClearanceDocumentDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.opsReviewDocument(
|
||||||
|
id,
|
||||||
|
dto.fileKey,
|
||||||
|
dto.status,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
dto.note,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/ops-finalize')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Operations finalizes self-clearance → customer may create the booking',
|
||||||
|
})
|
||||||
|
opsFinalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.clearanceService.opsFinalize(id);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Booking under contract (Path A customer / Path B GL ET) ────────────────
|
// ── Booking under contract (Path A customer / Path B GL ET) ────────────────
|
||||||
|
|
||||||
@Post(':id/bookings')
|
@Post(':id/bookings')
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface ContractListFilterOptions {
|
|||||||
freightType?: string;
|
freightType?: string;
|
||||||
tradeDirection?: string;
|
tradeDirection?: string;
|
||||||
paymentCurrency?: string;
|
paymentCurrency?: string;
|
||||||
|
customsClearingEnabled?: boolean;
|
||||||
createdFrom?: string;
|
createdFrom?: string;
|
||||||
createdTo?: string;
|
createdTo?: string;
|
||||||
}
|
}
|
||||||
@@ -209,6 +210,11 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
contractKind: options.contractKind,
|
contractKind: options.contractKind,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (options.customsClearingEnabled !== undefined) {
|
||||||
|
qb.andWhere('contract.customs_clearing_enabled = :customsClearingEnabled', {
|
||||||
|
customsClearingEnabled: options.customsClearingEnabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (options.serviceTypeId) {
|
if (options.serviceTypeId) {
|
||||||
qb.andWhere('contract.service_type_id = :serviceTypeId', {
|
qb.andWhere('contract.service_type_id = :serviceTypeId', {
|
||||||
serviceTypeId: options.serviceTypeId,
|
serviceTypeId: options.serviceTypeId,
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ export const CONTRACT_CLEARANCE_STATUSES = [
|
|||||||
'NOT_APPLICABLE',
|
'NOT_APPLICABLE',
|
||||||
'AWAITING_DOCUMENTS',
|
'AWAITING_DOCUMENTS',
|
||||||
'DOCUMENTS_UNDER_REVIEW',
|
'DOCUMENTS_UNDER_REVIEW',
|
||||||
'CLEARANCE_READY_FOR_BOOKING',
|
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
|
||||||
|
'SELF_CLEARED', // Path A — Operations approved self-clearance; customer may book
|
||||||
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||||
] as const;
|
] as const;
|
||||||
export type ContractClearanceStatusValue =
|
export type ContractClearanceStatusValue =
|
||||||
|
|||||||
@@ -400,6 +400,61 @@ const CONTRACT_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ── Path A self-clearance settings (no EDR customs service) ──────────────────
|
||||||
|
// When the contract does NOT bundle customs clearance, the customer clears the
|
||||||
|
// cargo himself and uploads his OWN clearance proof on the contract. Operations
|
||||||
|
// (not GL) reviews this smaller set before the customer may create the booking.
|
||||||
|
// Resolved by contract-clearance.util.ts as contract_clearance_selfclear_{op}_{freight}.
|
||||||
|
const SELF_CLEARANCE_IMPORT_FIELDS: OnboardingField[] = [
|
||||||
|
clearanceField("customs_declaration", "Customs Declaration (IM4/IM5)", 1),
|
||||||
|
clearanceField("import_release", "Import Release Permit", 2),
|
||||||
|
clearanceField("duty_tax_receipt", "Duty & Tax Payment Receipt", 3, {
|
||||||
|
required: false,
|
||||||
|
}),
|
||||||
|
clearanceField("delivery_order", "Delivery Order", 4, { required: false }),
|
||||||
|
clearanceField("supporting_document", "Other Clearance Document", 5, {
|
||||||
|
required: false,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const SELF_CLEARANCE_EXPORT_FIELDS: OnboardingField[] = [
|
||||||
|
clearanceField("customs_declaration", "Customs Declaration (EX3/EX8)", 1),
|
||||||
|
clearanceField("export_release", "Export Release", 2),
|
||||||
|
clearanceField("transit_document", "Transit Document (T1)", 3, {
|
||||||
|
required: false,
|
||||||
|
}),
|
||||||
|
clearanceField("supporting_document", "Other Clearance Document", 4, {
|
||||||
|
required: false,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [
|
||||||
|
{
|
||||||
|
code: "contract_clearance_selfclear_import_container",
|
||||||
|
label: "Self-clearance documents (import container)",
|
||||||
|
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||||
|
fields: SELF_CLEARANCE_IMPORT_FIELDS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "contract_clearance_selfclear_export_container",
|
||||||
|
label: "Self-clearance documents (export container)",
|
||||||
|
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||||
|
fields: SELF_CLEARANCE_EXPORT_FIELDS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "contract_clearance_selfclear_import_bulk",
|
||||||
|
label: "Self-clearance documents (import bulk)",
|
||||||
|
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||||
|
fields: SELF_CLEARANCE_IMPORT_FIELDS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "contract_clearance_selfclear_export_bulk",
|
||||||
|
label: "Self-clearance documents (export bulk)",
|
||||||
|
entity: CONTRACT_CLEARANCE_ENTITY,
|
||||||
|
fields: SELF_CLEARANCE_EXPORT_FIELDS,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// ── Contract intake settings ────────────────────────────────────────────────
|
// ── Contract intake settings ────────────────────────────────────────────────
|
||||||
// Commercial/framework documents attached at contract submission (wizard step 5),
|
// Commercial/framework documents attached at contract submission (wizard step 5),
|
||||||
// distinct from the post-sign clearance docs above.
|
// distinct from the post-sign clearance docs above.
|
||||||
@@ -456,6 +511,11 @@ export class FileUploadSettingsSeeder {
|
|||||||
description:
|
description:
|
||||||
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
|
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
|
||||||
})),
|
})),
|
||||||
|
...SELF_CLEARANCE_SETTINGS.map((s) => ({
|
||||||
|
...s,
|
||||||
|
description:
|
||||||
|
"Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
|
||||||
|
})),
|
||||||
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
|
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('a3000001-0001-4000-8000-00000000000a', 'edr_freight_app:contracts:clearance_review', 'Review pre-booking clearance docs'),
|
perm('a3000001-0001-4000-8000-00000000000a', 'edr_freight_app:contracts:clearance_review', 'Review pre-booking clearance docs'),
|
||||||
perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'),
|
perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'),
|
||||||
perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'),
|
perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'),
|
||||||
|
perm('a3000001-0001-4000-8000-00000000000d', 'edr_freight_app:contracts:ops_clearance_review', 'Operations review of self-clearance docs (Path A)'),
|
||||||
];
|
];
|
||||||
|
|
||||||
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
||||||
@@ -147,6 +148,7 @@ export const FREIGHT_PERMS = {
|
|||||||
clearanceReview: 'edr_freight_app:contracts:clearance_review',
|
clearanceReview: 'edr_freight_app:contracts:clearance_review',
|
||||||
finalizeClearance: 'edr_freight_app:contracts:finalize_clearance',
|
finalizeClearance: 'edr_freight_app:contracts:finalize_clearance',
|
||||||
createBooking: 'edr_freight_app:contracts:create_booking',
|
createBooking: 'edr_freight_app:contracts:create_booking',
|
||||||
|
opsClearanceReview: 'edr_freight_app:contracts:ops_clearance_review',
|
||||||
},
|
},
|
||||||
trainScheduling: {
|
trainScheduling: {
|
||||||
view: 'edr_freight_app:train_scheduling:view',
|
view: 'edr_freight_app:train_scheduling:view',
|
||||||
@@ -196,6 +198,10 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.trainScheduling.manage,
|
FREIGHT_PERMS.trainScheduling.manage,
|
||||||
FREIGHT_PERMS.fleet.view,
|
FREIGHT_PERMS.fleet.view,
|
||||||
FREIGHT_PERMS.fleet.manage,
|
FREIGHT_PERMS.fleet.manage,
|
||||||
|
// Path A (no customs): Operations reviews the customer's self-clearance docs
|
||||||
|
// on the contract before the customer may create a shipment booking.
|
||||||
|
FREIGHT_PERMS.contracts.view,
|
||||||
|
FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||||
...allRuleEngineViewKeys(),
|
...allRuleEngineViewKeys(),
|
||||||
],
|
],
|
||||||
director: [
|
director: [
|
||||||
|
|||||||
@@ -140,6 +140,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <ShieldCheck />,
|
icon: <ShieldCheck />,
|
||||||
permission: FREIGHT_PERMS.contracts.clearanceReview,
|
permission: FREIGHT_PERMS.contracts.clearanceReview,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Self-Clearance Review",
|
||||||
|
href: "/dashboard/contracts/ops-clearance",
|
||||||
|
icon: <ShieldCheck />,
|
||||||
|
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Train Schedules",
|
label: "Train Schedules",
|
||||||
href: "/dashboard/operations/train-scheduling-v2",
|
href: "/dashboard/operations/train-scheduling-v2",
|
||||||
@@ -467,10 +473,25 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="contracts/ops-clearance"
|
||||||
|
element={
|
||||||
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
|
||||||
|
>
|
||||||
|
<ContractClearanceListPage opsMode />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="contracts/clearance/:id"
|
path="contracts/clearance/:id"
|
||||||
element={
|
element={
|
||||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
|
<RequirePermission
|
||||||
|
permission={[
|
||||||
|
FREIGHT_PERMS.contracts.clearanceReview,
|
||||||
|
FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<ContractClearanceDetailPage />
|
<ContractClearanceDetailPage />
|
||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ export interface ContractClearanceReviewSectionProps {
|
|||||||
onChanged?: () => void;
|
onChanged?: () => void;
|
||||||
/** Hide the inline progress summary (e.g. when the parent renders its own). */
|
/** Hide the inline progress summary (e.g. when the parent renders its own). */
|
||||||
hideSummary?: boolean;
|
hideSummary?: boolean;
|
||||||
|
/**
|
||||||
|
* Path A (non-customs): the reviewer is Operations, not GL, and there is no GL
|
||||||
|
* output upload step. Routes review/finalize to the Operations endpoints.
|
||||||
|
*/
|
||||||
|
selfClear?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_META: Record<
|
const STATUS_META: Record<
|
||||||
@@ -59,6 +64,7 @@ export function ContractClearanceReviewSection({
|
|||||||
contractId,
|
contractId,
|
||||||
onChanged,
|
onChanged,
|
||||||
hideSummary,
|
hideSummary,
|
||||||
|
selfClear = false,
|
||||||
}: ContractClearanceReviewSectionProps) {
|
}: ContractClearanceReviewSectionProps) {
|
||||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||||
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
||||||
@@ -70,7 +76,7 @@ export function ContractClearanceReviewSection({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { reviewDocument, uploadOutputDocuments, finalizeClearance } =
|
const { reviewDocument, uploadOutputDocuments, finalizeClearance } =
|
||||||
useContractClearanceMutations(contractId);
|
useContractClearanceMutations(contractId, selfClear);
|
||||||
|
|
||||||
const customerDocs = useMemo(
|
const customerDocs = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -143,6 +143,12 @@ export const URL_CONSTANTS = {
|
|||||||
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
|
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
|
||||||
`/contracts/${id}/clearance/output-documents`,
|
`/contracts/${id}/clearance/output-documents`,
|
||||||
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
|
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
|
||||||
|
// Path A self-clearance — Operations reviews the customer's own clearance docs.
|
||||||
|
OPS_CLEARANCE_QUEUE: "/contracts/clearance/ops-queue",
|
||||||
|
OPS_CLEARANCE_REVIEW: (id: string) =>
|
||||||
|
`/contracts/${id}/clearance/ops-review`,
|
||||||
|
OPS_CLEARANCE_FINALIZE: (id: string) =>
|
||||||
|
`/contracts/${id}/clearance/ops-finalize`,
|
||||||
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
|
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
|
||||||
MILESTONES: (id: string) => `/contracts/${id}/milestones`,
|
MILESTONES: (id: string) => `/contracts/${id}/milestones`,
|
||||||
BOOKING_MILESTONES: (bookingId: string) =>
|
BOOKING_MILESTONES: (bookingId: string) =>
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ export function useContractClearanceQueue(region = "ET", enabled = true) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
|
||||||
|
export function useOpsClearanceQueue(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("OPS"),
|
||||||
|
queryFn: () => contractsService.getOpsClearanceQueue(),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useContractMilestones(id: string | undefined) {
|
export function useContractMilestones(id: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: QUERY_KEYS.CONTRACTS.milestones(id ?? ""),
|
queryKey: QUERY_KEYS.CONTRACTS.milestones(id ?? ""),
|
||||||
@@ -177,8 +186,15 @@ export function useContractMutations(contractId: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pre-booking clearance mutations (GL ET) keyed on a contract. */
|
/**
|
||||||
export function useContractClearanceMutations(contractId: string) {
|
* Pre-booking clearance mutations keyed on a contract. Pass `selfClear = true`
|
||||||
|
* for Path A (non-customs) contracts so review/finalize hit the Operations
|
||||||
|
* endpoints instead of the GL ET ones. Path A has no GL output upload step.
|
||||||
|
*/
|
||||||
|
export function useContractClearanceMutations(
|
||||||
|
contractId: string,
|
||||||
|
selfClear = false,
|
||||||
|
) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
@@ -196,7 +212,10 @@ export function useContractClearanceMutations(contractId: string) {
|
|||||||
fileKey: string;
|
fileKey: string;
|
||||||
status: "APPROVED" | "QUERIED";
|
status: "APPROVED" | "QUERIED";
|
||||||
note?: string;
|
note?: string;
|
||||||
}) => contractsService.reviewClearanceDocument(contractId, p),
|
}) =>
|
||||||
|
selfClear
|
||||||
|
? contractsService.opsReviewClearanceDocument(contractId, p)
|
||||||
|
: contractsService.reviewClearanceDocument(contractId, p),
|
||||||
onSuccess: (_d, p) => {
|
onSuccess: (_d, p) => {
|
||||||
toast.success(
|
toast.success(
|
||||||
p.status === "APPROVED"
|
p.status === "APPROVED"
|
||||||
@@ -219,9 +238,16 @@ export function useContractClearanceMutations(contractId: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const finalizeClearance = useMutation({
|
const finalizeClearance = useMutation({
|
||||||
mutationFn: () => contractsService.finalizeClearance(contractId),
|
mutationFn: () =>
|
||||||
|
selfClear
|
||||||
|
? contractsService.opsFinalizeClearance(contractId)
|
||||||
|
: contractsService.finalizeClearance(contractId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Clearance finalized — ready for booking");
|
toast.success(
|
||||||
|
selfClear
|
||||||
|
? "Clearance approved — customer can now book"
|
||||||
|
: "Clearance finalized — ready for booking",
|
||||||
|
);
|
||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
onError: (e) =>
|
onError: (e) =>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const FREIGHT_PERMS = {
|
|||||||
clearanceReview: "edr_freight_app:contracts:clearance_review",
|
clearanceReview: "edr_freight_app:contracts:clearance_review",
|
||||||
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
|
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
|
||||||
createBooking: "edr_freight_app:contracts:create_booking",
|
createBooking: "edr_freight_app:contracts:create_booking",
|
||||||
|
opsClearanceReview: "edr_freight_app:contracts:ops_clearance_review",
|
||||||
},
|
},
|
||||||
trainScheduling: {
|
trainScheduling: {
|
||||||
view: "edr_freight_app:train_scheduling:view",
|
view: "edr_freight_app:train_scheduling:view",
|
||||||
@@ -115,6 +116,13 @@ export function canCreateContractBooking(
|
|||||||
return hasPermission(user, FREIGHT_PERMS.contracts.createBooking);
|
return hasPermission(user, FREIGHT_PERMS.contracts.createBooking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Operations: can review the Path A self-clearance queue (non-customs). */
|
||||||
|
export function canReviewSelfClearance(
|
||||||
|
user: AuthUser | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return hasPermission(user, FREIGHT_PERMS.contracts.opsClearanceReview);
|
||||||
|
}
|
||||||
|
|
||||||
/** Can see/manage the customs document-clearance queue (Global Logistics). */
|
/** Can see/manage the customs document-clearance queue (Global Logistics). */
|
||||||
export function canViewClearance(user: AuthUser | null | undefined): boolean {
|
export function canViewClearance(user: AuthUser | null | undefined): boolean {
|
||||||
return hasPermission(user, FREIGHT_PERMS.bookings.reviewDocuments);
|
return hasPermission(user, FREIGHT_PERMS.bookings.reviewDocuments);
|
||||||
|
|||||||
@@ -66,7 +66,11 @@ export default function ContractClearanceDetailPage() {
|
|||||||
}, [clearance]);
|
}, [clearance]);
|
||||||
|
|
||||||
const reference = contract?.reference ?? "Clearance";
|
const reference = contract?.reference ?? "Clearance";
|
||||||
|
// Path A (no customs): Operations reviews; the customer books in the portal —
|
||||||
|
// there is no "Create booking" action here.
|
||||||
|
const selfClear = clearance?.includesCustoms === false;
|
||||||
const canBook =
|
const canBook =
|
||||||
|
!selfClear &&
|
||||||
clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" &&
|
clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" &&
|
||||||
canCreateContractBooking(user);
|
canCreateContractBooking(user);
|
||||||
|
|
||||||
@@ -155,7 +159,11 @@ export default function ContractClearanceDetailPage() {
|
|||||||
|
|
||||||
<Grid gap="lg">
|
<Grid gap="lg">
|
||||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
<ContractClearanceReviewSection contractId={id!} hideSummary />
|
<ContractClearanceReviewSection
|
||||||
|
contractId={id!}
|
||||||
|
hideSummary
|
||||||
|
selfClear={selfClear}
|
||||||
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ import { PageContainer } from "@/components/page/PageContainer";
|
|||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||||
import { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
|
import {
|
||||||
|
useContractClearanceQueue,
|
||||||
|
useOpsClearanceQueue,
|
||||||
|
} from "@/hooks/contracts/useContracts";
|
||||||
|
|
||||||
type ViewMode = "table" | "cards";
|
type ViewMode = "table" | "cards";
|
||||||
type Region = "ET" | "DJ";
|
type Region = "ET" | "DJ";
|
||||||
@@ -103,15 +106,28 @@ function DirectionIcon({ direction }: { direction: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ContractClearanceListPage() {
|
/**
|
||||||
|
* Pre-booking clearance queue. In `opsMode` it lists Path A self-clearance
|
||||||
|
* contracts for the Operations team (non-customs); otherwise the GL ET/DJ
|
||||||
|
* customs queue (Path B). Both route to the same detail page, which detects the
|
||||||
|
* path from the contract.
|
||||||
|
*/
|
||||||
|
export default function ContractClearanceListPage({
|
||||||
|
opsMode = false,
|
||||||
|
}: {
|
||||||
|
opsMode?: boolean;
|
||||||
|
} = {}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [region, setRegion] = useState<Region>("ET");
|
const [region, setRegion] = useState<Region>("ET");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [view, setView] = useState<ViewMode>("table");
|
const [view, setView] = useState<ViewMode>("table");
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
|
||||||
const { data, isLoading, isError, isFetching, refetch } =
|
const glQueue = useContractClearanceQueue(region, !opsMode);
|
||||||
useContractClearanceQueue(region);
|
const opsQueue = useOpsClearanceQueue(opsMode);
|
||||||
|
const { data, isLoading, isError, isFetching, refetch } = opsMode
|
||||||
|
? opsQueue
|
||||||
|
: glQueue;
|
||||||
|
|
||||||
const allRows = useMemo(
|
const allRows = useMemo(
|
||||||
() => (data?.items ?? []).map(toClearanceRow),
|
() => (data?.items ?? []).map(toClearanceRow),
|
||||||
@@ -238,8 +254,12 @@ export default function ContractClearanceListPage() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Contract Clearance"
|
title={opsMode ? "Self-Clearance Review" : "Contract Clearance"}
|
||||||
subtitle="Review pre-booking clearance documents on contracts before Global Logistics creates the shipment booking."
|
subtitle={
|
||||||
|
opsMode
|
||||||
|
? "Review the customer's own clearance documents (no EDR customs service) before they create a shipment booking."
|
||||||
|
: "Review pre-booking clearance documents on contracts before Global Logistics creates the shipment booking."
|
||||||
|
}
|
||||||
meta={
|
meta={
|
||||||
<Badge
|
<Badge
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -320,22 +340,24 @@ export default function ContractClearanceListPage() {
|
|||||||
style={{ flex: 1, minWidth: 220 }}
|
style={{ flex: 1, minWidth: 220 }}
|
||||||
/>
|
/>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<SegmentedControl
|
{!opsMode && (
|
||||||
size="sm"
|
<SegmentedControl
|
||||||
radius="md"
|
size="sm"
|
||||||
value={region}
|
radius="md"
|
||||||
onChange={(v) => {
|
value={region}
|
||||||
setRegion(v as Region);
|
onChange={(v) => {
|
||||||
setPagination({
|
setRegion(v as Region);
|
||||||
pageIndex: 0,
|
setPagination({
|
||||||
pageSize: pagination.pageSize,
|
pageIndex: 0,
|
||||||
});
|
pageSize: pagination.pageSize,
|
||||||
}}
|
});
|
||||||
data={[
|
}}
|
||||||
{ value: "ET", label: "Ethiopia" },
|
data={[
|
||||||
{ value: "DJ", label: "Djibouti" },
|
{ value: "ET", label: "Ethiopia" },
|
||||||
]}
|
{ value: "DJ", label: "Djibouti" },
|
||||||
/>
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{total} record{total !== 1 ? "s" : ""}
|
{total} record{total !== 1 ? "s" : ""}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -204,6 +204,26 @@ export const contractsService = {
|
|||||||
finalizeClearance: (id: string) =>
|
finalizeClearance: (id: string) =>
|
||||||
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE(id)),
|
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE(id)),
|
||||||
|
|
||||||
|
// ── Path A self-clearance (Operations review) ──
|
||||||
|
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
|
||||||
|
const response = await client.get<PaginatedContracts>(
|
||||||
|
C.OPS_CLEARANCE_QUEUE,
|
||||||
|
);
|
||||||
|
const data = unwrap(response.data);
|
||||||
|
return {
|
||||||
|
items: (data.items ?? []) as Freight.IContract[],
|
||||||
|
total: data.total ?? 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
opsReviewClearanceDocument: (
|
||||||
|
id: string,
|
||||||
|
payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },
|
||||||
|
) => postContract<Freight.IContract>(C.OPS_CLEARANCE_REVIEW(id), payload),
|
||||||
|
|
||||||
|
opsFinalizeClearance: (id: string) =>
|
||||||
|
postContract<Freight.IContract>(C.OPS_CLEARANCE_FINALIZE(id)),
|
||||||
|
|
||||||
// ── Booking under contract (GL ET — Path B) ──
|
// ── Booking under contract (GL ET — Path B) ──
|
||||||
createBookingUnderContract: (
|
createBookingUnderContract: (
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -129,10 +129,17 @@ export default function ContractClearanceFlow() {
|
|||||||
const isUnderReview = status === "DOCUMENTS_UNDER_REVIEW";
|
const isUnderReview = status === "DOCUMENTS_UNDER_REVIEW";
|
||||||
const isReady =
|
const isReady =
|
||||||
status === "CLEARANCE_READY_FOR_BOOKING" ||
|
status === "CLEARANCE_READY_FOR_BOOKING" ||
|
||||||
|
status === "SELF_CLEARED" ||
|
||||||
status === "ACTIVE_SHIPMENT_IN_PROGRESS";
|
status === "ACTIVE_SHIPMENT_IN_PROGRESS";
|
||||||
const canUpload = status === "AWAITING_DOCUMENTS" || isUnderReview;
|
const canUpload = status === "AWAITING_DOCUMENTS" || isUnderReview;
|
||||||
const isInitialUpload = status === "AWAITING_DOCUMENTS";
|
const isInitialUpload = status === "AWAITING_DOCUMENTS";
|
||||||
|
|
||||||
|
// Path B (customs) is reviewed by Global Logistics and GL creates the booking;
|
||||||
|
// Path A self-clearance is reviewed by the Operations team and the customer
|
||||||
|
// creates the booking himself afterward.
|
||||||
|
const customsPath = clearance?.includesCustoms ?? true;
|
||||||
|
const reviewer = customsPath ? "Global Logistics" : "the Operations team";
|
||||||
|
|
||||||
const missingRequired = useMemo(
|
const missingRequired = useMemo(
|
||||||
() => customerDocs.filter((d) => d.required && !d.file && !pending[d.fileKey]),
|
() => customerDocs.filter((d) => d.required && !d.file && !pending[d.fileKey]),
|
||||||
[customerDocs, pending],
|
[customerDocs, pending],
|
||||||
@@ -209,8 +216,9 @@ export default function ContractClearanceFlow() {
|
|||||||
icon={<CheckCircle2 size={18} />}
|
icon={<CheckCircle2 size={18} />}
|
||||||
mb="md"
|
mb="md"
|
||||||
>
|
>
|
||||||
Your clearance documents are approved. Global Logistics will
|
{customsPath
|
||||||
create your booking — you will be notified when payment is due.
|
? "Your clearance documents are approved. Global Logistics will create your booking — you will be notified when payment is due."
|
||||||
|
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : isUnderReview ? (
|
) : isUnderReview ? (
|
||||||
<Alert
|
<Alert
|
||||||
@@ -219,9 +227,9 @@ export default function ContractClearanceFlow() {
|
|||||||
icon={<Clock size={18} />}
|
icon={<Clock size={18} />}
|
||||||
mb="md"
|
mb="md"
|
||||||
>
|
>
|
||||||
Global Logistics is reviewing your documents. Only re-upload the
|
{reviewer.charAt(0).toUpperCase() + reviewer.slice(1)} is
|
||||||
documents flagged with a query below — approved documents stay as
|
reviewing your documents. Only re-upload the documents flagged
|
||||||
they are.
|
with a query below — approved documents stay as they are.
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<Alert
|
<Alert
|
||||||
@@ -230,9 +238,9 @@ export default function ContractClearanceFlow() {
|
|||||||
icon={<AlertCircle size={18} />}
|
icon={<AlertCircle size={18} />}
|
||||||
mb="md"
|
mb="md"
|
||||||
>
|
>
|
||||||
Upload every required clearance document (marked *) below to start
|
{customsPath
|
||||||
the review. Global Logistics will clear your shipment and create
|
? "Upload every required clearance document (marked *) below to start the review. Global Logistics will clear your shipment and create the booking for you."
|
||||||
the booking for you.
|
: "This service does not include EDR customs clearance — clear the cargo yourself and upload every required clearance document (marked *) below. The Operations team will review them before you can book a shipment."}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,14 @@ import {
|
|||||||
MUTED,
|
MUTED,
|
||||||
} from "./contract-ui";
|
} from "./contract-ui";
|
||||||
|
|
||||||
// Statuses where Path A customers may create a shipment booking themselves.
|
// Statuses where a customer may create a shipment booking themselves. Reached
|
||||||
|
// only after self-clearance is approved by Operations (Path A) or, for DOMESTIC,
|
||||||
|
// directly at counter-sign.
|
||||||
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
|
const PATH_A_BOOKABLE = ["FULLY_EXECUTED", "CONTRACT_ACTIVE"];
|
||||||
// Statuses where Path B customers upload clearance docs on the contract.
|
// Statuses where the customer uploads clearance documents on the contract. Used
|
||||||
const PATH_B_CLEARANCE = [
|
// by both paths: Path B (customs, GL-reviewed) and Path A self-clearance
|
||||||
|
// (non-customs IMPORT/EXPORT, Operations-reviewed).
|
||||||
|
const CLEARANCE_UPLOAD_STATUSES = [
|
||||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||||
"CLEARANCE_UNDER_REVIEW",
|
"CLEARANCE_UNDER_REVIEW",
|
||||||
"CLEARANCE_READY_FOR_BOOKING",
|
"CLEARANCE_READY_FOR_BOOKING",
|
||||||
@@ -120,12 +124,14 @@ export default function ContractDetailPage() {
|
|||||||
|
|
||||||
const canSign = contract.status === "CONTRACT_READY";
|
const canSign = contract.status === "CONTRACT_READY";
|
||||||
const customsPath = contract.customsClearingEnabled;
|
const customsPath = contract.customsClearingEnabled;
|
||||||
// Path A — transport only, customer may book a shipment directly.
|
// The customer creates the booking himself unless GL owns it (customs / Path B).
|
||||||
|
// Reached only once the contract is executed (after self-clearance on Path A).
|
||||||
const canBookShipment =
|
const canBookShipment =
|
||||||
!customsPath && PATH_A_BOOKABLE.includes(contract.status);
|
!customsPath && PATH_A_BOOKABLE.includes(contract.status);
|
||||||
// Path B — customs clearance, customer uploads clearance documents.
|
// The customer uploads clearance documents on the contract while in a clearance
|
||||||
|
// status — Path B (customs, GL-reviewed) or Path A self-clearance (Operations).
|
||||||
const canUploadClearance =
|
const canUploadClearance =
|
||||||
customsPath && PATH_B_CLEARANCE.includes(contract.status);
|
CLEARANCE_UPLOAD_STATUSES.includes(contract.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box style={{ padding: "28px 32px 40px" }}>
|
<Box style={{ padding: "28px 32px 40px" }}>
|
||||||
@@ -326,8 +332,8 @@ export default function ContractDetailPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
|
||||||
{/* Path B notice */}
|
{/* Clearance notice (both paths) */}
|
||||||
{customsPath && PATH_B_CLEARANCE.includes(contract.status) && (
|
{canUploadClearance && (
|
||||||
<Paper
|
<Paper
|
||||||
withBorder
|
withBorder
|
||||||
radius="lg"
|
radius="lg"
|
||||||
@@ -352,15 +358,23 @@ export default function ContractDetailPage() {
|
|||||||
<Group gap={10} align="center" mb={6}>
|
<Group gap={10} align="center" mb={6}>
|
||||||
<Upload size={16} color={GREEN} />
|
<Upload size={16} color={GREEN} />
|
||||||
<Text fw={700} fz={15} c={INK}>
|
<Text fw={700} fz={15} c={INK}>
|
||||||
Customs clearance shipment
|
{customsPath
|
||||||
|
? "Customs clearance shipment"
|
||||||
|
: "Customs clearance required"}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text fz={13} c="dimmed">
|
<Text fz={13} c="dimmed">
|
||||||
{contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
|
{customsPath
|
||||||
? "Upload your clearance documents so Global Logistics can review them. After approval, Global Logistics creates your booking — you only pay the freight."
|
? contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
|
||||||
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
? "Upload your clearance documents so Global Logistics can review them. After approval, Global Logistics creates your booking — you only pay the freight."
|
||||||
? "Global Logistics is reviewing your clearance documents. Re-upload any queried documents to proceed."
|
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
||||||
: "Your documents are cleared. Global Logistics will create your booking shortly — you will be notified when payment is due."}
|
? "Global Logistics is reviewing your clearance documents. Re-upload any queried documents to proceed."
|
||||||
|
: "Your documents are cleared. Global Logistics will create your booking shortly — you will be notified when payment is due."
|
||||||
|
: contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
|
||||||
|
? "This service does not include EDR customs clearance. Clear the cargo yourself and upload your clearance documents so the Operations team can review them before you book a shipment."
|
||||||
|
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
||||||
|
? "The Operations team is reviewing your clearance documents. Re-upload any queried documents to proceed."
|
||||||
|
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
|
||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
@@ -676,7 +690,9 @@ export default function ContractDetailPage() {
|
|||||||
? "No bookings yet. Use “New booking” to ship against this contract."
|
? "No bookings yet. Use “New booking” to ship against this contract."
|
||||||
: customsPath
|
: customsPath
|
||||||
? "No bookings yet. After your clearance documents are approved, Global Logistics creates the booking on your behalf."
|
? "No bookings yet. After your clearance documents are approved, Global Logistics creates the booking on your behalf."
|
||||||
: "Bookings appear here once the contract is fully executed."}
|
: canUploadClearance
|
||||||
|
? "No bookings yet. After the Operations team approves your clearance documents, you can create a booking here."
|
||||||
|
: "Bookings appear here once the contract is fully executed."}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -4,18 +4,23 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Center,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
|
Table,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
FileStack,
|
FileStack,
|
||||||
|
Inbox,
|
||||||
Package,
|
Package,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
@@ -27,13 +32,15 @@ import {
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { ContractListFilter } from "@/services/contracts.service";
|
import type { ContractListFilter } from "@/services/contracts.service";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
import { usePagination } from "@edr/ui-common";
|
||||||
import {
|
import {
|
||||||
DataTable,
|
BORDER,
|
||||||
DataTableFooter,
|
ContractStatusBadge,
|
||||||
type ColumnDef,
|
GREEN,
|
||||||
usePagination,
|
INK,
|
||||||
} from "@edr/ui-common";
|
MUTED,
|
||||||
import { BORDER, ContractStatusBadge, INK, MUTED, StatCard } from "./contract-ui";
|
StatCard,
|
||||||
|
} from "./contract-ui";
|
||||||
|
|
||||||
function primaryRoute(contract: Freight.IContract) {
|
function primaryRoute(contract: Freight.IContract) {
|
||||||
const route = contract.routes?.[0];
|
const route = contract.routes?.[0];
|
||||||
@@ -54,27 +61,35 @@ const PATH_B_CLEARANCE_STATUSES = [
|
|||||||
/** The single most relevant next action for a customer's contract row. */
|
/** The single most relevant next action for a customer's contract row. */
|
||||||
function getCustomerRowAction(
|
function getCustomerRowAction(
|
||||||
contract: Freight.IContract,
|
contract: Freight.IContract,
|
||||||
): { label: string; to: string } {
|
): { label: string; to: string; primary: boolean } {
|
||||||
const id = contract.id;
|
const id = contract.id;
|
||||||
if (contract.status === "CONTRACT_READY") {
|
if (contract.status === "CONTRACT_READY") {
|
||||||
return { label: "View & sign", to: `/contracts/${id}/view` };
|
return { label: "View & sign", to: `/contracts/${id}/view`, primary: true };
|
||||||
}
|
}
|
||||||
if (contract.status === "CHANGES_REQUESTED") {
|
if (contract.status === "CHANGES_REQUESTED") {
|
||||||
return { label: "Edit & resubmit", to: `/contracts/${id}` };
|
return { label: "Edit & resubmit", to: `/contracts/${id}`, primary: true };
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
contract.customsClearingEnabled &&
|
contract.customsClearingEnabled &&
|
||||||
PATH_B_CLEARANCE_STATUSES.includes(contract.status)
|
PATH_B_CLEARANCE_STATUSES.includes(contract.status)
|
||||||
) {
|
) {
|
||||||
return { label: "Upload clearance", to: `/contracts/${id}/clearance` };
|
return {
|
||||||
|
label: "Upload clearance",
|
||||||
|
to: `/contracts/${id}/clearance`,
|
||||||
|
primary: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!contract.customsClearingEnabled &&
|
!contract.customsClearingEnabled &&
|
||||||
PATH_A_BOOKABLE_STATUSES.includes(contract.status)
|
PATH_A_BOOKABLE_STATUSES.includes(contract.status)
|
||||||
) {
|
) {
|
||||||
return { label: "Book shipment", to: `/contracts/${id}/bookings/new` };
|
return {
|
||||||
|
label: "Book shipment",
|
||||||
|
to: `/contracts/${id}/bookings/new`,
|
||||||
|
primary: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { label: "View", to: `/contracts/${id}` };
|
return { label: "View", to: `/contracts/${id}`, primary: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ContractsList() {
|
export default function ContractsList() {
|
||||||
@@ -161,176 +176,33 @@ export default function ContractsList() {
|
|||||||
return { active, pending, total };
|
return { active, pending, total };
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const columns: ColumnDef<Freight.IContract>[] = [
|
|
||||||
{
|
|
||||||
id: "reference",
|
|
||||||
header: () => <ColHeader label="Contract" />,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const c = row.original;
|
|
||||||
const isGeneral = c.contractKind === "GENERAL";
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Text fz={14} fw={700} style={{ color: INK }}>
|
|
||||||
{c.reference}
|
|
||||||
</Text>
|
|
||||||
<Text fz={12} c="dimmed">
|
|
||||||
{isGeneral ? "General" : "One-Time"} ·{" "}
|
|
||||||
{c.freightType === "CONTAINER" ? "Containerised" : "Bulk"}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cargo",
|
|
||||||
header: () => <ColHeader label="Cargo" />,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const isContainer = row.original.freightType === "CONTAINER";
|
|
||||||
return (
|
|
||||||
<Group gap={7} wrap="nowrap" align="center">
|
|
||||||
{isContainer ? (
|
|
||||||
<Package size={15} color={MUTED} />
|
|
||||||
) : (
|
|
||||||
<Weight size={15} color={MUTED} />
|
|
||||||
)}
|
|
||||||
<Text fz={13} style={{ color: INK }}>
|
|
||||||
{isContainer ? "Container" : "Bulk"}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "route",
|
|
||||||
header: () => <ColHeader label="Route" />,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const { origin, destination, count } = primaryRoute(row.original);
|
|
||||||
return (
|
|
||||||
<Text fz={13} style={{ color: INK }}>
|
|
||||||
{origin}{" "}
|
|
||||||
<Text span c="dimmed">
|
|
||||||
→
|
|
||||||
</Text>{" "}
|
|
||||||
{destination}
|
|
||||||
{count > 1 && (
|
|
||||||
<Text span c="dimmed" fz={12}>
|
|
||||||
{" "}
|
|
||||||
+{count - 1}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trade",
|
|
||||||
header: () => <ColHeader label="Trade" />,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const dir = row.original.tradeDirection;
|
|
||||||
const label = dir
|
|
||||||
? dir.charAt(0) + dir.slice(1).toLowerCase()
|
|
||||||
: "—";
|
|
||||||
return (
|
|
||||||
<Text fz={13} c={dir ? undefined : "dimmed"} style={{ color: dir ? INK : undefined }}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "currency",
|
|
||||||
header: () => <ColHeader label="Currency" />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Text fz={13} style={{ color: INK }}>
|
|
||||||
{row.original.paymentCurrency ?? "—"}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "created",
|
|
||||||
header: () => <ColHeader label="Created" />,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const created = row.original.createdAt;
|
|
||||||
return (
|
|
||||||
<Text fz={13} c={created ? undefined : "dimmed"} style={{ color: created ? INK : undefined }}>
|
|
||||||
{created ? new Date(created).toLocaleDateString() : "—"}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "validUntil",
|
|
||||||
header: () => <ColHeader label="Valid Until" />,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const until = row.original.contractValidUntil;
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
fz={13}
|
|
||||||
c={until ? undefined : "dimmed"}
|
|
||||||
style={{ color: until ? INK : undefined }}
|
|
||||||
>
|
|
||||||
{until ? new Date(until).toLocaleDateString() : "—"}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "status",
|
|
||||||
header: () => <ColHeader label="Status" />,
|
|
||||||
cell: ({ row }) => <ContractStatusBadge status={row.original.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
header: () => <ColHeader label="Action" />,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const action = getCustomerRowAction(row.original);
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="compact-sm"
|
|
||||||
radius="md"
|
|
||||||
variant={action.label === "View" ? "light" : "filled"}
|
|
||||||
color="edr-green"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate(action.to);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
|
||||||
const total = data?.meta?.total ?? (data?.items?.length ?? 0);
|
const total = data?.meta?.total ?? (data?.items?.length ?? 0);
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
|
const pageIndex = pagination.pageIndex;
|
||||||
|
const start = total === 0 ? 0 : pageIndex * pagination.pageSize + 1;
|
||||||
|
const end = Math.min((pageIndex + 1) * pagination.pageSize, total);
|
||||||
|
|
||||||
|
const goToPage = (i: number) =>
|
||||||
|
setPagination({
|
||||||
|
pageIndex: Math.max(0, Math.min(i, pageCount - 1)),
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box style={{ padding: "28px 32px 32px" }}>
|
<Box style={{ padding: "28px 32px 32px" }}>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
|
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
||||||
<Box>
|
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||||
<Title
|
Contracts
|
||||||
order={1}
|
</Title>
|
||||||
fw={800}
|
|
||||||
fz={26}
|
|
||||||
style={{ letterSpacing: "-0.01em" }}
|
|
||||||
>
|
|
||||||
Contracts
|
|
||||||
</Title>
|
|
||||||
<Text size="sm" c="edr-muted" mt={4} maw={520}>
|
|
||||||
Your freight agreements — one-time and general. Sign a contract,
|
|
||||||
then ship against it over its validity window.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
leftSection={<Plus size={16} />}
|
leftSection={<Plus size={16} />}
|
||||||
onClick={() => navigate("/contracts/new", { state: { fresh: true } })}
|
onClick={() => navigate("/contracts/new", { state: { fresh: true } })}
|
||||||
|
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
|
||||||
>
|
>
|
||||||
New Contract
|
New Contract
|
||||||
</Button>
|
</Button>
|
||||||
@@ -439,6 +311,7 @@ export default function ContractsList() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<X size={14} />}
|
leftSection={<X size={14} />}
|
||||||
onClick={clearExtraFilters}
|
onClick={clearExtraFilters}
|
||||||
|
styles={{ root: { fontWeight: 600 } }}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
@@ -447,39 +320,351 @@ export default function ContractsList() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<Card p={0} style={{ overflow: "hidden" }}>
|
<Paper
|
||||||
<DataTable
|
withBorder
|
||||||
columns={columns}
|
radius="lg"
|
||||||
data={rows}
|
style={{ borderColor: BORDER, overflow: "hidden" }}
|
||||||
status={dataTableStatus}
|
>
|
||||||
onRowClick={(row) =>
|
<Box style={{ overflowX: "auto" }}>
|
||||||
navigate(`/contracts/${(row as Freight.IContract).id}`)
|
<Table
|
||||||
}
|
verticalSpacing={14}
|
||||||
pagination={{
|
horizontalSpacing={20}
|
||||||
pageIndex: pagination.pageIndex,
|
highlightOnHover
|
||||||
pageSize: pagination.pageSize,
|
styles={{
|
||||||
pageCount,
|
th: {
|
||||||
totalCount: total,
|
fontSize: 11,
|
||||||
}}
|
fontWeight: 700,
|
||||||
tableOptions={{
|
letterSpacing: "0.05em",
|
||||||
state: { pagination },
|
textTransform: "uppercase",
|
||||||
onPaginationChange: setPagination,
|
color: MUTED,
|
||||||
manualPagination: true,
|
background: "#F8FAFC",
|
||||||
pageCount,
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
}}
|
whiteSpace: "nowrap",
|
||||||
footer={DataTableFooter}
|
},
|
||||||
emptyMessage="No contracts yet. Create one from New Contract."
|
td: {
|
||||||
/>
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
</Card>
|
verticalAlign: "middle",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Contract</Table.Th>
|
||||||
|
<Table.Th>Cargo</Table.Th>
|
||||||
|
<Table.Th>Route</Table.Th>
|
||||||
|
<Table.Th>Trade</Table.Th>
|
||||||
|
<Table.Th>Currency</Table.Th>
|
||||||
|
<Table.Th>Created</Table.Th>
|
||||||
|
<Table.Th>Valid Until</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th ta="right">Action</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{isLoading && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={9}>
|
||||||
|
<Center py={48}>
|
||||||
|
<Loader color="edr-green" size="sm" />
|
||||||
|
</Center>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && isError && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={9}>
|
||||||
|
<Center py={48}>
|
||||||
|
<Text fz={13} c="red">
|
||||||
|
Failed to load contracts. Please try again.
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && rows.length === 0 && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={9}>
|
||||||
|
<Stack align="center" gap={8} py={48}>
|
||||||
|
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
|
||||||
|
<Text fz={13} c="dimmed">
|
||||||
|
No contracts yet. Create one from New Contract.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading &&
|
||||||
|
!isError &&
|
||||||
|
rows.map((c) => {
|
||||||
|
const isGeneral = c.contractKind === "GENERAL";
|
||||||
|
const isContainer = c.freightType === "CONTAINER";
|
||||||
|
const { origin, destination, count } = primaryRoute(c);
|
||||||
|
const dir = c.tradeDirection;
|
||||||
|
const tradeLabel = dir
|
||||||
|
? dir.charAt(0) + dir.slice(1).toLowerCase()
|
||||||
|
: "—";
|
||||||
|
const action = getCustomerRowAction(c);
|
||||||
|
return (
|
||||||
|
<Table.Tr
|
||||||
|
key={c.id}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/contracts/${c.id}`)}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={14} fw={700} style={{ color: INK }}>
|
||||||
|
{c.reference}
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="dimmed">
|
||||||
|
{isGeneral ? "General" : "One-Time"} ·{" "}
|
||||||
|
{isContainer ? "Containerised" : "Bulk"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={7} wrap="nowrap" align="center">
|
||||||
|
{isContainer ? (
|
||||||
|
<Package size={15} color={MUTED} />
|
||||||
|
) : (
|
||||||
|
<Weight size={15} color={MUTED} />
|
||||||
|
)}
|
||||||
|
<Text fz={13} style={{ color: INK }}>
|
||||||
|
{isContainer ? "Container" : "Bulk"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={13} style={{ color: INK }}>
|
||||||
|
{origin}{" "}
|
||||||
|
<Text span c="dimmed">
|
||||||
|
→
|
||||||
|
</Text>{" "}
|
||||||
|
{destination}
|
||||||
|
{count > 1 && (
|
||||||
|
<Text span c="dimmed" fz={12}>
|
||||||
|
{" "}
|
||||||
|
+{count - 1}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text
|
||||||
|
fz={13}
|
||||||
|
c={dir ? undefined : "dimmed"}
|
||||||
|
style={{ color: dir ? INK : undefined }}
|
||||||
|
>
|
||||||
|
{tradeLabel}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={13} style={{ color: INK }}>
|
||||||
|
{c.paymentCurrency ?? "—"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text
|
||||||
|
fz={13}
|
||||||
|
c={c.createdAt ? undefined : "dimmed"}
|
||||||
|
style={{ color: c.createdAt ? INK : undefined }}
|
||||||
|
>
|
||||||
|
{c.createdAt
|
||||||
|
? new Date(c.createdAt).toLocaleDateString()
|
||||||
|
: "—"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text
|
||||||
|
fz={13}
|
||||||
|
c={c.contractValidUntil ? undefined : "dimmed"}
|
||||||
|
style={{
|
||||||
|
color: c.contractValidUntil ? INK : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.contractValidUntil
|
||||||
|
? new Date(
|
||||||
|
c.contractValidUntil,
|
||||||
|
).toLocaleDateString()
|
||||||
|
: "—"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<ContractStatusBadge status={c.status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
variant={action.primary ? "filled" : "light"}
|
||||||
|
color="edr-green"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(action.to);
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
root: { fontWeight: 600, paddingInline: 14 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Pagination footer */}
|
||||||
|
{!isLoading && !isError && rows.length > 0 && (
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
wrap="wrap"
|
||||||
|
gap="md"
|
||||||
|
px={20}
|
||||||
|
py={14}
|
||||||
|
style={{ borderTop: `1px solid ${BORDER}`, background: "#FCFDFE" }}
|
||||||
|
>
|
||||||
|
<Group gap={10} align="center">
|
||||||
|
<Text fz={13} c="dimmed">
|
||||||
|
Rows
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
data={["10", "25", "50"]}
|
||||||
|
value={String(pagination.pageSize)}
|
||||||
|
onChange={(v) =>
|
||||||
|
v &&
|
||||||
|
setPagination({ pageIndex: 0, pageSize: Number(v) })
|
||||||
|
}
|
||||||
|
radius="md"
|
||||||
|
size="xs"
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
style={{ width: 76 }}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
<Text fz={13} c="dimmed">
|
||||||
|
{start}–{end} of {total}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap={6} align="center">
|
||||||
|
<PagerButton
|
||||||
|
icon={<ChevronLeft size={16} />}
|
||||||
|
disabled={pageIndex === 0}
|
||||||
|
onClick={() => goToPage(pageIndex - 1)}
|
||||||
|
ariaLabel="Previous page"
|
||||||
|
/>
|
||||||
|
{pageNumbers(pageIndex, pageCount).map((p, i) =>
|
||||||
|
p === "…" ? (
|
||||||
|
<Text key={`gap-${i}`} fz={13} c="dimmed" px={4}>
|
||||||
|
…
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<PageChip
|
||||||
|
key={p}
|
||||||
|
page={p}
|
||||||
|
active={p === pageIndex}
|
||||||
|
onClick={() => goToPage(p)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<PagerButton
|
||||||
|
icon={<ChevronRight size={16} />}
|
||||||
|
disabled={pageIndex >= pageCount - 1}
|
||||||
|
onClick={() => goToPage(pageIndex + 1)}
|
||||||
|
ariaLabel="Next page"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ColHeader({ label }: { label: string }) {
|
/** Compact page-number window with ellipses: 1 … 4 5 6 … 12. */
|
||||||
|
function pageNumbers(active: number, count: number): (number | "…")[] {
|
||||||
|
if (count <= 7) return Array.from({ length: count }, (_, i) => i);
|
||||||
|
const out: (number | "…")[] = [0];
|
||||||
|
const lo = Math.max(1, active - 1);
|
||||||
|
const hi = Math.min(count - 2, active + 1);
|
||||||
|
if (lo > 1) out.push("…");
|
||||||
|
for (let i = lo; i <= hi; i++) out.push(i);
|
||||||
|
if (hi < count - 2) out.push("…");
|
||||||
|
out.push(count - 1);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageChip({
|
||||||
|
page,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Text fz={12} fw={700} c="dimmed" style={{ letterSpacing: 0.3 }}>
|
<Box
|
||||||
{label}
|
component="button"
|
||||||
</Text>
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
minWidth: 32,
|
||||||
|
height: 32,
|
||||||
|
padding: "0 8px",
|
||||||
|
borderRadius: 9,
|
||||||
|
border: `1px solid ${active ? GREEN : BORDER}`,
|
||||||
|
background: active ? GREEN : "#FFFFFF",
|
||||||
|
color: active ? "#FFFFFF" : INK,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: active ? 700 : 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 120ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{page + 1}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PagerButton({
|
||||||
|
icon,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
ariaLabel,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 9,
|
||||||
|
border: `1px solid ${BORDER}`,
|
||||||
|
background: "#FFFFFF",
|
||||||
|
color: disabled ? "#C2CCD6" : INK,
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
transition: "all 120ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
1138
docs/docs-new
Normal file
1138
docs/docs-new
Normal file
File diff suppressed because it is too large
Load Diff
@@ -69,7 +69,8 @@ export const CONTRACT_CLEARANCE_STATUSES = [
|
|||||||
"NOT_APPLICABLE",
|
"NOT_APPLICABLE",
|
||||||
"AWAITING_DOCUMENTS",
|
"AWAITING_DOCUMENTS",
|
||||||
"DOCUMENTS_UNDER_REVIEW",
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
"CLEARANCE_READY_FOR_BOOKING",
|
"CLEARANCE_READY_FOR_BOOKING", // Path B — GL may create the booking
|
||||||
|
"SELF_CLEARED", // Path A — Operations approved; customer may book
|
||||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -225,8 +226,18 @@ export interface ContractClearanceDocument {
|
|||||||
|
|
||||||
export interface ContractClearanceView {
|
export interface ContractClearanceView {
|
||||||
contractId: string;
|
contractId: string;
|
||||||
|
/** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */
|
||||||
|
status?: string;
|
||||||
clearanceStatus: ContractClearanceStatus;
|
clearanceStatus: ContractClearanceStatus;
|
||||||
cycleNumber: number;
|
cycleNumber: number;
|
||||||
|
/**
|
||||||
|
* True when the contract bundles EDR customs clearance (Path B, GL-reviewed).
|
||||||
|
* False for Path A self-clearance, reviewed by the Operations team.
|
||||||
|
*/
|
||||||
|
includesCustoms?: boolean;
|
||||||
|
/** Resolved customer-input / GL-output clearance setting codes. */
|
||||||
|
inputCode?: string | null;
|
||||||
|
outputCode?: string | null;
|
||||||
documents: ContractClearanceDocument[];
|
documents: ContractClearanceDocument[];
|
||||||
/** True once every required customer document is APPROVED. */
|
/** True once every required customer document is APPROVED. */
|
||||||
allApproved: boolean;
|
allApproved: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user