From 0ab553bf482b65257ea31280b35932ee7e26692b Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 27 Jun 2026 19:18:43 +0000 Subject: [PATCH] add file viewer functionality across various components - Implemented a shared file viewer modal using `useFileViewer` hook to allow inline viewing of documents (images, PDFs, videos, etc.) across the application. - Updated `ContractClearanceReviewSection`, `ContractRequestDetailPage`, `ContractViewPage`, and booking-related components to utilize the new file viewer for document previews. - Added "Approve all" button in `ContractClearanceReviewSection` to bulk approve documents. - Enhanced document action buttons to include view and download options based on file type. - Introduced `isViewable` utility to determine if a file can be previewed inline. - Created `FileViewer` component to handle rendering of various file types and added appropriate fallback for unsupported formats. --- .../contracts/contract-transition.service.ts | 43 ++- .../modules/contracts/contracts.controller.ts | 9 + .../contracts/dto/sign-contract.dto.ts | 10 +- .../detail/ClearanceReviewSection.tsx | 93 ++++-- .../contracts/ContractActionsToolbar.tsx | 47 ++- .../ContractClearanceReviewSection.tsx | 122 +++++-- .../src/hooks/contracts/useContracts.ts | 25 +- .../backoffice/src/hooks/useFileViewer.tsx | 24 ++ .../contracts/ContractRequestDetailPage.tsx | 66 +++- .../src/pages/contracts/ContractViewPage.tsx | 68 +++- .../portal/src/hooks/useFileViewer.tsx | 24 ++ .../BookingDetailPage/ReadonlyBookingView.tsx | 32 +- .../components/Documents.tsx | 16 + .../bookings/clearance/ClearanceFlow.tsx | 14 + .../pages/contracts/ContractClearanceFlow.tsx | 38 ++- .../pages/contracts/ContractDetailPage.tsx | 54 ++- .../src/components/FileViewer/FileViewer.tsx | 307 ++++++++++++++++++ .../src/components/FileViewer/index.ts | 7 + packages/ui-common/src/index.ts | 10 + 19 files changed, 893 insertions(+), 116 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx create mode 100644 apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx create mode 100644 packages/ui-common/src/components/FileViewer/FileViewer.tsx create mode 100644 packages/ui-common/src/components/FileViewer/index.ts 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 055b36b61..825e50bdd 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 @@ -231,10 +231,14 @@ export class ContractTransitionService { await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); + // Record who acted on this step, but DO NOT advance the contract status here — + // approving one step (e.g. LINE_STAFF) must not finalize the chain while later + // steps (e.g. DIRECTOR) are still pending. Status only moves to APPROVED once + // every step in the chain is complete; until then the contract stays in + // PENDING_APPROVAL so the next required role can act. const updates: Record = {}; const now = new Date(); if (requiredRole === 'LINE_STAFF') { - updates.status = 'APPROVED_PENDING_SIGNATURE'; updates.approvedByStaffId = actorId; updates.approvedByStaffAt = now; } else if (requiredRole === 'DIRECTOR') { @@ -246,9 +250,7 @@ export class ContractTransitionService { } const allDone = await this.contractsRepository.allApprovalStepsComplete(contractId); - if (allDone) { - updates.status = 'APPROVED'; - } + updates.status = allDone ? 'APPROVED' : 'PENDING_APPROVAL'; if (Object.keys(updates).length > 0) { await this.contractsRepository.update(contractId, updates as never); @@ -368,9 +370,28 @@ export class ContractTransitionService { options: { signerUserId?: string }, ): Promise { const role = dto.role as ContractSignerRole; - const raw = dto.signatureImageBase64.includes(',') - ? dto.signatureImageBase64.split(',')[1]! - : dto.signatureImageBase64; + + // Resolve the signature image. The client may send a freshly-drawn image, or + // omit it to reuse the signer's saved profile signature. Fall back to the + // saved one whenever no image is supplied. + let imageBase64 = dto.signatureImageBase64; + let signerDisplayName = dto.signerDisplayName; + if (!imageBase64 && options.signerUserId) { + const saved = await this.signaturesService.getForUser(options.signerUserId); + if (saved?.signatureImageUrl) { + imageBase64 = saved.signatureImageUrl; + signerDisplayName = signerDisplayName || saved.signerDisplayName; + } + } + if (!imageBase64) { + throw new BadRequestException( + 'No signature provided and no saved signature found on the profile.', + ); + } + + const raw = imageBase64.includes(',') + ? imageBase64.split(',')[1]! + : imageBase64; const buffer = Buffer.from(raw, 'base64'); const sigFile: Express.Multer.File = { fieldname: `signature_${role.toLowerCase()}`, @@ -395,17 +416,19 @@ export class ContractTransitionService { await this.contractsRepository.saveSignature({ contractId: contract.id, role, - signerDisplayName: dto.signerDisplayName, + signerDisplayName, signedAt: new Date(), signatureFileId: fileRecord.id, consentText: dto.consentText ?? null, }); - if (options.signerUserId) { + // Only (re)save the reusable profile signature when the signer drew a NEW + // image. Reusing the saved signature must not rewrite it with itself. + if (options.signerUserId && dto.signatureImageBase64) { try { await this.signaturesService.upsertForUser({ userId: options.signerUserId, - signerDisplayName: dto.signerDisplayName, + signerDisplayName, signatureImageBase64: dto.signatureImageBase64, }); } catch (err) { 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 4fcc99b81..eeff2ec52 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -42,6 +42,7 @@ import { ContractTransitionService } from './contract-transition.service'; import { ContractClearanceService } from './contract-clearance.service'; import { ContractBookingService } from './contract-booking.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { SignaturesService } from '../signatures/signatures.service'; import { CreateContractDto } from './dto/create-contract.dto'; import { UpdateContractDto } from './dto/update-contract.dto'; import { FilterContractDto } from './dto/filter-contract.dto'; @@ -68,6 +69,7 @@ export class ContractsController { private readonly clearanceService: ContractClearanceService, private readonly contractBookingService: ContractBookingService, private readonly milestoneService: ClearanceMilestoneService, + private readonly signaturesService: SignaturesService, ) {} @Post() @@ -313,6 +315,12 @@ export class ContractsController { } const { view, html, signatures } = await this.transitionService.getContractDocumentView(id); + // The signer's reusable saved signature (if any) so the sign UI can offer + // "Approve & sign" with the stored image instead of forcing a fresh draw. + const signerId = resolveAuthUserId(user); + const savedSignature = signerId + ? await this.signaturesService.getForUser(signerId) + : null; return { contractId: view.bookingId, reference: view.reference, @@ -325,6 +333,7 @@ export class ContractsController { canSignStaff: view.canSignStaff, hasContractDocument: view.hasContractDocument, signatures, + savedSignature, }; } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts index 0f4460376..febe7a83b 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts @@ -6,10 +6,16 @@ export class SignContractDto { @IsIn(['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO']) role!: 'CUSTOMER' | 'STAFF' | 'DIRECTOR' | 'CEO'; - @ApiProperty({ description: 'PNG signature image as base64 (with or without data URL prefix)' }) + @ApiPropertyOptional({ + description: + 'PNG signature image as base64 (with or without data URL prefix). ' + + 'Optional: when omitted, the signer\'s reusable saved signature from their ' + + 'profile is used instead.', + }) + @IsOptional() @IsString() @MinLength(20) - signatureImageBase64!: string; + signatureImageBase64?: string; @ApiProperty() @IsString() 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 index d8c0577fd..8f8ec104d 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -20,7 +20,7 @@ import { AlertCircle, CheckCircle2, Download, - ExternalLink, + Eye, FileCheck2, FileText, MessageSquareWarning, @@ -28,9 +28,11 @@ import { } from "lucide-react"; import toast from "react-hot-toast"; import type { Freight } from "@edr/types"; +import { isViewable } from "@edr/ui-common"; import { SectionCard } from "./SectionCard"; import { bookingsService } from "@/services/bookings.service"; +import { useFileViewer } from "@/hooks/useFileViewer"; export interface ClearanceReviewSectionProps { bookingId: string; @@ -66,6 +68,7 @@ export function ClearanceReviewSection({ const [queryNotes, setQueryNotes] = useState>({}); const [openQuery, setOpenQuery] = useState>({}); const [outputFiles, setOutputFiles] = useState>({}); + const { view, viewer } = useFileViewer(); const { data: clearance, isLoading } = useQuery({ queryKey: ["clearance", bookingId], @@ -207,6 +210,7 @@ export function ClearanceReviewSection({ note: queryNotes[doc.fileKey], }) } + onView={view} busy={reviewMutation.isPending} /> )) @@ -233,18 +237,46 @@ export function ClearanceReviewSection({ {doc.file ? ( - - - - - + <> + {isViewable({ + name: doc.file.name, + url: doc.file.url, + }) && ( + + + view({ + name: doc.file!.name, + url: doc.file!.url, + }) + } + c="edr-green" + style={{ + display: "flex", + background: "transparent", + border: "none", + cursor: "pointer", + }} + > + + + + )} + + + + + + ) : ( Not uploaded @@ -325,6 +357,7 @@ export function ClearanceReviewSection({ + {viewer} ); } @@ -366,6 +399,7 @@ function DocReviewCard({ onNote, onApprove, onQuery, + onView, busy, }: { doc: Freight.ClearanceDocument; @@ -375,6 +409,7 @@ function DocReviewCard({ onNote: (v: string) => void; onApprove: () => void; onQuery: () => void; + onView: (file: { name: string; url: string }) => void; busy: boolean; }) { const status = doc.reviewStatus ?? "PENDING"; @@ -420,22 +455,22 @@ function DocReviewCard({ {meta.label} - {hasFile && ( - - - - )} + {hasFile && + isViewable({ name: doc.file!.name, url: doc.file!.url }) && ( + + + + )} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx index b7480cd12..9b6419240 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx @@ -13,6 +13,7 @@ import { FileSignature, MessageSquareWarning, PackagePlus, + ShieldCheck, Sparkles, XCircle, Zap, @@ -29,12 +30,23 @@ type Mutations = ReturnType; interface ContractActionsToolbarProps { contract: Freight.IContract; mutations: Mutations; + /** Switch the detail page to its Clearance Review tab. */ + onReviewClearance?: () => void; } +// Contract is in the pre-booking clearance phase — staff can review the +// customer's uploaded documents. +const CLEARANCE_REVIEW_STATUSES = [ + "AWAITING_CLEARANCE_DOCUMENTS", + "CLEARANCE_UNDER_REVIEW", + "CLEARANCE_READY_FOR_BOOKING", +]; + /** Detail-page staff actions: accept / request changes / reject / generate / sign. */ export function ContractActionsToolbar({ contract, mutations, + onReviewClearance, }: ContractActionsToolbarProps) { const navigate = useNavigate(); const { user } = useAuth(); @@ -62,12 +74,12 @@ export function ContractActionsToolbar({ } const canAccept = status === "SUBMITTED"; - // The contract is generated automatically when the final approval lands. We - // only surface a manual "Generate" fallback if that auto-generation failed — - // i.e. the contract is approved but no document was produced yet. + // Generation only becomes available once EVERY approval step is complete and + // the contract reaches APPROVED. While any step is still pending the contract + // stays in PENDING_APPROVAL, so this button does not appear after only the + // first (line-staff) approval — the director step must land first. const needsManualGenerate = - ["APPROVED", "APPROVED_PENDING_SIGNATURE"].includes(status) && - !contract.contractGeneratedAt; + status === "APPROVED" && !contract.contractGeneratedAt; // Signing now happens on the contract VIEW page (staff must open and read the // generated contract before signing) — no sign button in this toolbar. const canViewContract = @@ -77,6 +89,14 @@ export function ContractActionsToolbar({ status === "CLEARANCE_READY_FOR_BOOKING" && contract.customsClearingEnabled && canCreateContractBooking(user); + // Show "Review clearance" while the contract is in the document-review phase. + // Reviewer = GL (Path B / customs) or Operations (Path A / no customs). + const canReviewClearance = + Boolean(onReviewClearance) && + CLEARANCE_REVIEW_STATUSES.includes(status); + const clearanceReviewer = contract.customsClearingEnabled + ? "Review clearance (GL)" + : "Review clearance (Ops)"; return ( @@ -125,7 +145,7 @@ export function ContractActionsToolbar({ loading={mutations.generateContract.isPending} onClick={() => mutations.generateContract.mutate()} > - Re-generate contract + Generate contract )} @@ -142,6 +162,18 @@ export function ContractActionsToolbar({ )} + {canReviewClearance && ( + + )} + {canCreateBooking && ( + )} + } > @@ -179,6 +203,7 @@ export function ContractClearanceReviewSection({ onQuery={() => handleReview(doc.fileKey, "QUERIED", queryNotes[doc.fileKey]) } + onView={view} busy={reviewDocument.isPending} /> )) @@ -205,18 +230,46 @@ export function ContractClearanceReviewSection({ {doc.file ? ( - - - - - + <> + {isViewable({ + name: doc.file.name, + url: doc.file.url, + }) && ( + + + view({ + name: doc.file!.name, + url: doc.file!.url, + }) + } + c="edr-green" + style={{ + display: "flex", + background: "transparent", + border: "none", + cursor: "pointer", + }} + > + + + + )} + + + + + + ) : ( Not uploaded @@ -308,6 +361,7 @@ export function ContractClearanceReviewSection({ + {viewer} ); } @@ -349,6 +403,7 @@ function DocReviewCard({ onNote, onApprove, onQuery, + onView, busy, }: { doc: Freight.ContractClearanceDocument; @@ -358,6 +413,7 @@ function DocReviewCard({ onNote: (v: string) => void; onApprove: () => void; onQuery: () => void; + onView: (file: { name: string; url: string }) => void; busy: boolean; }) { const status = doc.reviewStatus ?? "PENDING"; @@ -403,22 +459,22 @@ function DocReviewCard({ {meta.label} - {hasFile && ( - - - - )} + {hasFile && + isViewable({ name: doc.file!.name, url: doc.file!.url }) && ( + + + + )} 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 984727a75..9e6784bb2 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts @@ -227,6 +227,29 @@ export function useContractClearanceMutations( onError: () => toast.error("Could not update document"), }); + // Approve every still-pending customer document in one click. There is no + // server-side bulk endpoint, so fan out the single-document review calls and + // refresh once after they all settle. + const approveAll = useMutation({ + mutationFn: async (fileKeys: string[]) => { + const review = selfClear + ? contractsService.opsReviewClearanceDocument + : contractsService.reviewClearanceDocument; + await Promise.all( + fileKeys.map((fileKey) => + review(contractId, { fileKey, status: "APPROVED" }), + ), + ); + }, + onSuccess: (_d, fileKeys) => { + toast.success( + `${fileKeys.length} document${fileKeys.length === 1 ? "" : "s"} approved`, + ); + refresh(); + }, + onError: () => toast.error("Could not approve all documents"), + }); + const uploadOutputDocuments = useMutation({ mutationFn: (files: Record) => contractsService.uploadClearanceOutput(contractId, files), @@ -256,7 +279,7 @@ export function useContractClearanceMutations( ), }); - return { reviewDocument, uploadOutputDocuments, finalizeClearance }; + return { reviewDocument, approveAll, uploadOutputDocuments, finalizeClearance }; } /** Complete a post-booking GL milestone. */ diff --git a/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx b/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx new file mode 100644 index 000000000..4ca24476c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx @@ -0,0 +1,24 @@ +import { useCallback, useState } from "react"; +import { FileViewerModal, type ViewableFile } from "@edr/ui-common"; + +/** + * Drives a single shared {@link FileViewerModal} for a page. Call `view(file)` + * from any file row to open the document inline (pdf / image / video / office / + * text); render `viewer` once near the page root. + * + * const { view, viewer } = useFileViewer(); + * + * {viewer} + */ +export function useFileViewer() { + const [file, setFile] = useState(null); + + const view = useCallback((f: ViewableFile) => setFile(f), []); + const close = useCallback(() => setFile(null), []); + + const viewer = ( + + ); + + return { view, close, viewer }; +} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx index 50a4dd3a2..c6a03a805 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx @@ -1,4 +1,4 @@ -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { ArrowLeft, ArrowRight, @@ -12,6 +12,7 @@ import { Receipt, RefreshCw, Route as RouteIcon, + ShieldCheck, Snowflake, } from "lucide-react"; import { @@ -25,10 +26,12 @@ import { Loader, Paper, Stack, + Tabs, Text, Title, } from "@mantine/core"; +import "@/components/overview/overview.css"; import { PageContainer } from "@/components/page"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; @@ -37,12 +40,21 @@ import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge" import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper"; import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar"; import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard"; +import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection"; import { getContractStatusMeta } from "@/features/contracts/contract-status.config"; import { useContractDetail, useContractMutations, } from "@/hooks/contracts/useContracts"; +// Statuses in the pre-booking clearance phase — the Clearance Review tab shows. +const CLEARANCE_REVIEW_STATUSES = [ + "AWAITING_CLEARANCE_DOCUMENTS", + "CLEARANCE_UNDER_REVIEW", + "CLEARANCE_READY_FOR_BOOKING", + "ACTIVE_SHIPMENT_IN_PROGRESS", +]; + function formatDate(value: string | null | undefined): string { if (!value) return "—"; const d = new Date(value); @@ -66,6 +78,18 @@ export default function ContractRequestDetailPage() { isFetching, } = useContractDetail(id); const mutations = useContractMutations(id ?? ""); + const [searchParams, setSearchParams] = useSearchParams(); + const activeTab = searchParams.get("tab") === "clearance" ? "clearance" : "details"; + const setTab = (tab: string) => + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (tab === "details") next.delete("tab"); + else next.set("tab", tab); + return next; + }, + { replace: true }, + ); if (isLoading) { return ( @@ -132,6 +156,13 @@ export default function ContractRequestDetailPage() { contract.status === "APPROVED" || contract.status === "APPROVED_PENDING_SIGNATURE"; + const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status); + // Path A (no customs) → Operations reviews; Path B (customs) → GL reviews. + const selfClear = !contract.customsClearingEnabled; + // If the tab param points at clearance but the contract isn't in a clearance + // phase, fall back to details so we never show an empty tab. + const currentTab = activeTab === "clearance" && showClearanceTab ? "clearance" : "details"; + const customerLabel = contract.isGovernment ? (contract.governmentInstitution ?? "Government") : (contract.companyId ?? "—"); @@ -216,9 +247,38 @@ export default function ContractRequestDetailPage() { description={statusMeta.description} /> + {showClearanceTab && ( + setTab(v ?? "details")} + variant="pills" + color="edr-green" + classNames={{ list: "ov-tablist", tab: "ov-tab" }} + > + + }> + Details + + } + > + Clearance Review + + + + )} + {/* LEFT — primary content */} + {currentTab === "clearance" ? ( + refetch()} + /> + ) : ( {routes.length === 0 ? ( @@ -350,6 +410,7 @@ export default function ContractRequestDetailPage() { ) : null} + )} {/* RIGHT — sticky action rail */} @@ -359,6 +420,9 @@ export default function ContractRequestDetailPage() { setTab("clearance") : undefined + } /> {showApprovalCard && ( (null); + // Offer the staff member's saved signature first; they can draw a fresh one. + const [drawNew, setDrawNew] = useState(false); const { data, isLoading, isError, refetch } = useQuery({ queryKey: [...QUERY_KEYS.CONTRACTS.byId(id ?? ""), "contract-view"], @@ -41,11 +44,16 @@ export default function ContractViewPage() { enabled: Boolean(id), }); + const savedSignatureImage = data?.savedSignature?.signatureImageUrl ?? null; + const usingSaved = Boolean(savedSignatureImage) && !drawNew; + const signMutation = useMutation({ mutationFn: () => contractsService.signContract(id!, { role: "STAFF", - signatureImageBase64: signatureData ?? "", + signatureImageBase64: usingSaved + ? (savedSignatureImage as string) + : (signatureData ?? ""), signerDisplayName: signerName.trim(), consentText: "I confirm this contract on behalf of EDR.", }), @@ -61,8 +69,17 @@ export default function ContractViewPage() { const handlePrint = () => iframeRef.current?.contentWindow?.print(); + const openSign = () => { + setSignerName(data?.savedSignature?.signerDisplayName ?? ""); + setSignatureData(null); + setDrawNew(false); + setSignOpen(true); + }; + const confirmSign = () => { - if (!signerName.trim() || !signatureData) return; + if (!signerName.trim()) return; + const image = usingSaved ? savedSignatureImage : signatureData; + if (!image) return; signMutation.mutate(); }; @@ -111,13 +128,9 @@ export default function ContractViewPage() { )} @@ -155,7 +168,36 @@ export default function ContractViewPage() { value={signerName} onChange={(e) => setSignerName(e.currentTarget.value)} /> - + {usingSaved ? ( + + + Saved signature + + + + ) : ( + + )} diff --git a/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx b/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx new file mode 100644 index 000000000..4ca24476c --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx @@ -0,0 +1,24 @@ +import { useCallback, useState } from "react"; +import { FileViewerModal, type ViewableFile } from "@edr/ui-common"; + +/** + * Drives a single shared {@link FileViewerModal} for a page. Call `view(file)` + * from any file row to open the document inline (pdf / image / video / office / + * text); render `viewer` once near the page root. + * + * const { view, viewer } = useFileViewer(); + * + * {viewer} + */ +export function useFileViewer() { + const [file, setFile] = useState(null); + + const view = useCallback((f: ViewableFile) => setFile(f), []); + const close = useCallback(() => setFile(null), []); + + const viewer = ( + + ); + + return { view, close, viewer }; +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index d115369c3..d0c5f0a06 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -1,10 +1,12 @@ import { Box, Group, Text } from "@mantine/core"; import { useMutation } from "@tanstack/react-query"; -import { CreditCard, Download } from "lucide-react"; +import { CreditCard, Download, Eye } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; +import { isViewable } from "@edr/ui-common"; import { api } from "@/services/api"; +import { useFileViewer } from "@/hooks/useFileViewer"; import { paymentsService, type PaymentMethod } from "@/services/payments.service"; import type { Freight } from "@edr/types"; @@ -34,6 +36,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) const navigate = useNavigate(); const status = booking.status as string; const [payModalOpen, setPayModalOpen] = useState(false); + const { view, viewer } = useFileViewer(); // POST /payments/initiate creates the intent and returns the provider's // redirect URL (clientAction.url). Send the browser straight there; fall back @@ -159,10 +162,28 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) meta={file.code.replace(/_/g, " ")} status="verified" action={ - } - /> + + {isViewable({ + name: file.name, + url: file.signedUrl ?? file.url, + mimeType: file.mimeType, + }) && ( + } + onClick={() => + view({ + name: file.name, + url: file.signedUrl ?? file.url, + mimeType: file.mimeType, + }) + } + /> + )} + } + /> + } /> ))} @@ -213,6 +234,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) } onConfirm={(method) => payMutation.mutate(method)} /> + {viewer} ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Documents.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Documents.tsx index bf9c2f894..49eeeed12 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Documents.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/Documents.tsx @@ -108,9 +108,12 @@ export function DocRow({ export function IconSquare({ icon, href, + onClick, }: { icon: ReactNode; href?: string | null; + /** When provided (and no href), renders a clickable button square. */ + onClick?: () => void; }) { const style: React.CSSProperties = { flexShrink: 0, @@ -122,6 +125,7 @@ export function IconSquare({ borderRadius: 8, border: "1px solid #E6ECF2", color: "#6B7C8E", + cursor: href || onClick ? "pointer" : "default", }; if (href) { return ( @@ -136,6 +140,18 @@ export function IconSquare({ ); } + if (onClick) { + return ( + + {icon} + + ); + } return ( {icon} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx index 579fcc9d6..e3728361d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/ClearanceFlow.tsx @@ -13,14 +13,17 @@ import { CheckCircle2, Clock, Download, + Eye, FileText, Plus, Upload, } from "lucide-react"; import type { Freight } from "@edr/types"; +import { isViewable } from "@edr/ui-common"; import { IconSquare } from "../BookingDetailPage/components/Documents"; +import { useFileViewer } from "@/hooks/useFileViewer"; import { OperationDatePicker } from "./OperationDatePicker"; import type { ClearanceFlowController } from "./useClearanceFlow"; @@ -104,6 +107,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) { uploadMutation, proceedMutation, } = flow; + const { view, viewer } = useFileViewer(); if (!clearance) return null; @@ -164,6 +168,15 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) { + {doc.file && + isViewable({ name: doc.file.name, url: doc.file.url }) && ( + } + onClick={() => + view({ name: doc.file!.name, url: doc.file!.url }) + } + /> + )} {doc.file && ( } /> )} @@ -310,6 +323,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) { )} {footer} + {viewer} ); } 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 873c5e3c9..b1eabf786 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceFlow.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceFlow.tsx @@ -22,13 +22,16 @@ import { CheckCircle2, Clock, Download, + Eye, FileText, Plus, Upload, } from "lucide-react"; import type { Freight } from "@edr/types"; +import { isViewable } from "@edr/ui-common"; import { api } from "@/services/api"; +import { useFileViewer } from "@/hooks/useFileViewer"; import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents"; import { BORDER, ContractStatusBadge, INK } from "./contract-ui"; @@ -88,6 +91,7 @@ export default function ContractClearanceFlow() { const [pending, setPending] = useState>({}); const [adHoc, setAdHoc] = useState([]); + const { view, viewer } = useFileViewer(); const { data: contract } = useQuery( api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }), @@ -280,6 +284,15 @@ export default function ContractClearanceFlow() { + {doc.file && + isViewable({ name: doc.file.name, url: doc.file.url }) && ( + } + onClick={() => + view({ name: doc.file!.name, url: doc.file!.url }) + } + /> + )} {doc.file && ( {doc.file ? ( - } - /> + + {isViewable({ + name: doc.file.name, + url: doc.file.url, + }) && ( + } + onClick={() => + view({ + name: doc.file!.name, + url: doc.file!.url, + }) + } + /> + )} + } + /> + ) : ( Pending @@ -446,6 +475,7 @@ export default function ContractClearanceFlow() { + {viewer} ); } 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 3490af241..89c5c90f4 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -21,6 +21,7 @@ import { CalendarClock, CheckCircle2, Download, + Eye, FileSignature, FileText, Flame, @@ -35,7 +36,9 @@ import { Upload, Weight, } from "lucide-react"; +import { isViewable } from "@edr/ui-common"; import { api } from "@/services/api"; +import { useFileViewer } from "@/hooks/useFileViewer"; import { formatRateUnit } from "./new-contract-form/unit-rates"; import { BORDER, @@ -63,6 +66,7 @@ export default function ContractDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [tab, setTab] = useState("details"); + const { view, viewer } = useFileViewer(); const { data: contract, @@ -638,19 +642,42 @@ export default function ContractDetailPage() { - + + {isViewable({ + name: file.name, + url: file.signedUrl ?? file.url, + mimeType: file.mimeType, + }) && ( + + )} + + ))} @@ -744,6 +771,7 @@ export default function ContractDetailPage() { + {viewer} ); } diff --git a/packages/ui-common/src/components/FileViewer/FileViewer.tsx b/packages/ui-common/src/components/FileViewer/FileViewer.tsx new file mode 100644 index 000000000..fa8049798 --- /dev/null +++ b/packages/ui-common/src/components/FileViewer/FileViewer.tsx @@ -0,0 +1,307 @@ +import { useMemo } from "react"; +import { + Box, + Button, + Center, + Group, + Modal, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; +import { + Download, + ExternalLink, + FileArchive, + FileQuestion, +} from "lucide-react"; + +/** The minimal file shape the viewer needs. */ +export interface ViewableFile { + /** Display name (used for the title + extension fallback). */ + name: string; + /** Direct URL to the file content. A signed URL is preferred when present. */ + url: string; + /** MIME type when known (e.g. "application/pdf", "image/png"). */ + mimeType?: string | null; +} + +export interface FileViewerModalProps { + /** Whether the modal is open. */ + open: boolean; + /** The file to display, or null when nothing is selected. */ + file: ViewableFile | null; + /** Close handler. */ + onClose: () => void; +} + +type ViewerKind = + | "image" + | "video" + | "audio" + | "pdf" + | "office" + | "text" + | "unsupported"; + +const EXT_KIND: Record = { + // images + png: "image", + jpg: "image", + jpeg: "image", + gif: "image", + webp: "image", + bmp: "image", + svg: "image", + // video + mp4: "video", + webm: "video", + ogv: "video", + mov: "video", + m4v: "video", + // audio + mp3: "audio", + wav: "audio", + ogg: "audio", + m4a: "audio", + // documents + pdf: "pdf", + // office — rendered via the Microsoft Office online viewer + doc: "office", + docx: "office", + xls: "office", + xlsx: "office", + ppt: "office", + pptx: "office", + // text + txt: "text", + csv: "text", + json: "text", + log: "text", + md: "text", +}; + +/** Archives / binaries we deliberately do NOT try to render inline. */ +const UNVIEWABLE_EXT = new Set([ + "zip", + "rar", + "7z", + "tar", + "gz", + "bz2", + "exe", + "dmg", + "iso", + "bin", +]); + +function extOf(name: string): string { + const dot = name.lastIndexOf("."); + return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ""; +} + +/** Decide how to render a file from its MIME type, falling back to extension. */ +export function resolveViewerKind(file: ViewableFile): ViewerKind { + const mime = (file.mimeType ?? "").toLowerCase(); + const ext = extOf(file.name); + + if (UNVIEWABLE_EXT.has(ext)) return "unsupported"; + + if (mime.startsWith("image/")) return "image"; + if (mime.startsWith("video/")) return "video"; + if (mime.startsWith("audio/")) return "audio"; + if (mime === "application/pdf") return "pdf"; + if ( + mime.includes("word") || + mime.includes("excel") || + mime.includes("spreadsheet") || + mime.includes("powerpoint") || + mime.includes("presentation") || + mime.includes("officedocument") + ) { + return "office"; + } + if (mime.startsWith("text/") || mime === "application/json") return "text"; + + // Fall back to the file extension when the MIME type is missing/generic. + return EXT_KIND[ext] ?? "unsupported"; +} + +/** True when a file can be previewed inline (not an archive/binary). */ +export function isViewable(file: ViewableFile): boolean { + return resolveViewerKind(file) !== "unsupported"; +} + +/** + * A wide modal that renders the content of common document types inline — + * images, video, audio, PDFs, Office documents (via the Microsoft online + * viewer) and plain text. Archives and other binaries fall back to a download + * prompt. Use the {@link isViewable} / {@link resolveViewerKind} helpers to gate + * a "view" affordance in the caller. + */ +export function FileViewerModal({ open, file, onClose }: FileViewerModalProps) { + const kind = useMemo( + () => (file ? resolveViewerKind(file) : "unsupported"), + [file], + ); + + return ( + + {file?.name ?? "Document"} + + } + size="90%" + radius="md" + centered + overlayProps={{ blur: 2, backgroundOpacity: 0.55 }} + styles={{ + content: { + height: "90vh", + display: "flex", + flexDirection: "column", + }, + body: { flex: 1, minHeight: 0, display: "flex", padding: 0 }, + header: { paddingInline: 16 }, + }} + > + {file && ( + + + + + + + + + + )} + + ); +} + +function FileContent({ + file, + kind, +}: { + file: ViewableFile; + kind: ViewerKind; +}) { + switch (kind) { + case "image": + return ( +
+ {file.name} +
+ ); + case "video": + return ( +
+
+ ); + case "audio": + return ( +
+
+ ); + case "pdf": + return ( +