diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 0ccc933d3..af1b47aee 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -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 { + 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 { + 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 { 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 { 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 { + 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 { + return this.contractsRepository.findAllPaginated({ + page: filter.page ?? 1, + pageSize: filter.pageSize ?? 100, + statuses: ['CLEARANCE_UNDER_REVIEW'], + customsClearingEnabled: false, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts index 91b2eba58..9b108ce75 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts @@ -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}`; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 1b5cbe8ba..055b36b61 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -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'; diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 56c3d5a63..4fcc99b81 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -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') diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 79573fcf4..ecfea0b7d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -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 { 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, diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index 101afc2fe..0461d3736 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -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 = diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 74ebd9d93..3466e6653 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -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: diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index b1a84139f..ba0d6da19 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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 = { @@ -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: [ diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 3efaa1e5e..bf2c01c1d 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -140,6 +140,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.clearanceReview, }, + { + label: "Self-Clearance Review", + href: "/dashboard/contracts/ops-clearance", + icon: , + permission: FREIGHT_PERMS.contracts.opsClearanceReview, + }, { label: "Train Schedules", href: "/dashboard/operations/train-scheduling-v2", @@ -467,10 +473,25 @@ const App = () => { } /> + + + + } + /> + } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx index 873ea412e..ff23f1930 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx @@ -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>({}); const [openQuery, setOpenQuery] = useState>({}); @@ -70,7 +76,7 @@ export function ContractClearanceReviewSection({ }); const { reviewDocument, uploadOutputDocuments, finalizeClearance } = - useContractClearanceMutations(contractId); + useContractClearanceMutations(contractId, selfClear); const customerDocs = useMemo( () => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 5487a5393..c8ebcafae 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -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) => diff --git a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts index af98d806a..984727a75 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts @@ -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) => diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 09574ce79..2469e7047 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -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); diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index 578d13ed6..31c7b38c1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -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() { - + diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx index c008b66ed..1307b528c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx @@ -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("ET"); const [query, setQuery] = useState(""); const [view, setView] = useState("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() { - { - setRegion(v as Region); - setPagination({ - pageIndex: 0, - pageSize: pagination.pageSize, - }); - }} - data={[ - { value: "ET", label: "Ethiopia" }, - { value: "DJ", label: "Djibouti" }, - ]} - /> + {!opsMode && ( + { + setRegion(v as Region); + setPagination({ + pageIndex: 0, + pageSize: pagination.pageSize, + }); + }} + data={[ + { value: "ET", label: "Ethiopia" }, + { value: "DJ", label: "Djibouti" }, + ]} + /> + )} {total} record{total !== 1 ? "s" : ""} diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index eb7156788..27d0e6c84 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -204,6 +204,26 @@ export const contractsService = { finalizeClearance: (id: string) => postContract(C.CLEARANCE_FINALIZE(id)), + // ── Path A self-clearance (Operations review) ── + getOpsClearanceQueue: async (): Promise => { + const response = await client.get( + 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(C.OPS_CLEARANCE_REVIEW(id), payload), + + opsFinalizeClearance: (id: string) => + postContract(C.OPS_CLEARANCE_FINALIZE(id)), + // ── Booking under contract (GL ET — Path B) ── createBookingUnderContract: ( id: string, diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceFlow.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceFlow.tsx index ae51304d6..873c5e3c9 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceFlow.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceFlow.tsx @@ -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={} 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."} ) : isUnderReview ? ( } 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. ) : ( } 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."} )} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 1839c1017..3490af241 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -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 ( @@ -326,8 +332,8 @@ export default function ContractDetailPage() { - {/* Path B notice */} - {customsPath && PATH_B_CLEARANCE.includes(contract.status) && ( + {/* Clearance notice (both paths) */} + {canUploadClearance && ( - Customs clearance shipment + {customsPath + ? "Customs clearance shipment" + : "Customs clearance required"} - {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."} )} @@ -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."} ) : ( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx index 096791d48..46af51ca2 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx @@ -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[] = [ - { - id: "reference", - header: () => , - cell: ({ row }) => { - const c = row.original; - const isGeneral = c.contractKind === "GENERAL"; - return ( -
- - {c.reference} - - - {isGeneral ? "General" : "One-Time"} ·{" "} - {c.freightType === "CONTAINER" ? "Containerised" : "Bulk"} - -
- ); - }, - }, - { - id: "cargo", - header: () => , - cell: ({ row }) => { - const isContainer = row.original.freightType === "CONTAINER"; - return ( - - {isContainer ? ( - - ) : ( - - )} - - {isContainer ? "Container" : "Bulk"} - - - ); - }, - }, - { - id: "route", - header: () => , - cell: ({ row }) => { - const { origin, destination, count } = primaryRoute(row.original); - return ( - - {origin}{" "} - - → - {" "} - {destination} - {count > 1 && ( - - {" "} - +{count - 1} - - )} - - ); - }, - }, - { - id: "trade", - header: () => , - cell: ({ row }) => { - const dir = row.original.tradeDirection; - const label = dir - ? dir.charAt(0) + dir.slice(1).toLowerCase() - : "—"; - return ( - - {label} - - ); - }, - }, - { - id: "currency", - header: () => , - cell: ({ row }) => ( - - {row.original.paymentCurrency ?? "—"} - - ), - }, - { - id: "created", - header: () => , - cell: ({ row }) => { - const created = row.original.createdAt; - return ( - - {created ? new Date(created).toLocaleDateString() : "—"} - - ); - }, - }, - { - id: "validUntil", - header: () => , - cell: ({ row }) => { - const until = row.original.contractValidUntil; - return ( - - {until ? new Date(until).toLocaleDateString() : "—"} - - ); - }, - }, - { - id: "status", - header: () => , - cell: ({ row }) => , - }, - { - id: "actions", - header: () => , - cell: ({ row }) => { - const action = getCustomerRowAction(row.original); - return ( - - ); - }, - }, - ]; - - 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 ( {/* Header */} - - - - Contracts - - - Your freight agreements — one-time and general. Sign a contract, - then ship against it over its validity window. - - + + + Contracts + @@ -439,6 +311,7 @@ export default function ContractsList() { radius="md" leftSection={} onClick={clearExtraFilters} + styles={{ root: { fontWeight: 600 } }} > Clear @@ -447,39 +320,351 @@ export default function ContractsList() { {/* Table */} - - - 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." - /> - + + + + + + Contract + Cargo + Route + Trade + Currency + Created + Valid Until + Status + Action + + + + {isLoading && ( + + +
+ +
+
+
+ )} + + {!isLoading && isError && ( + + +
+ + Failed to load contracts. Please try again. + +
+
+
+ )} + + {!isLoading && !isError && rows.length === 0 && ( + + + + + + No contracts yet. Create one from New Contract. + + + + + )} + + {!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 ( + navigate(`/contracts/${c.id}`)} + > + + + {c.reference} + + + {isGeneral ? "General" : "One-Time"} ·{" "} + {isContainer ? "Containerised" : "Bulk"} + + + + + {isContainer ? ( + + ) : ( + + )} + + {isContainer ? "Container" : "Bulk"} + + + + + + {origin}{" "} + + → + {" "} + {destination} + {count > 1 && ( + + {" "} + +{count - 1} + + )} + + + + + {tradeLabel} + + + + + {c.paymentCurrency ?? "—"} + + + + + {c.createdAt + ? new Date(c.createdAt).toLocaleDateString() + : "—"} + + + + + {c.contractValidUntil + ? new Date( + c.contractValidUntil, + ).toLocaleDateString() + : "—"} + + + + + + + + + + + + ); + })} +
+
+
+ + {/* Pagination footer */} + {!isLoading && !isError && rows.length > 0 && ( + + + + Rows + +