diff --git a/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts b/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts index e061061e..168872d6 100644 --- a/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts +++ b/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts @@ -276,12 +276,36 @@ export const useProformaDetailPageController = () => { }); }; - const handleOpenPreviewPdf = () => { - if (!previewPdfUrl) { + const handleOpenPreviewPdf = async () => { + if (previewPdfUrl) { + window.open(previewPdfUrl, "_blank", "noopener,noreferrer"); return; } - window.open(previewPdfUrl, "_blank", "noopener,noreferrer"); + const result = await previewPdfQuery.refetch(); + + if (result.error) { + showErrorToast( + t("proformas.detail.document.error_title"), + result.error instanceof Error ? result.error.message : t("proformas.detail.document.error") + ); + return; + } + + if (!isUsablePdfBlob(result.data)) { + showErrorToast( + t("proformas.detail.document.empty_title"), + t("proformas.detail.document.empty_description") + ); + return; + } + + const objectUrl = URL.createObjectURL(result.data); + window.open(objectUrl, "_blank", "noopener,noreferrer"); + + window.setTimeout(() => { + URL.revokeObjectURL(objectUrl); + }, 60_000); }; const handlePrintPreview = () => { @@ -379,6 +403,7 @@ export const useProformaDetailPageController = () => { changeStatus: t("pages.proformas.list.actions.change_status"), issue: t("pages.proformas.list.actions.issue"), duplicate: t("pages.proformas.list.actions.duplicate"), + openPdf: t("proformas.detail.document.actions.open"), downloadPdf: t("pages.proformas.list.actions.download_pdf"), archive: t("pages.proformas.list.actions.archive"), unarchive: t("pages.proformas.list.actions.unarchive"), @@ -398,6 +423,7 @@ export const useProformaDetailPageController = () => { loading: { issue: issueDialogCtrl.isSubmitting, duplicate: duplicateMutation.isPending, + "open-pdf": previewPdfQuery.isFetching && !previewPdfBlob, "download-pdf": previewPdfQuery.isFetching && !previewPdfBlob, archive: archiveMutation.isPending, unarchive: unarchiveMutation.isPending, @@ -438,6 +464,14 @@ export const useProformaDetailPageController = () => { case "duplicate": handleDuplicate(); return; + case "open-pdf": + handleOpenPreviewPdf().catch((error: unknown) => { + showErrorToast( + t("proformas.detail.document.error_title"), + error instanceof Error ? error.message : t("proformas.detail.document.error") + ); + }); + return; case "download-pdf": handleDownload(); return; diff --git a/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts b/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts index 102dc405..0431f92e 100644 --- a/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts +++ b/modules/customer-invoices/src/web/proformas/detail/entities/proforma-detail-view-models.entity.ts @@ -135,6 +135,7 @@ export type ProformaDetailActionKey = | "change-status" | "issue" | "duplicate" + | "open-pdf" | "download-pdf" | "archive" | "unarchive"; diff --git a/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-detail-header-actions.tsx b/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-detail-header-actions.tsx index 5d1f6d51..48aa32ba 100644 --- a/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-detail-header-actions.tsx +++ b/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-detail-header-actions.tsx @@ -10,6 +10,7 @@ import { CopyIcon, DownloadIcon, FileOutputIcon, + ExternalLinkIcon, MoreHorizontalIcon, PencilIcon, RefreshCwIcon, @@ -30,9 +31,7 @@ export const ProformaDetailHeaderActions = ({ onExecuteAction, }: ProformaDetailHeaderActionsProps) => { const { t } = useTranslation(); - const visibleItems = actions.items.filter( - (item) => item.visibility === "visible" && item.key !== "download-pdf" - ); + const visibleItems = actions.items.filter((item) => item.visibility === "visible"); const primaryAction = visibleItems.find((item) => item.placement === "primary") ?? null; const secondaryActions = visibleItems.filter((item) => item.placement === "secondary"); const menuActions = visibleItems.filter((item) => item.placement === "menu"); @@ -45,6 +44,8 @@ export const ProformaDetailHeaderActions = ({ return FileOutputIcon; case "duplicate": return CopyIcon; + case "open-pdf": + return ExternalLinkIcon; case "download-pdf": return DownloadIcon; case "archive": diff --git a/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-document-pane.tsx b/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-document-pane.tsx index dd2dd5ba..81799eb1 100644 --- a/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-document-pane.tsx +++ b/modules/customer-invoices/src/web/proformas/detail/ui/blocks/proforma-document-pane.tsx @@ -1,7 +1,7 @@ import { PDFViewer } from "@repo/rdx-ui/components"; import { Alert, AlertDescription, AlertTitle, Button } from "@repo/shadcn-ui/components"; import { cn } from "@repo/shadcn-ui/lib/utils"; -import { useId } from "react"; +import { useEffect, useId, useState } from "react"; import { useTranslation } from "../../../../i18n"; import type { ProformaDetailDocumentState, ProformaDetailDocumentViewModel } from "../../entities"; @@ -25,22 +25,51 @@ export const ProformaDocumentPane = ({ }: ProformaDocumentPaneProps) => { const { t } = useTranslation(); const headingId = useId(); + const [isDesktopViewport, setIsDesktopViewport] = useState(() => + typeof window === "undefined" ? false : window.matchMedia("(min-width: 1024px)").matches + ); + const supportsNativeInlinePdf = + typeof navigator !== "undefined" && navigator.pdfViewerEnabled === true; + const shouldUseNativeInlinePdf = supportsNativeInlinePdf && isDesktopViewport; const isRefreshing = state.isFetching && Boolean(document.src); const shouldRenderBlockingError = state.isError && !document.src; const shouldRenderStaleError = state.isError && Boolean(document.src); + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const mediaQuery = window.matchMedia("(min-width: 1024px)"); + const handleChange = (event: MediaQueryListEvent) => { + setIsDesktopViewport(event.matches); + }; + + setIsDesktopViewport(mediaQuery.matches); + mediaQuery.addEventListener("change", handleChange); + + return () => { + mediaQuery.removeEventListener("change", handleChange); + }; + }, []); + return (
-
+

{document.title}

{shouldRenderStaleError ? ( - + {t("proformas.detail.document.error_title")}

{state.errorMessage ?? t("proformas.detail.document.error")}

@@ -55,7 +84,8 @@ export const ProformaDocumentPane = ({
diff --git a/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-actions.ts b/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-actions.ts index d1c8f7ba..8721207d 100644 --- a/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-actions.ts +++ b/modules/customer-invoices/src/web/proformas/detail/utils/build-proforma-detail-actions.ts @@ -11,6 +11,7 @@ export interface ProformaDetailActionLabels { changeStatus: string; issue: string; duplicate: string; + openPdf: string; downloadPdf: string; archive: string; unarchive: string; @@ -120,10 +121,19 @@ export const buildProformaDetailActions = ({ loading.duplicate ?? false, null ), + buildAction( + "open-pdf", + labels.openPdf, + "secondary", + "visible", + !(loading["open-pdf"] ?? false), + loading["open-pdf"] ?? false, + loading["open-pdf"] ? labels.disabledReasons.operationInProgress : null + ), buildAction( "download-pdf", labels.downloadPdf, - isApproved ? "secondary" : "secondary", + "secondary", "visible", !(loading["download-pdf"] ?? false), loading["download-pdf"] ?? false, diff --git a/packages/rdx-ui/src/components/pdf-viewer.tsx b/packages/rdx-ui/src/components/pdf-viewer.tsx index b7a3ba99..cd6571c9 100644 --- a/packages/rdx-ui/src/components/pdf-viewer.tsx +++ b/packages/rdx-ui/src/components/pdf-viewer.tsx @@ -11,6 +11,7 @@ import { SelectValue, Spinner, } from "@repo/shadcn-ui/components"; +import { cn } from "@repo/shadcn-ui/lib/utils"; import { DownloadIcon, ExternalLinkIcon, @@ -47,6 +48,10 @@ type PDFViewerProps = { title?: string; description?: string; className?: string; + variant?: "panel" | "native-inline"; + showOpenAction?: boolean; + showDownloadAction?: boolean; + showPrinterAction?: boolean; loading?: boolean; isRefreshing?: boolean; error?: string | null; @@ -71,6 +76,10 @@ export function PDFViewer({ title = "Vista previa", description = "Documento PDF embebido en la pantalla.", className, + variant = "panel", + showOpenAction = true, + showDownloadAction = true, + showPrinterAction = true, loading = false, isRefreshing = false, error = null, @@ -87,6 +96,8 @@ export function PDFViewer({ const [zoom, setZoom] = useState(() => defaultZoom === FIT_ZOOM_VALUE ? FIT_ZOOM_VALUE : String(clampZoom(defaultZoom, zoomOptions)) ); + const minZoom = Math.min(...zoomOptions); + const maxZoom = Math.max(...zoomOptions); const resolvedLabels: PDFViewerLabels = { zoomOut: "Alejar", zoomIn: "Acercar", @@ -109,9 +120,13 @@ export function PDFViewer({ return null; } + if (variant === "native-inline") { + return src; + } + const separator = src.includes("#") ? "&" : "#"; - return `${src}${separator}toolbar=0&navpanes=0&scrollbar=1&view=FitH`; - }, [src]); + return `${src}${separator}toolbar=0&navpanes=0&scrollbar=1&page=1&zoom=page-width`; + }, [src, variant]); useEffect(() => { if (zoom !== FIT_ZOOM_VALUE) { @@ -127,13 +142,9 @@ export function PDFViewer({ const styles = window.getComputedStyle(element); const paddingX = Number.parseFloat(styles.paddingLeft) + Number.parseFloat(styles.paddingRight); - const paddingY = - Number.parseFloat(styles.paddingTop) + Number.parseFloat(styles.paddingBottom); const availableWidth = Math.max(element.clientWidth - paddingX, 320); - const availableHeight = Math.max(element.clientHeight - paddingY, 320); const widthScale = availableWidth / PDF_PAGE_WIDTH; - const heightScale = availableHeight / PDF_PAGE_HEIGHT; - const nextScale = Math.min(widthScale, heightScale, 1); + const nextScale = Math.min(widthScale, maxZoom / 100); setFitZoom(Math.max(Math.round(nextScale * 100), 25)); }; @@ -147,12 +158,11 @@ export function PDFViewer({ observer.disconnect(); window.removeEventListener("resize", updateFitZoom); }; - }, [zoom]); + }, [maxZoom, zoom]); const effectiveZoom = zoom === FIT_ZOOM_VALUE ? fitZoom : Number(zoom); const zoomScale = effectiveZoom / 100; - const minZoom = Math.min(...zoomOptions); - const maxZoom = Math.max(...zoomOptions); + const scaledPageHeight = PDF_PAGE_HEIGHT * zoomScale; const handleOpen = () => { if (onOpen) { @@ -192,6 +202,97 @@ export function PDFViewer({ } }; + const viewerContent = ( + +
+ {loading ? ( +
+
+ + {resolvedLabels.loading} +
+
+ ) : null} + + {!loading && error ? ( +
+ + {resolvedLabels.errorTitle} + +

{error}

+ {onRetry ? ( + + ) : null} +
+
+
+ ) : null} + + {!(loading || error) && resolvedSrc ? ( +
+ {variant === "native-inline" ? ( +