From fc1adce9093b35d0cddb792d257943757da95fda Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 28 Jun 2026 20:12:45 +0000 Subject: [PATCH] feat: implement PDF regeneration for contracts and enhance document upload handling in clearance sections --- .../contracts/contract-transition.service.ts | 22 ++ apps/edr-freight-web/backoffice/src/App.tsx | 39 +--- .../detail/ClearanceReviewSection.tsx | 179 +++++++-------- .../ContractClearanceReviewSection.tsx | 186 ++++++++-------- .../bookings/DocumentClearanceListPage.tsx | 205 +++++++++++++----- 5 files changed, 362 insertions(+), 269 deletions(-) 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 825e50bdd..01d2cb2ef 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 @@ -302,6 +302,26 @@ export class ContractTransitionService { return { view, html, signatures: view.signatures }; } + /** + * Rebuild the stored `contract` PDF from the current aggregate (now including + * the latest signatures) so the downloaded/viewed file matches the live HTML + * view. Best-effort — a Chromium hiccup must never fail the signing + * transaction; the doc still re-renders live on /contract/view. + */ + private async regenerateContractPdf( + contractId: string, + reference: string, + ): Promise { + try { + const { view } = await this.documentViewModelBuilder.build(contractId); + await this.upsertContractPdf(contractId, reference, view); + } catch (err) { + this.logger.warn( + `Signed contract PDF regen deferred for ${reference}: ${err}. It re-renders live on view.`, + ); + } + } + /** Render the contract PDF and upsert it as the `contract` file on the contract. */ private async upsertContractPdf( contractId: string, @@ -458,6 +478,7 @@ export class ContractTransitionService { status: 'SIGNED_CUSTOMER', customerSignedAt: new Date(), } as never); + await this.regenerateContractPdf(contractId, contract.reference); return this.contractsService.findById(contractId); } @@ -517,6 +538,7 @@ export class ContractTransitionService { } await this.contractsRepository.update(contractId, updates as never); + await this.regenerateContractPdf(contractId, contract.reference); return this.contractsService.findById(contractId); } diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index bf2c01c1d..01b4ff98b 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -35,8 +35,6 @@ import NewBookingPage from "./pages/bookings/NewBookingPage"; import ContractRequestsPage from "./pages/contracts/ContractRequestsPage"; import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage"; import ContractViewPage from "./pages/contracts/ContractViewPage"; -import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage"; -import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage"; import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm"; import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; @@ -134,18 +132,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.bookings.reviewDocuments, }, - { - label: "Contract Clearance", - href: "/dashboard/contracts/clearance", - 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,34 +453,15 @@ const App = () => { /> - - - } + element={} /> - - - } + element={} /> - - - } + element={} /> >({}); const [openQuery, setOpenQuery] = useState>({}); - const [outputFiles, setOutputFiles] = useState>({}); + const [uploadingKey, setUploadingKey] = useState(null); const { view, viewer } = useFileViewer(); const { data: clearance, isLoading } = useQuery({ @@ -100,13 +100,17 @@ export function ClearanceReviewSection({ }); const outputMutation = useMutation({ - mutationFn: () => bookingsService.uploadClearanceOutput(bookingId, outputFiles), + mutationFn: (files: Record) => + bookingsService.uploadClearanceOutput(bookingId, files), onSuccess: () => { - toast.success("Output documents uploaded"); - setOutputFiles({}); + toast.success("Document uploaded"); + setUploadingKey(null); refresh(); }, - onError: () => toast.error("Upload failed"), + onError: () => { + toast.error("Upload failed"); + setUploadingKey(null); + }, }); const finalizeMutation = useMutation({ @@ -223,99 +227,100 @@ export function ClearanceReviewSection({ - {glDocs.map((doc) => ( - - - - - {doc.label} - {doc.required ? " *" : ""} - - - - {doc.file ? ( - <> - {isViewable({ - name: doc.file.name, - url: fileViewUrl(doc.file.id), - }) && ( - + {glDocs.map((doc) => { + const isUploading = + uploadingKey === doc.fileKey && outputMutation.isPending; + return ( + + + + + {doc.label} + {doc.required ? " *" : ""} + + + + {doc.file ? ( + <> + {isViewable({ + name: doc.file.name, + url: fileViewUrl(doc.file.id), + }) && ( + + + view({ + name: doc.file!.name, + url: fileViewUrl(doc.file!.id), + }) + } + c="edr-green" + style={{ + display: "flex", + background: "transparent", + border: "none", + cursor: "pointer", + }} + > + + + + )} + - view({ - name: doc.file!.name, - url: fileViewUrl(doc.file!.id), - }) - } + component="a" + href={fileViewUrl(doc.file.id, true)} c="edr-green" - style={{ - display: "flex", - background: "transparent", - border: "none", - cursor: "pointer", - }} + style={{ display: "flex" }} > - + - )} - - - - - - - ) : ( - - Not uploaded - - )} - - f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f })) - } - accept="application/pdf,image/*" - > - {(props) => ( - + + ) : ( + + Not uploaded + )} - + { + if (!f) return; + setUploadingKey(doc.fileKey); + outputMutation.mutate({ [doc.fileKey]: f }); + }} + accept="application/pdf,image/*" + disabled={isUploading} + > + {(props) => ( + + )} + + - - ))} + ); + })} - - - )} 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 fa85cf815..c6ba17b6e 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx @@ -92,7 +92,7 @@ export function ContractClearanceReviewSection({ }: ContractClearanceReviewSectionProps) { const [queryNotes, setQueryNotes] = useState>({}); const [openQuery, setOpenQuery] = useState>({}); - const [outputFiles, setOutputFiles] = useState>({}); + const [uploadingKey, setUploadingKey] = useState(null); const { view, viewer } = useFileViewer(); const reviewerTeam = selfClear ? "Operations" : "Global Logistics"; @@ -242,110 +242,106 @@ export function ContractClearanceReviewSection({ - {glDocs.map((doc) => ( - - - - - {doc.label} - {doc.required ? " *" : ""} - - - - {doc.file ? ( - <> - {isViewable({ - name: doc.file.name, - url: fileViewUrl(doc.file.id), - }) && ( - + {glDocs.map((doc) => { + const isUploading = + uploadingKey === doc.fileKey && uploadOutputDocuments.isPending; + return ( + + + + + {doc.label} + {doc.required ? " *" : ""} + + + + {doc.file ? ( + <> + {isViewable({ + name: doc.file.name, + url: fileViewUrl(doc.file.id), + }) && ( + + + view({ + name: doc.file!.name, + url: fileViewUrl(doc.file!.id), + }) + } + c="edr-green" + style={{ + display: "flex", + background: "transparent", + border: "none", + cursor: "pointer", + }} + > + + + + )} + - view({ - name: doc.file!.name, - url: fileViewUrl(doc.file!.id), - }) - } + component="a" + href={fileViewUrl(doc.file.id, true)} c="edr-green" - style={{ - display: "flex", - background: "transparent", - border: "none", - cursor: "pointer", - }} + style={{ display: "flex" }} > - + - )} - - - - - - - ) : ( - - Not uploaded - - )} - {!readOnly && ( - - f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f })) - } - accept="application/pdf,image/*" - > - {(props) => ( - - )} - - )} + + ) : ( + + Not uploaded + + )} + {!readOnly && ( + { + if (!f) return; + setUploadingKey(doc.fileKey); + uploadOutputDocuments.mutate( + { [doc.fileKey]: f }, + { onSuccess: () => { setUploadingKey(null); onChanged?.(); }, + onError: () => setUploadingKey(null) }, + ); + }} + accept="application/pdf,image/*" + disabled={isUploading} + > + {(props) => ( + + )} + + )} + - - ))} + ); + })} - {!readOnly && ( - - - - )} )} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceListPage.tsx index 4d04e4c9c..0e48d2f1c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceListPage.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { ActionIcon, Badge, @@ -20,7 +20,9 @@ import { import { ArrowRight, Calendar, + CheckCircle, ChevronRight, + History, Inbox, LayoutGrid, RefreshCw, @@ -46,12 +48,16 @@ import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { bookingsService } from "@/services/bookings.service"; import type { BookingDetail } from "@/types/booking"; import { - CLEARANCE_REVIEW_STATUS, CLEARANCE_TABS, type ClearanceTabKey, } from "@/features/clearance/clearance-tabs.config"; type ViewMode = "table" | "cards"; +type PageTab = "queue" | "history"; +type ClearanceMode = "gl" | "ops"; + +const CLEARANCE_REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW"; +const CLEARANCE_HISTORY_STATUS = "CLEARANCE_READY"; interface ClearanceRow { id: string; @@ -62,6 +68,7 @@ interface ClearanceRow { originLabel: string; destinationLabel: string; scheduledDate: string; + updatedAt: string; hasCustoms: boolean; } @@ -85,6 +92,7 @@ function toClearanceRow(booking: BookingDetail): ClearanceRow { originLabel: labelFromRef(booking.originYard), destinationLabel: labelFromRef(booking.destinationYard), scheduledDate: booking.scheduledDate, + updatedAt: booking.updatedAt ?? "", hasCustoms: Boolean( booking.customsClearingEnabled ?? booking.serviceType?.includesCustoms, ), @@ -102,12 +110,6 @@ function formatDate(iso?: string): string { }); } -/** - * Icon-only chip for a booking's trade direction — Truck for import, ShipWheel - * for export — on a light background, matching the "awaiting review" badge - * styling. Keeps the cards within the white / light-gray / green palette and - * drops the text label in favour of a tooltip. - */ function DirectionIcon({ direction }: { direction: string }) { const isImport = direction === "IMPORT"; const Icon = isImport ? Truck : ShipWheel; @@ -129,33 +131,55 @@ function DirectionIcon({ direction }: { direction: string }) { export default function DocumentClearanceListPage() { const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + + // "mode=ops" query param lets the old ops-clearance redirect land on the right tab + const initialMode: ClearanceMode = + searchParams.get("mode") === "ops" ? "ops" : "gl"; + const [clearanceMode, setClearanceMode] = useState(initialMode); + const [pageTab, setPageTab] = useState("queue"); const [activeTab, setActiveTab] = useState("all"); const [query, setQuery] = useState(""); const [view, setView] = useState("table"); const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const isHistory = pageTab === "history"; + const { data, isLoading, isError, isFetching, refetch } = useQuery({ - queryKey: ["clearance", "list"], + queryKey: ["clearance", "list", clearanceMode, isHistory], queryFn: () => - bookingsService.list({ status: CLEARANCE_REVIEW_STATUS, pageSize: 200 }), + bookingsService.list({ + status: isHistory ? CLEARANCE_HISTORY_STATUS : CLEARANCE_REVIEW_STATUS, + pageSize: 200, + }), }); - // GL clears customs bookings only; non-customs clearance is reviewed by - // Marketing on the booking detail. Scope the queue defensively so a staff or - // marketing user opening this page still sees the customs queue. - const allRows = useMemo( - () => (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms), - [data?.items], - ); + const allRows = useMemo(() => { + const rows = (data?.items ?? []).map(toClearanceRow); + const filtered = + clearanceMode === "gl" + ? rows.filter((r) => r.hasCustoms) + : rows.filter((r) => !r.hasCustoms); - // Per-tab counts drive the badge on each tab. - const tabCounts = useMemo(() => { - return { + if (isHistory) { + // Latest cleared first — fall back to updatedAt + return [...filtered].sort((a, b) => { + const ta = new Date(a.updatedAt || 0).getTime(); + const tb = new Date(b.updatedAt || 0).getTime(); + return tb - ta; + }); + } + return filtered; + }, [data?.items, clearanceMode, isHistory]); + + const tabCounts = useMemo( + () => ({ all: allRows.length, import: allRows.filter((r) => r.tradeDirection === "IMPORT").length, export: allRows.filter((r) => r.tradeDirection === "EXPORT").length, - } satisfies Record; - }, [allRows]); + }), + [allRows], + ); const rows = useMemo(() => { const q = query.trim().toLowerCase(); @@ -185,6 +209,35 @@ export default function DocumentClearanceListPage() { [navigate], ); + const handleModeChange = (mode: ClearanceMode) => { + setClearanceMode(mode); + setPageTab("queue"); + setActiveTab("all"); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + // clear the ?mode= param after first use + setSearchParams({}); + }; + + const statusBadge = isHistory ? ( + } + > + {tabCounts.all} cleared + + ) : ( + } + > + {tabCounts.all} awaiting review + + ); + const columns: ColumnDef[] = useMemo( () => [ { @@ -249,11 +302,16 @@ export default function DocumentClearanceListPage() { { id: "status", header: () => Status, - cell: () => ( - - Under review - - ), + cell: ({ row }) => + isHistory ? ( + + Cleared + + ) : ( + + Under review + + ), }, { id: "go", @@ -265,7 +323,7 @@ export default function DocumentClearanceListPage() { ), }, ], - [], + [isHistory], ); return ( @@ -274,16 +332,7 @@ export default function DocumentClearanceListPage() { } - > - {tabCounts.all} awaiting review - - } + meta={statusBadge} action={ + {/* Mode toggle: GL Customs vs Self-Clearance (Operations) */} + + handleModeChange(v as ClearanceMode)} + data={[ + { value: "gl", label: "GL Clearance" }, + { value: "ops", label: "Self-Clearance" }, + ]} + radius="md" + color="edr-green" + /> + { + setPageTab(v as PageTab); + setActiveTab("all"); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + data={[ + { + value: "queue", + label: ( + + + Queue + + ), + }, + { + value: "history", + label: ( + + + History + + ), + }, + ]} + radius="md" + /> + + { setQuery(e.currentTarget.value); - setPagination({ - pageIndex: 0, - pageSize: pagination.pageSize, - }); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); }} rightSection={ query ? ( @@ -430,9 +519,7 @@ export default function DocumentClearanceListPage() { columns={columns} data={pagedRows} - status={ - isLoading ? "loading" : isError ? "error" : "success" - } + status={isLoading ? "loading" : isError ? "error" : "success"} onRowClick={(row) => openDetail(row.id)} pagination={{ pageIndex: pagination.pageIndex, @@ -454,6 +541,7 @@ export default function DocumentClearanceListPage() { )} @@ -467,10 +555,12 @@ export default function DocumentClearanceListPage() { function ClearanceCardGrid({ rows, loading, + isHistory, onOpen, }: { rows: ClearanceRow[]; loading: boolean; + isHistory: boolean; onOpen: (id: string) => void; }) { if (loading) { @@ -495,7 +585,12 @@ function ClearanceCardGrid({ return ( {rows.map((r) => ( - onOpen(r.id)} /> + onOpen(r.id)} + /> ))} ); @@ -503,9 +598,11 @@ function ClearanceCardGrid({ function ClearanceCard({ row, + isHistory, onOpen, }: { row: ClearanceRow; + isHistory: boolean; onOpen: () => void; }) { return ( @@ -543,9 +640,15 @@ function ClearanceCard({ - - Under review - + {isHistory ? ( + + Cleared + + ) : ( + + Under review + + )}