From da01b4d4533200cd32d709e5fb56f7f126f7c3a7 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 25 Jun 2026 01:50:21 +0000 Subject: [PATCH] feat: add clearance review section and update booking status handling - Introduced `ClearanceReviewSection` component for document review and approval process. - Updated booking status configuration to include new clearance statuses. - Modified `DocumentClearanceDetailPage` and `BookingRequestDetailPage` to integrate the new clearance review functionality. - Adjusted `DocumentClearanceListPage` to filter customs bookings appropriately. - Enhanced `ClearanceCard` in the portal to reflect customs clearance status. - Removed unnecessary customs-related logic from clearance tabs and document review components. --- .../booking-transition.clearance.spec.ts | 73 ++- .../modules/bookings/bookings.repository.ts | 6 + .../src/modules/bookings/bookings.service.ts | 35 +- .../src/seed/freight-permissions.registry.ts | 6 +- .../components/bookings/BookingStatusTabs.tsx | 4 + .../detail/ClearanceReviewSection.tsx | 540 +++++++++++++++++ .../src/components/bookings/detail/index.ts | 1 + .../bookings/booking-status.config.ts | 5 + .../clearance/clearance-tabs.config.ts | 13 +- .../bookings/BookingRequestDetailPage.tsx | 12 + .../bookings/DocumentClearanceDetailPage.tsx | 544 ++---------------- .../bookings/DocumentClearanceListPage.tsx | 33 +- .../backoffice/src/types/booking.ts | 15 + .../components/ClearanceCard.tsx | 13 +- 14 files changed, 762 insertions(+), 538 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index 12ce77d6d..d8de3e95a 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -63,7 +63,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => { ); }); - it('moves to CLEARANCE_READY when all required documents are APPROVED', async () => { + it('moves to CLEARANCE_READY when all required documents are APPROVED (non-customs, no output set)', async () => { const { service, bookingsRepository } = makeService([ { settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' }, { settingCode: inputSetting.code, fileKey: 'packing_list', status: 'APPROVED' }, @@ -75,3 +75,74 @@ describe('BookingTransitionService — finalizeClearance gate', () => { ); }); }); + +/** + * Customs bookings additionally require the GL output documents before + * finalizing — they are cleared by Global Logistics, not the customer alone. + */ +describe('BookingTransitionService — finalizeClearance customs output gate', () => { + const customsBooking = { + id: 'b-2', + status: 'DOCUMENTS_UNDER_REVIEW', + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + serviceType: { includesCustoms: true }, // input + output sets apply + }; + + const inputSetting = { + code: 'clearance_import_container_with_customs', + fields: [{ fileKey: 'commercial_invoice', isRequired: true }], + }; + const outputSetting = { + code: 'clearance_output_import_container', + fields: [{ fileKey: 'im4', fileLabel: 'IM4 declaration', isRequired: true }], + }; + + function makeCustomsService(uploadedOutputCodes: string[]) { + const bookingsRepository = { + findDocumentReviews: jest.fn().mockResolvedValue([ + { settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' }, + ]), + update: jest.fn().mockResolvedValue({ id: 'b-2' }), + }; + const bookingsService = { findById: jest.fn().mockResolvedValue(customsBooking) }; + const fileUploadSettingsService = { + getByCode: jest.fn((code: string) => + Promise.resolve(code === outputSetting.code ? outputSetting : inputSetting), + ), + }; + const filesService = { + findByResource: jest + .fn() + .mockResolvedValue(uploadedOutputCodes.map((code) => ({ code }))), + }; + + const service = new BookingTransitionService( + bookingsRepository as never, + {} as never, + {} as never, + {} as never, + filesService as never, + fileUploadSettingsService as never, + {} as never, + bookingsService as never, + ); + return { service, bookingsRepository }; + } + + it('rejects when required customs output documents are missing', async () => { + const { service } = makeCustomsService([]); // no output uploaded + await expect(service.finalizeClearance('b-2')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('moves to CLEARANCE_READY when input is approved and output docs are present', async () => { + const { service, bookingsRepository } = makeCustomsService(['im4']); + await service.finalizeClearance('b-2'); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-2', + expect.objectContaining({ status: 'CLEARANCE_READY' }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 6cfac85fa..e8f0a1393 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -39,6 +39,7 @@ export interface BookingListFilterOptions { paymentCurrency?: string; paymentStatus?: string; excludePaymentStatus?: string; + customsClearingEnabled?: boolean; createdFrom?: string; createdTo?: string; consolidationPaired?: string; @@ -728,6 +729,11 @@ export class BookingsRepository extends BaseRepository { excludePaymentStatus: options.excludePaymentStatus, }); } + if (options.customsClearingEnabled !== undefined) { + qb.andWhere('booking.customs_clearing_enabled = :customsClearingEnabled', { + customsClearingEnabled: options.customsClearingEnabled, + }); + } if (options.consolidationPaired === 'true') { qb.andWhere('booking.consolidation_partner_id IS NOT NULL'); } else if (options.consolidationPaired === 'false') { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 9db946d26..19994d6a8 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -26,6 +26,7 @@ import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; +import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { BookingsRepository } from './bookings.repository'; @@ -119,6 +120,18 @@ export class BookingsService { } /** Build evaluation input from booking freight shape. */ + /** + * Whether a service type bundles customs clearance. This is the single source + * of truth for a booking's `customsClearingEnabled` — the customer cannot + * diverge from it, and it decides who clears the documents (GL vs Marketing). + */ + private async resolveIncludesCustoms(serviceTypeId: string): Promise { + const serviceType = await this.dataSource + .getRepository(ServiceType) + .findOne({ where: { id: serviceTypeId } }); + return serviceType?.includesCustoms ?? false; + } + private async buildEvalInput(dto: { freightType: FreightType; cargoTypeId?: string | null; @@ -404,6 +417,11 @@ export class BookingsService { warnings.push(...ruleResult.warnings); + // Customs clearing is owned by the service type, not the customer: when the + // service includes customs, EDR/GL clears it (no external agent); otherwise + // the customer clears it themselves and may name their broker. + const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId); + const booking = await this.bookingsRepository.create({ reference, companyId: companyId ?? null, @@ -421,8 +439,8 @@ export class BookingsService { lastMileDeliveryAddress: dto.lastMileDeliveryAddress, lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null, lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null, - customsClearingEnabled: dto.customsClearingEnabled ?? false, - customsClearingAgent: dto.customsClearingAgent ?? null, + customsClearingEnabled: includesCustoms, + customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null), equipmentReturn: dto.equipmentReturn, originYardId: dto.originYardId, destinationYardId: dto.destinationYardId, @@ -652,6 +670,16 @@ export class BookingsService { if (dto.endDate) updates.endDate = new Date(dto.endDate); delete updates.containers; + // Customs clearing always mirrors the (possibly changed) service type — never + // the client payload — so it can't diverge from the service's customs scope. + const includesCustoms = await this.resolveIncludesCustoms( + dto.serviceTypeId ?? existing.serviceTypeId, + ); + updates.customsClearingEnabled = includesCustoms; + updates.customsClearingAgent = includesCustoms + ? null + : (dto.customsClearingAgent ?? existing.customsClearingAgent ?? null); + await this.bookingsRepository.update(id, updates); if (freightType === 'CONTAINER' && dto.containers) { @@ -820,6 +848,9 @@ export class BookingsService { page, pageSize, statuses, + // Global Logistics only clears customs bookings; non-customs clearance is + // reviewed by Marketing from the booking detail, not this queue. + customsClearingEnabled: true, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); 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 224c72892..12cca3f53 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -184,7 +184,9 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.uploadClearanceOutput, FREIGHT_PERMS.bookings.finalizeClearance, ], - // Marketing handles intake through contract (same as line staff here). + // Marketing handles intake through contract (same as line staff here) and, + // for non-customs bookings, reviews/finalizes the customer's clearance + // documents from the booking detail (customs bookings go to Global Logistics). marketing: [ FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.staffAccept, @@ -195,6 +197,8 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.cancel, FREIGHT_PERMS.bookings.generateContract, FREIGHT_PERMS.bookings.signStaff, + FREIGHT_PERMS.bookings.reviewDocuments, + FREIGHT_PERMS.bookings.finalizeClearance, ], orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx index c0eaaaace..e5ca1dabd 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusTabs.tsx @@ -2,9 +2,11 @@ import { Badge, ScrollArea, Tabs } from "@mantine/core"; import { CheckCircle, ClipboardCheck, + ClipboardList, FileSignature, Inbox, LayoutGrid, + ShieldCheck, Train, Wallet, XCircle, @@ -21,7 +23,9 @@ const TAB_ICONS: Record = { intake: , in_approval: , approved_contract: , + clearance: , payment: , + ops_review: , operations: , completed: , closed: , diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx new file mode 100644 index 000000000..409f698d6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -0,0 +1,540 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Alert, + Badge, + Box, + Button, + FileButton, + Group, + Loader, + Paper, + Progress, + Stack, + Text, + Textarea, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { + AlertCircle, + CheckCircle2, + Download, + ExternalLink, + FileCheck2, + FileText, + MessageSquareWarning, + Upload, +} from "lucide-react"; +import toast from "react-hot-toast"; +import type { Freight } from "@edr/types"; + +import { SectionCard } from "./SectionCard"; +import { bookingsService } from "@/services/bookings.service"; + +export interface ClearanceReviewSectionProps { + bookingId: string; + /** Called after any review/finalize mutation so the parent can refetch. */ + onChanged?: () => void; + /** Hide the inline progress summary (e.g. when the parent renders its own). */ + hideSummary?: boolean; +} + +const STATUS_META: Record< + Freight.DocumentReviewStatus, + { label: string; color: string } +> = { + APPROVED: { label: "Approved", color: "edr-green" }, + QUERIED: { label: "Queried", color: "red" }, + PENDING: { label: "Pending", color: "edr-slate" }, +}; + +/** + * Staff-facing clearance document review: approve / query each customer + * document, upload customs output documents (customs bookings only) and + * finalize once every required document is approved. Shared by the Global + * Logistics clearance detail page (customs) and the Marketing booking detail + * (non-customs) — the only difference is the output-docs block, which renders + * only when the booking has a customs output set. + */ +export function ClearanceReviewSection({ + bookingId, + onChanged, + hideSummary, +}: ClearanceReviewSectionProps) { + const qc = useQueryClient(); + const [queryNotes, setQueryNotes] = useState>({}); + const [openQuery, setOpenQuery] = useState>({}); + const [outputFiles, setOutputFiles] = useState>({}); + + const { data: clearance, isLoading } = useQuery({ + queryKey: ["clearance", bookingId], + queryFn: () => bookingsService.getClearance(bookingId), + }); + + const refresh = () => { + qc.invalidateQueries({ queryKey: ["clearance", bookingId] }); + qc.invalidateQueries({ queryKey: ["clearance", "list"] }); + onChanged?.(); + }; + + const reviewMutation = useMutation({ + mutationFn: (p: { + fileKey: string; + status: "APPROVED" | "QUERIED"; + note?: string; + }) => bookingsService.reviewClearanceDocument(bookingId, p), + onSuccess: (_d, p) => { + toast.success( + p.status === "APPROVED" ? "Document approved" : "Query sent to customer", + ); + if (p.status === "QUERIED") + setOpenQuery((o) => ({ ...o, [p.fileKey]: false })); + refresh(); + }, + onError: () => toast.error("Could not update document"), + }); + + const outputMutation = useMutation({ + mutationFn: () => bookingsService.uploadClearanceOutput(bookingId, outputFiles), + onSuccess: () => { + toast.success("Output documents uploaded"); + setOutputFiles({}); + refresh(); + }, + onError: () => toast.error("Upload failed"), + }); + + const finalizeMutation = useMutation({ + mutationFn: () => bookingsService.finalizeClearance(bookingId), + onSuccess: () => { + toast.success("Clearance finalized"); + refresh(); + }, + onError: (e) => + toast.error( + e instanceof Error ? e.message : "Could not finalize clearance", + ), + }); + + const customerDocs = useMemo( + () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"), + [clearance], + ); + const glDocs = useMemo( + () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"), + [clearance], + ); + + const stats = useMemo(() => { + const total = customerDocs.length; + const approved = customerDocs.filter( + (d) => d.reviewStatus === "APPROVED", + ).length; + const queried = customerDocs.filter( + (d) => d.reviewStatus === "QUERIED", + ).length; + const pending = total - approved - queried; + const pct = total === 0 ? 0 : Math.round((approved / total) * 100); + return { total, approved, queried, pending, pct }; + }, [customerDocs]); + + if (isLoading || !clearance) { + return ( + + + Loading clearance… + + ); + } + + return ( + + + {stats.approved}/{stats.total} approved + + } + > + + {!hideSummary && stats.total > 0 && ( + + + + + + + + + )} + {customerDocs.length === 0 ? ( + + No customer documents are required for this booking. + + ) : ( + customerDocs.map((doc) => ( + + setOpenQuery((o) => ({ ...o, [doc.fileKey]: open })) + } + onNote={(v) => + setQueryNotes((n) => ({ ...n, [doc.fileKey]: v })) + } + onApprove={() => + reviewMutation.mutate({ + fileKey: doc.fileKey, + status: "APPROVED", + }) + } + onQuery={() => + reviewMutation.mutate({ + fileKey: doc.fileKey, + status: "QUERIED", + note: queryNotes[doc.fileKey], + }) + } + busy={reviewMutation.isPending} + /> + )) + )} + + + + {clearance.outputCode && ( + + + {glDocs.map((doc) => ( + + + + + {doc.label} + {doc.required ? " *" : ""} + + + + {doc.file ? ( + + + + + + ) : ( + + Not uploaded + + )} + + f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f })) + } + accept="application/pdf,image/*" + > + {(props) => ( + + )} + + + + ))} + + + + + + )} + + {finalizeMutation.isError && ( + }> + {finalizeMutation.error instanceof Error + ? finalizeMutation.error.message + : "Could not finalize clearance."} + + )} + + + + + + + + + {clearance.allApproved + ? "All required documents are approved — you can finalize." + : "Approve every required document to unlock finalization."} + + + + + + + ); +} + +function StatPill({ + color, + label, + value, +}: { + color: string; + label: string; + value: number; +}) { + return ( + + + + {value} + + + {label} + + + ); +} + +function DocReviewCard({ + doc, + note, + queryOpen, + onToggleQuery, + onNote, + onApprove, + onQuery, + busy, +}: { + doc: Freight.ClearanceDocument; + note: string; + queryOpen: boolean; + onToggleQuery: (open: boolean) => void; + onNote: (v: string) => void; + onApprove: () => void; + onQuery: () => void; + busy: boolean; +}) { + const status = doc.reviewStatus ?? "PENDING"; + const meta = STATUS_META[status]; + const hasFile = !!doc.file; + + return ( + + + + + + + + + {doc.label} + {doc.required ? " *" : ""} + + + {hasFile ? doc.file!.name : "Not uploaded by customer"} + + + + + + + {meta.label} + + {hasFile && ( + + + + )} + + + + {status === "QUERIED" && doc.note && ( + } + p="xs" + > + + {doc.note} + + + )} + + {hasFile && ( + + {!queryOpen ? ( + + + + + ) : ( + + + + + Describe the problem for the customer + + +