mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 13:40:57 +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(
|
||||
contractId: string,
|
||||
fileKey: string,
|
||||
status: 'APPROVED' | 'QUERIED',
|
||||
staffId: 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> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
||||
@@ -280,7 +322,7 @@ export class ContractClearanceService {
|
||||
`Document "${fileKey}" queried: ${note}`,
|
||||
'CHANGES_REQUESTED',
|
||||
staffId,
|
||||
'GL_ET',
|
||||
reviewerRole,
|
||||
);
|
||||
// Return the contract to the customer to re-upload the queried document.
|
||||
await this.contractsRepository.update(contractId, {
|
||||
@@ -325,11 +367,17 @@ export class ContractClearanceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* GL ET finalizes pre-booking clearance: requires every customer document
|
||||
* APPROVED (and required output docs present) → CLEARANCE_READY_FOR_BOOKING.
|
||||
* GL ET finalizes Path B pre-booking clearance: requires every customer
|
||||
* document APPROVED and required output docs present → CLEARANCE_READY_FOR_BOOKING
|
||||
* (GL then creates the booking). Rejects self-clearance (Path A) contracts.
|
||||
*/
|
||||
async finalize(contractId: string): Promise<Contract> {
|
||||
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') {
|
||||
throw new ConflictException(
|
||||
`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
|
||||
* CLEARANCE_UNDER_REVIEW (customs contracts only).
|
||||
* Operations finalizes Path A self-clearance: requires every customer document
|
||||
* 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(
|
||||
filter: FilterContractDto,
|
||||
@@ -388,6 +474,22 @@ export class ContractClearanceService {
|
||||
page: filter.page ?? 1,
|
||||
pageSize: filter.pageSize ?? 100,
|
||||
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,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
|
||||
@@ -20,16 +20,28 @@ function freightFor(freightType: string): Freight {
|
||||
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(
|
||||
tradeDirection: string,
|
||||
freightType: string,
|
||||
includesCustoms: boolean,
|
||||
): string | null {
|
||||
if (!includesCustoms) return null;
|
||||
const op = operationFor(tradeDirection);
|
||||
if (!op) return null;
|
||||
const freight = freightFor(freightType);
|
||||
if (!includesCustoms) {
|
||||
return `contract_clearance_selfclear_${op}_${freight}`;
|
||||
}
|
||||
return `contract_clearance_${op}_${freight}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { contractClearanceSettingCode } from './contract-clearance.util';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractSignerRole } from './entities/contract-signature.entity';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
@@ -441,9 +442,14 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff/Director/CEO counter-sign → branch on customs:
|
||||
* - customs: AWAITING_CLEARANCE_DOCUMENTS + clearance gate opened (Path B)
|
||||
* - transport: FULLY_EXECUTED (ONE_TIME) / CONTRACT_ACTIVE (GENERAL)
|
||||
* Staff/Director/CEO counter-sign → branch on the execution path. A customs
|
||||
* border (IMPORT/EXPORT) always requires a clearance gate before any shipment;
|
||||
* 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(
|
||||
contractId: string,
|
||||
@@ -461,9 +467,19 @@ export class ContractTransitionService {
|
||||
lockedAt: now,
|
||||
};
|
||||
|
||||
if (contract.customsClearingEnabled) {
|
||||
// Path B — open a clearance cycle, seed the pre-booking milestones, and
|
||||
// route the customer to the document upload.
|
||||
// A clearance gate applies whenever a clearance doc set resolves — Path B
|
||||
// (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC
|
||||
// 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 cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
||||
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
||||
@@ -471,7 +487,7 @@ export class ContractTransitionService {
|
||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||
updates.clearanceCycleNumber = cycleNumber;
|
||||
} else {
|
||||
// Path A — transport only; ready for the customer to book.
|
||||
// No clearance gate (DOMESTIC) — ready for the customer to book directly.
|
||||
updates.status =
|
||||
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
||||
updates.clearanceStatus = 'NOT_APPLICABLE';
|
||||
|
||||
@@ -405,6 +405,45 @@ export class ContractsController {
|
||||
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) ────────────────
|
||||
|
||||
@Post(':id/bookings')
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ContractListFilterOptions {
|
||||
freightType?: string;
|
||||
tradeDirection?: string;
|
||||
paymentCurrency?: string;
|
||||
customsClearingEnabled?: boolean;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
}
|
||||
@@ -209,6 +210,11 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
contractKind: options.contractKind,
|
||||
});
|
||||
}
|
||||
if (options.customsClearingEnabled !== undefined) {
|
||||
qb.andWhere('contract.customs_clearing_enabled = :customsClearingEnabled', {
|
||||
customsClearingEnabled: options.customsClearingEnabled,
|
||||
});
|
||||
}
|
||||
if (options.serviceTypeId) {
|
||||
qb.andWhere('contract.service_type_id = :serviceTypeId', {
|
||||
serviceTypeId: options.serviceTypeId,
|
||||
|
||||
@@ -49,7 +49,8 @@ export const CONTRACT_CLEARANCE_STATUSES = [
|
||||
'NOT_APPLICABLE',
|
||||
'AWAITING_DOCUMENTS',
|
||||
'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',
|
||||
] as const;
|
||||
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 ────────────────────────────────────────────────
|
||||
// Commercial/framework documents attached at contract submission (wizard step 5),
|
||||
// distinct from the post-sign clearance docs above.
|
||||
@@ -456,6 +511,11 @@ export class FileUploadSettingsSeeder {
|
||||
description:
|
||||
"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) => ({
|
||||
...s,
|
||||
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-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'),
|
||||
perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'),
|
||||
perm('a3000001-0001-4000-8000-00000000000d', 'edr_freight_app:contracts:ops_clearance_review', 'Operations review of self-clearance docs (Path A)'),
|
||||
];
|
||||
|
||||
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',
|
||||
finalizeClearance: 'edr_freight_app:contracts:finalize_clearance',
|
||||
createBooking: 'edr_freight_app:contracts:create_booking',
|
||||
opsClearanceReview: 'edr_freight_app:contracts:ops_clearance_review',
|
||||
},
|
||||
trainScheduling: {
|
||||
view: 'edr_freight_app:train_scheduling:view',
|
||||
@@ -196,6 +198,10 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.trainScheduling.manage,
|
||||
FREIGHT_PERMS.fleet.view,
|
||||
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(),
|
||||
],
|
||||
director: [
|
||||
|
||||
@@ -140,6 +140,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.contracts.clearanceReview,
|
||||
},
|
||||
{
|
||||
label: "Self-Clearance Review",
|
||||
href: "/dashboard/contracts/ops-clearance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||
},
|
||||
{
|
||||
label: "Train Schedules",
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
@@ -467,10 +473,25 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contracts/ops-clearance"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
|
||||
>
|
||||
<ContractClearanceListPage opsMode />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contracts/clearance/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||
]}
|
||||
>
|
||||
<ContractClearanceDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
|
||||
@@ -39,6 +39,11 @@ export interface ContractClearanceReviewSectionProps {
|
||||
onChanged?: () => void;
|
||||
/** Hide the inline progress summary (e.g. when the parent renders its own). */
|
||||
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<
|
||||
@@ -59,6 +64,7 @@ export function ContractClearanceReviewSection({
|
||||
contractId,
|
||||
onChanged,
|
||||
hideSummary,
|
||||
selfClear = false,
|
||||
}: ContractClearanceReviewSectionProps) {
|
||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
||||
@@ -70,7 +76,7 @@ export function ContractClearanceReviewSection({
|
||||
});
|
||||
|
||||
const { reviewDocument, uploadOutputDocuments, finalizeClearance } =
|
||||
useContractClearanceMutations(contractId);
|
||||
useContractClearanceMutations(contractId, selfClear);
|
||||
|
||||
const customerDocs = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -143,6 +143,12 @@ export const URL_CONSTANTS = {
|
||||
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
|
||||
`/contracts/${id}/clearance/output-documents`,
|
||||
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`,
|
||||
MILESTONES: (id: string) => `/contracts/${id}/milestones`,
|
||||
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) {
|
||||
return useQuery({
|
||||
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 refresh = () => {
|
||||
@@ -196,7 +212,10 @@ export function useContractClearanceMutations(contractId: string) {
|
||||
fileKey: string;
|
||||
status: "APPROVED" | "QUERIED";
|
||||
note?: string;
|
||||
}) => contractsService.reviewClearanceDocument(contractId, p),
|
||||
}) =>
|
||||
selfClear
|
||||
? contractsService.opsReviewClearanceDocument(contractId, p)
|
||||
: contractsService.reviewClearanceDocument(contractId, p),
|
||||
onSuccess: (_d, p) => {
|
||||
toast.success(
|
||||
p.status === "APPROVED"
|
||||
@@ -219,9 +238,16 @@ export function useContractClearanceMutations(contractId: string) {
|
||||
});
|
||||
|
||||
const finalizeClearance = useMutation({
|
||||
mutationFn: () => contractsService.finalizeClearance(contractId),
|
||||
mutationFn: () =>
|
||||
selfClear
|
||||
? contractsService.opsFinalizeClearance(contractId)
|
||||
: contractsService.finalizeClearance(contractId),
|
||||
onSuccess: () => {
|
||||
toast.success("Clearance finalized — ready for booking");
|
||||
toast.success(
|
||||
selfClear
|
||||
? "Clearance approved — customer can now book"
|
||||
: "Clearance finalized — ready for booking",
|
||||
);
|
||||
refresh();
|
||||
},
|
||||
onError: (e) =>
|
||||
|
||||
@@ -33,6 +33,7 @@ export const FREIGHT_PERMS = {
|
||||
clearanceReview: "edr_freight_app:contracts:clearance_review",
|
||||
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
|
||||
createBooking: "edr_freight_app:contracts:create_booking",
|
||||
opsClearanceReview: "edr_freight_app:contracts:ops_clearance_review",
|
||||
},
|
||||
trainScheduling: {
|
||||
view: "edr_freight_app:train_scheduling:view",
|
||||
@@ -115,6 +116,13 @@ export function canCreateContractBooking(
|
||||
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). */
|
||||
export function canViewClearance(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.bookings.reviewDocuments);
|
||||
|
||||
@@ -66,7 +66,11 @@ export default function ContractClearanceDetailPage() {
|
||||
}, [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 =
|
||||
!selfClear &&
|
||||
clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" &&
|
||||
canCreateContractBooking(user);
|
||||
|
||||
@@ -155,7 +159,11 @@ export default function ContractClearanceDetailPage() {
|
||||
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<ContractClearanceReviewSection contractId={id!} hideSummary />
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={selfClear}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
|
||||
@@ -40,7 +40,10 @@ import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { KpiStrip } from "@/components/page/KpiStrip";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
import {
|
||||
useContractClearanceQueue,
|
||||
useOpsClearanceQueue,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
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 [region, setRegion] = useState<Region>("ET");
|
||||
const [query, setQuery] = useState("");
|
||||
const [view, setView] = useState<ViewMode>("table");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } =
|
||||
useContractClearanceQueue(region);
|
||||
const glQueue = useContractClearanceQueue(region, !opsMode);
|
||||
const opsQueue = useOpsClearanceQueue(opsMode);
|
||||
const { data, isLoading, isError, isFetching, refetch } = opsMode
|
||||
? opsQueue
|
||||
: glQueue;
|
||||
|
||||
const allRows = useMemo(
|
||||
() => (data?.items ?? []).map(toClearanceRow),
|
||||
@@ -238,8 +254,12 @@ export default function ContractClearanceListPage() {
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Contract Clearance"
|
||||
subtitle="Review pre-booking clearance documents on contracts before Global Logistics creates the shipment booking."
|
||||
title={opsMode ? "Self-Clearance Review" : "Contract Clearance"}
|
||||
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={
|
||||
<Badge
|
||||
variant="light"
|
||||
@@ -320,22 +340,24 @@ export default function ContractClearanceListPage() {
|
||||
style={{ flex: 1, minWidth: 220 }}
|
||||
/>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={region}
|
||||
onChange={(v) => {
|
||||
setRegion(v as Region);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}}
|
||||
data={[
|
||||
{ value: "ET", label: "Ethiopia" },
|
||||
{ value: "DJ", label: "Djibouti" },
|
||||
]}
|
||||
/>
|
||||
{!opsMode && (
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={region}
|
||||
onChange={(v) => {
|
||||
setRegion(v as Region);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}}
|
||||
data={[
|
||||
{ value: "ET", label: "Ethiopia" },
|
||||
{ value: "DJ", label: "Djibouti" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
|
||||
@@ -204,6 +204,26 @@ export const contractsService = {
|
||||
finalizeClearance: (id: string) =>
|
||||
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) ──
|
||||
createBookingUnderContract: (
|
||||
id: string,
|
||||
|
||||
@@ -129,10 +129,17 @@ export default function ContractClearanceFlow() {
|
||||
const isUnderReview = status === "DOCUMENTS_UNDER_REVIEW";
|
||||
const isReady =
|
||||
status === "CLEARANCE_READY_FOR_BOOKING" ||
|
||||
status === "SELF_CLEARED" ||
|
||||
status === "ACTIVE_SHIPMENT_IN_PROGRESS";
|
||||
const canUpload = status === "AWAITING_DOCUMENTS" || isUnderReview;
|
||||
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(
|
||||
() => customerDocs.filter((d) => d.required && !d.file && !pending[d.fileKey]),
|
||||
[customerDocs, pending],
|
||||
@@ -209,8 +216,9 @@ export default function ContractClearanceFlow() {
|
||||
icon={<CheckCircle2 size={18} />}
|
||||
mb="md"
|
||||
>
|
||||
Your clearance documents are approved. Global Logistics will
|
||||
create your booking — you will be notified when payment is due.
|
||||
{customsPath
|
||||
? "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>
|
||||
) : isUnderReview ? (
|
||||
<Alert
|
||||
@@ -219,9 +227,9 @@ export default function ContractClearanceFlow() {
|
||||
icon={<Clock size={18} />}
|
||||
mb="md"
|
||||
>
|
||||
Global Logistics is reviewing your documents. Only re-upload the
|
||||
documents flagged with a query below — approved documents stay as
|
||||
they are.
|
||||
{reviewer.charAt(0).toUpperCase() + reviewer.slice(1)} is
|
||||
reviewing your documents. Only re-upload the documents flagged
|
||||
with a query below — approved documents stay as they are.
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert
|
||||
@@ -230,9 +238,9 @@ export default function ContractClearanceFlow() {
|
||||
icon={<AlertCircle size={18} />}
|
||||
mb="md"
|
||||
>
|
||||
Upload every required clearance document (marked *) below to start
|
||||
the review. Global Logistics will clear your shipment and create
|
||||
the booking for you.
|
||||
{customsPath
|
||||
? "Upload every required clearance document (marked *) below to start the review. Global Logistics will clear your shipment and create 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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -46,10 +46,14 @@ import {
|
||||
MUTED,
|
||||
} 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"];
|
||||
// Statuses where Path B customers upload clearance docs on the contract.
|
||||
const PATH_B_CLEARANCE = [
|
||||
// Statuses where the customer uploads clearance documents on the contract. Used
|
||||
// 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",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
@@ -120,12 +124,14 @@ export default function ContractDetailPage() {
|
||||
|
||||
const canSign = contract.status === "CONTRACT_READY";
|
||||
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 =
|
||||
!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 =
|
||||
customsPath && PATH_B_CLEARANCE.includes(contract.status);
|
||||
CLEARANCE_UPLOAD_STATUSES.includes(contract.status);
|
||||
|
||||
return (
|
||||
<Box style={{ padding: "28px 32px 40px" }}>
|
||||
@@ -326,8 +332,8 @@ export default function ContractDetailPage() {
|
||||
</Paper>
|
||||
|
||||
|
||||
{/* Path B notice */}
|
||||
{customsPath && PATH_B_CLEARANCE.includes(contract.status) && (
|
||||
{/* Clearance notice (both paths) */}
|
||||
{canUploadClearance && (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="lg"
|
||||
@@ -352,15 +358,23 @@ export default function ContractDetailPage() {
|
||||
<Group gap={10} align="center" mb={6}>
|
||||
<Upload size={16} color={GREEN} />
|
||||
<Text fw={700} fz={15} c={INK}>
|
||||
Customs clearance shipment
|
||||
{customsPath
|
||||
? "Customs clearance shipment"
|
||||
: "Customs clearance required"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={13} c="dimmed">
|
||||
{contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
|
||||
? "Upload your clearance documents so Global Logistics can review them. After approval, Global Logistics creates your booking — you only pay the freight."
|
||||
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
||||
? "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."}
|
||||
{customsPath
|
||||
? contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
|
||||
? "Upload your clearance documents so Global Logistics can review them. After approval, Global Logistics creates your booking — you only pay the freight."
|
||||
: contract.status === "CLEARANCE_UNDER_REVIEW"
|
||||
? "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>
|
||||
</Paper>
|
||||
)}
|
||||
@@ -676,7 +690,9 @@ export default function ContractDetailPage() {
|
||||
? "No bookings yet. Use “New booking” to ship against this contract."
|
||||
: customsPath
|
||||
? "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>
|
||||
</Stack>
|
||||
) : (
|
||||
|
||||
@@ -4,18 +4,23 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
FileStack,
|
||||
Inbox,
|
||||
Package,
|
||||
Plus,
|
||||
Search,
|
||||
@@ -27,13 +32,15 @@ import {
|
||||
import { api } from "@/services/api";
|
||||
import type { ContractListFilter } from "@/services/contracts.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { usePagination } from "@edr/ui-common";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
type ColumnDef,
|
||||
usePagination,
|
||||
} from "@edr/ui-common";
|
||||
import { BORDER, ContractStatusBadge, INK, MUTED, StatCard } from "./contract-ui";
|
||||
BORDER,
|
||||
ContractStatusBadge,
|
||||
GREEN,
|
||||
INK,
|
||||
MUTED,
|
||||
StatCard,
|
||||
} from "./contract-ui";
|
||||
|
||||
function primaryRoute(contract: Freight.IContract) {
|
||||
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. */
|
||||
function getCustomerRowAction(
|
||||
contract: Freight.IContract,
|
||||
): { label: string; to: string } {
|
||||
): { label: string; to: string; primary: boolean } {
|
||||
const id = contract.id;
|
||||
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") {
|
||||
return { label: "Edit & resubmit", to: `/contracts/${id}` };
|
||||
return { label: "Edit & resubmit", to: `/contracts/${id}`, primary: true };
|
||||
}
|
||||
if (
|
||||
contract.customsClearingEnabled &&
|
||||
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 (
|
||||
!contract.customsClearingEnabled &&
|
||||
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() {
|
||||
@@ -161,176 +176,33 @@ export default function ContractsList() {
|
||||
return { active, pending, total };
|
||||
}, [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 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 (
|
||||
<Box style={{ padding: "28px 32px 32px" }}>
|
||||
<Stack gap="lg">
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
|
||||
<Box>
|
||||
<Title
|
||||
order={1}
|
||||
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>
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
Contracts
|
||||
</Title>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => navigate("/contracts/new", { state: { fresh: true } })}
|
||||
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
|
||||
>
|
||||
New Contract
|
||||
</Button>
|
||||
@@ -439,6 +311,7 @@ export default function ContractsList() {
|
||||
radius="md"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={clearExtraFilters}
|
||||
styles={{ root: { fontWeight: 600 } }}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
@@ -447,39 +320,351 @@ export default function ContractsList() {
|
||||
</Paper>
|
||||
|
||||
{/* Table */}
|
||||
<Card p={0} style={{ overflow: "hidden" }}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={dataTableStatus}
|
||||
onRowClick={(row) =>
|
||||
navigate(`/contracts/${(row as Freight.IContract).id}`)
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
footer={DataTableFooter}
|
||||
emptyMessage="No contracts yet. Create one from New Contract."
|
||||
/>
|
||||
</Card>
|
||||
<Paper
|
||||
withBorder
|
||||
radius="lg"
|
||||
style={{ borderColor: BORDER, overflow: "hidden" }}
|
||||
>
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table
|
||||
verticalSpacing={14}
|
||||
horizontalSpacing={20}
|
||||
highlightOnHover
|
||||
styles={{
|
||||
th: {
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.05em",
|
||||
textTransform: "uppercase",
|
||||
color: MUTED,
|
||||
background: "#F8FAFC",
|
||||
borderBottom: `1px solid ${BORDER}`,
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
td: {
|
||||
borderBottom: `1px solid ${BORDER}`,
|
||||
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>
|
||||
</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 (
|
||||
<Text fz={12} fw={700} c="dimmed" style={{ letterSpacing: 0.3 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Box
|
||||
component="button"
|
||||
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",
|
||||
"AWAITING_DOCUMENTS",
|
||||
"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",
|
||||
] as const;
|
||||
|
||||
@@ -225,8 +226,18 @@ export interface ContractClearanceDocument {
|
||||
|
||||
export interface ContractClearanceView {
|
||||
contractId: string;
|
||||
/** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */
|
||||
status?: string;
|
||||
clearanceStatus: ContractClearanceStatus;
|
||||
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[];
|
||||
/** True once every required customer document is APPROVED. */
|
||||
allApproved: boolean;
|
||||
|
||||
Reference in New Issue
Block a user