From 23b353999a9ac2e8a235ff3a0ba0c61edc4a78cd Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 25 Jun 2026 01:28:45 +0000 Subject: [PATCH] feat: enhance cargo details handling and add document clearance features - Improved handling of commodity selection in the cargo details form to prevent unwanted resets on re-renders. - Added `isReefer` flag to the CreateBookingDto interface for booking-level refrigerated status. - Introduced a new configuration file for clearance tabs to manage document clearance views. - Implemented DocumentClearanceDetailPage for detailed review and management of clearance documents. - Created DocumentClearanceListPage for listing and filtering clearance bookings with enhanced UI components. --- .../bookings/booking-pricing.service.ts | 6 + .../src/modules/bookings/bookings.service.ts | 20 + .../bookings/dto/create-booking.dto.ts | 11 + .../rule-engine/rule-engine.service.ts | 54 +- .../src/seed/pricing-data.seeder.ts | 8 +- apps/edr-freight-web/backoffice/src/App.tsx | 13 +- .../clearance/clearance-tabs.config.ts | 26 + .../bookings/DocumentClearanceDetailPage.tsx | 731 +++++++++++++++++ .../bookings/DocumentClearanceListPage.tsx | 615 ++++++++++++++ .../src/pages/bookings/GlClearancePage.tsx | 765 ------------------ .../src/pages/bookings/NewBookingPage.tsx | 22 +- .../bookings/new-booking-form/shared.tsx | 6 +- .../bookings/new-booking-form/step4-route.tsx | 43 +- .../new-booking-form/step5-cargo-details.tsx | 22 +- packages/types/src/freight/index.ts | 2 + 15 files changed, 1545 insertions(+), 799 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/features/clearance/clearance-tabs.config.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceListPage.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 0315de60d..f9d8fb17e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -276,6 +276,12 @@ export class BookingPricingService { allowConsolidation, shippingLineId: booking.shippingLineId, totalWagons, + // Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge). + // Container freight carries 0 here — its surcharges scale by container count. + bulkTons: + booking.freightType === 'BULK' + ? Number(booking.cargoTotalWeightVgm ?? 0) + : 0, containers, }; } 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 699d6ac22..9db946d26 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -126,8 +126,10 @@ export class BookingsService { paymentCurrency: string; tradeDirection: string; isHazardous?: boolean; + isReefer?: boolean; isGovernment?: boolean; shippingLineId?: string | null; + bulkTons?: number; containers: CreateBookingContainerDto[]; }): Promise { const containerLines = @@ -166,10 +168,14 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency, tradeDirection: dto.tradeDirection, isHazardous: dto.isHazardous ?? false, + // Bulk reefer comes from the customer toggle; container reefer is derived + // from the container type and ORed in by the engine. + isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false, isGovernment: dto.isGovernment ?? false, allowConsolidation, shippingLineId: dto.shippingLineId, totalWagons, + bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0, containers, }; } @@ -387,8 +393,10 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency, tradeDirection, isHazardous: dto.isHazardous, + isReefer: dto.isReefer, isGovernment, shippingLineId: dto.shippingLineId, + bulkTons: dto.cargoTotalWeightVgm, containers, }); const ruleResult = await this.ruleEngineService.evaluate(evalInput); @@ -425,6 +433,10 @@ export class BookingsService { shippingLineId: dto.shippingLineId, cargoTotalWeightVgm: dto.cargoTotalWeightVgm, isHazardous: dto.isHazardous ?? false, + // Bulk reefer is the customer's toggle; container reefer is derived from + // the container type at pricing time, so the booking-level flag stays off + // for container freight to avoid double-counting. + isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false, paymentCurrency: dto.paymentCurrency, pnrCode: dto.pnrCode, financialTerms: dto.financialTerms, @@ -586,7 +598,9 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, tradeDirection, isHazardous: dto.isHazardous ?? existing.isHazardous, + isReefer: dto.isReefer ?? existing.isReefer, shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined, + bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0), containers, }); @@ -606,6 +620,12 @@ export class BookingsService { ...dto, freightType, cargoTypeId: freightType === 'BULK' ? cargoTypeId : null, + // Booking-level reefer is only meaningful for bulk; container reefer is + // derived from the container type at pricing time. + isReefer: + freightType === 'BULK' + ? (dto.isReefer ?? existing.isReefer ?? false) + : false, priorityScore: ruleResult.priorityScore, tradeDirection, }; diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 7f420d3f1..ee5faa465 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -306,6 +306,17 @@ export class CreateBookingDto { @Transform(({ value }) => value === 'true' || value === true) isHazardous?: boolean; + /** + * Booking-level refrigerated flag. For bulk freight this is the customer's + * reefer choice (containers derive reefer from the container type instead). + * ORed with per-container reefer when the REEFER surcharge is evaluated. + */ + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isReefer?: boolean; + @ApiProperty({ enum: PAYMENT_CURRENCIES }) @IsIn([...PAYMENT_CURRENCIES]) paymentCurrency!: string; diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index ce0082f83..16027f9c3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -57,6 +57,12 @@ export interface BookingEvaluationInput { allowConsolidation?: boolean; shippingLineId?: string | null; totalWagons: number; + /** + * Total bulk tonnage on the booking (cargoTotalWeightVgm). Used to scale + * PER_TON surcharges (e.g. the bulk reefer surcharge). 0/undefined for + * container freight, which is scaled by container count instead. + */ + bulkTons?: number; containers: BookingContainerEvalInput[]; } @@ -224,16 +230,46 @@ export class RuleEngineService { }); if (!triggered) continue; - let triggerValue: number | null = null; - let calculatedAmount = Number(rate.rateValue); + // Surcharges scale by their own rateUnit, so the same trigger can bill the + // right way per freight shape — e.g. a PER_TON reefer rate multiplies the + // bulk tonnage, while a PER_CONTAINER reefer rate multiplies the container + // count. triggerValue records the quantity billed (shown on the breakdown). + const rateValue = Number(rate.rateValue); + const containerCount = input.containers.reduce( + (sum, c) => sum + Number(c.quantity || 0), + 0, + ); + const overweightExcessTons = containerWeightResults.reduce( + (sum, r) => sum + (r.overweightExcessTons ?? 0), + 0, + ); - // Per-ton surcharges (typically OVERWEIGHT) bill against the excess tons. - if (rate.rateUnit === 'PER_TON' && rate.trigger === 'OVERWEIGHT') { - triggerValue = containerWeightResults.reduce( - (sum, r) => sum + (r.overweightExcessTons ?? 0), - 0, - ); - calculatedAmount = triggerValue * Number(rate.rateValue); + let triggerValue: number | null = null; + let calculatedAmount: number; + + switch (rate.rateUnit) { + case 'PER_TON': + // OVERWEIGHT bills the excess tons; every other PER_TON surcharge + // (e.g. bulk reefer) bills the full bulk tonnage. + triggerValue = + rate.trigger === 'OVERWEIGHT' + ? overweightExcessTons + : Number(input.bulkTons ?? 0); + calculatedAmount = triggerValue * rateValue; + break; + case 'PER_CONTAINER': + triggerValue = containerCount; + calculatedAmount = triggerValue * rateValue; + break; + case 'PER_WAGON': + triggerValue = input.totalWagons; + calculatedAmount = triggerValue * rateValue; + break; + case 'FLAT': + default: + // FLAT (and any unknown unit) bills once. + calculatedAmount = rateValue; + break; } // Safety guard: never include a surcharge with a non-positive amount (a diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index e4153c747..57fa1dc23 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -474,7 +474,13 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { // ── Surcharges (trigger-based) ────────────────────────────────────── { appliesTo: "OTHER", trigger: "OVERWEIGHT", rateType: "OVERWEIGHT_PER_TON", rateValue: 25, rateUnit: "PER_TON" }, { appliesTo: "OTHER", trigger: "HAZARDOUS", rateType: "HAZARD_SURCHARGE", rateValue: 150, rateUnit: "FLAT" }, - { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 200, rateUnit: "FLAT" }, + // Reefer surcharge scales with the freight shape: container bookings bill + // per reefer container, bulk bookings bill per ton. The engine now honors + // each rate's unit, so both rows can coexist — only the matching one + // produces a non-zero line (the other multiplies by 0 and is dropped). + // Small test values (< 20) so the surcharge stays a minor add for now. + { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 15, rateUnit: "PER_CONTAINER" }, + { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 2, rateUnit: "PER_TON" }, { appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, ]; diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 05b976c85..7a14e3ffa 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -28,7 +28,8 @@ import LoginPage from "./pages/auth/LoginPage"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; -import GlClearancePage from "./pages/bookings/GlClearancePage"; +import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage"; +import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; @@ -417,7 +418,15 @@ const App = () => { path="clearance" element={ - + + + } + /> + + } /> diff --git a/apps/edr-freight-web/backoffice/src/features/clearance/clearance-tabs.config.ts b/apps/edr-freight-web/backoffice/src/features/clearance/clearance-tabs.config.ts new file mode 100644 index 000000000..b0166aa3f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/clearance/clearance-tabs.config.ts @@ -0,0 +1,26 @@ +import type { LucideIcon } from "lucide-react"; +import { Layers, ShieldCheck, ShipWheel, Truck } from "lucide-react"; + +/** + * The document-clearance queue is a single backend status + * (`DOCUMENTS_UNDER_REVIEW`); the tabs slice that queue by the operational axis + * that matters to a clearance officer — trade direction and customs scope — + * rather than by booking status (which is uniform here). + */ +export type ClearanceTabKey = "all" | "import" | "export" | "customs"; + +export interface ClearanceTab { + key: ClearanceTabKey; + label: string; + icon: LucideIcon; +} + +export const CLEARANCE_TABS: ClearanceTab[] = [ + { key: "all", label: "All", icon: Layers }, + { key: "import", label: "Import", icon: Truck }, + { key: "export", label: "Export", icon: ShipWheel }, + { key: "customs", label: "With customs", icon: ShieldCheck }, +]; + +/** The backend booking status that places a booking in the clearance queue. */ +export const CLEARANCE_REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW"; diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx new file mode 100644 index 000000000..9109ff9fe --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -0,0 +1,731 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Alert, + Badge, + Box, + Button, + FileButton, + Grid, + Group, + Loader, + Paper, + Progress, + RingProgress, + Stack, + Text, + Textarea, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { + AlertCircle, + ArrowRight, + CheckCircle2, + Clock, + Download, + ExternalLink, + FileCheck2, + FileText, + MessageSquareWarning, + PackageCheck, + ShieldCheck, + Upload, +} from "lucide-react"; +import toast from "react-hot-toast"; +import type { Freight } from "@edr/types"; + +import { PageContainer } from "@/components/page/PageContainer"; +import { PageHeader } from "@/components/page/PageHeader"; +import { SectionCard } from "@/components/bookings/detail"; +import { bookingsService } from "@/services/bookings.service"; +import { useBookingDetail } from "@/hooks/bookings/useBookings"; + +export default function DocumentClearanceDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const qc = useQueryClient(); + + const { data: booking } = useBookingDetail(id); + const { + data: clearance, + isLoading, + isError, + } = useQuery({ + queryKey: ["clearance", id], + queryFn: () => bookingsService.getClearance(id!), + enabled: Boolean(id), + }); + + const [queryNotes, setQueryNotes] = useState>({}); + const [openQuery, setOpenQuery] = useState>({}); + const [outputFiles, setOutputFiles] = useState>({}); + + const refresh = () => { + qc.invalidateQueries({ queryKey: ["clearance", id] }); + qc.invalidateQueries({ queryKey: ["clearance", "list"] }); + }; + + const reviewMutation = useMutation({ + mutationFn: (p: { + fileKey: string; + status: "APPROVED" | "QUERIED"; + note?: string; + }) => bookingsService.reviewClearanceDocument(id!, 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(id!, outputFiles), + onSuccess: () => { + toast.success("Output documents uploaded"); + setOutputFiles({}); + refresh(); + }, + onError: () => toast.error("Upload failed"), + }); + + const finalizeMutation = useMutation({ + mutationFn: () => bookingsService.finalizeClearance(id!), + onSuccess: () => { + toast.success("Clearance finalized"); + refresh(); + navigate("/dashboard/clearance"); + }, + 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]); + + const reference = booking?.reference ?? "Clearance"; + + if (isLoading) { + return ( + + + + Loading clearance… + + + ); + } + + if (isError || !clearance) { + return ( + + + }> + We couldn’t load this booking’s clearance. + + + ); + } + + return ( + + + } + > + All approved + + ) : ( + } + > + Review pending + + ) + } + /> + + {/* Hero */} + + + + {/* LEFT — document review */} + + + + {stats.approved}/{stats.total} approved + + } + > + + {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) => ( + + )} + + + + ))} + + + + + + )} + + + + {/* RIGHT — sticky summary + finalize */} + + + + + + + + {stats.pct}% + + + approved + + + } + /> + + + + + + + + + {finalizeMutation.isError && ( + } + > + {finalizeMutation.error instanceof Error + ? finalizeMutation.error.message + : "Could not finalize clearance."} + + )} + + + + + + + + Finalize clearance + + + + {clearance.allApproved + ? "All required documents are approved — you can finalize." + : "Approve every required document to unlock finalization."} + + + + + + + + + + ); +} + +function ClearanceHero({ + booking, + clearance, + stats, +}: { + booking: ReturnType["data"]; + clearance: Freight.ClearanceView; + stats: { pct: number; approved: number; total: number }; +}) { + const direction = booking?.tradeDirection ?? "—"; + const origin = + booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin"; + const destination = + booking?.destinationYard?.label ?? + booking?.destinationYard?.code ?? + "Destination"; + + return ( + + + + + + + + + + {booking?.reference ?? "Clearance"} + + + {direction} + + {clearance.includesCustoms ? ( + } + > + Customs + + ) : null} + + + + {origin} + + + + {destination} + + + + + + + + + Document review + + + {stats.approved}/{stats.total} + + + + + + + ); +} + +function ProgressStat({ + color, + label, + value, +}: { + color: string; + label: string; + value: number; +}) { + return ( + + + {value} + + + + + {label} + + + + ); +} + +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" }, +}; + +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 + + +