diff --git a/modules/customer-invoices/src/web/customer-invoice-routes.tsx b/modules/customer-invoices/src/web/customer-invoice-routes.tsx index 4fad2b45..8c6c4158 100644 --- a/modules/customer-invoices/src/web/customer-invoice-routes.tsx +++ b/modules/customer-invoices/src/web/customer-invoice-routes.tsx @@ -26,6 +26,10 @@ const ProformaUpdatePage = lazy(() => import("./proformas/update").then((m) => ({ default: m.ProformaUpdatePage })) ); +const ProformaDetailPage = lazy(() => + import("./proformas/detail").then((m) => ({ default: m.ProformaDetailPage })) +); + const IssuedInvoicesLayout = lazy(() => import("./issued-invoices/shared/ui").then((m) => ({ default: m.IssuedInvoicesLayout })) ); @@ -93,6 +97,19 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] ), }, + { + path: "proformas/:id", + handle: { + layout: "app-fullscreen", + protected: true, + }, + element: ( + + + + ), + }, + { path: "customer-invoices", handle: { diff --git a/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-header.tsx b/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-header.tsx index 68066753..b76011b8 100644 --- a/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-header.tsx +++ b/modules/customer-invoices/src/web/proformas/create/ui/blocks/proforma-create-header.tsx @@ -1,7 +1,9 @@ -import { PageFormHeader, PageKeyboardShortcutsButton } from "@erp/core/components"; +import { PageKeyboardShortcutsButton } from "@erp/core/components"; import { CancelActionButton, FormActionsBar, RhfSubmitActionButton } from "@repo/rdx-ui/components"; import type { ReactNode } from "react"; +import { ProformaPageHeader } from "../../../shared"; + export interface ProformaCreateHeaderProps { formId: string; isSaving: boolean; @@ -16,7 +18,7 @@ export const ProformaCreateHeader = ({ children, }: ProformaCreateHeaderProps) => { return ( - } - backLabel="Volver" onBack={onCancel} title="Nueva proforma" > {children} - + ); }; diff --git a/modules/customer-invoices/src/web/proformas/detail/controllers/index.ts b/modules/customer-invoices/src/web/proformas/detail/controllers/index.ts new file mode 100644 index 00000000..bad74a30 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/detail/controllers/index.ts @@ -0,0 +1 @@ +export * from "./use-proforma-detail-page-controller"; 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 new file mode 100644 index 00000000..1c287d4b --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/detail/controllers/use-proforma-detail-page-controller.ts @@ -0,0 +1,250 @@ +import { useReturnToNavigation } from "@erp/core/hooks"; +import { + getPaymentMethodOptions, + usePaymentMethodsListQuery, +} from "@erp/catalogs/client/payment-methods"; +import { + getPaymentTermOptions, + usePaymentTermsListQuery, +} from "@erp/catalogs/client/payment-terms"; +import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers"; +import { useEffect, useMemo, useState } from "react"; +import { createSearchParams, useNavigate, useParams } from "react-router-dom"; + +import { useTranslation } from "../../../i18n"; +import { useDownloadProformaPDFController, useDownloadProformaPDFQuery } from "../../download-pdf"; +import { useIssueProformaDialogController } from "../../issue-proforma"; +import { prepareIssueProformaTarget } from "../../issue-proforma/utils"; +import { + useDuplicateProformaByIdMutation, + useProformaArchiveMutation, + useProformaGetQuery, + useProformaUnarchiveMutation, +} from "../../shared"; + +const PROFORMAS_FALLBACK_PATH = "/proformas"; + +const ACTIVE_ONLY_FILTERS = [ + { + field: "isActive", + operator: "EQUALS" as const, + value: "true", + }, +]; + +export const useProformaDetailPageController = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { id = "" } = useParams<{ id: string }>(); + const [previewZoom, setPreviewZoom] = useState("fit"); + + const { currentReturnTo, navigateBack } = useReturnToNavigation({ + fallbackPath: PROFORMAS_FALLBACK_PATH, + }); + + const proformaQuery = useProformaGetQuery({ + id, + enabled: Boolean(id), + }); + + const issueDialogCtrl = useIssueProformaDialogController(); + const downloadCtrl = useDownloadProformaPDFController(); + const previewPdfQuery = useDownloadProformaPDFQuery(id, { + enabled: Boolean(id), + }); + const duplicateMutation = useDuplicateProformaByIdMutation(); + const archiveMutation = useProformaArchiveMutation(); + const unarchiveMutation = useProformaUnarchiveMutation(); + const [previewPdfUrl, setPreviewPdfUrl] = useState(null); + + useEffect(() => { + if (!previewPdfQuery.data) { + setPreviewPdfUrl(null); + return; + } + + const objectUrl = URL.createObjectURL(previewPdfQuery.data); + setPreviewPdfUrl(objectUrl); + + return () => { + URL.revokeObjectURL(objectUrl); + }; + }, [previewPdfQuery.data]); + + const paymentMethodsQuery = usePaymentMethodsListQuery({ + criteria: { + pageSize: 999, + filters: ACTIVE_ONLY_FILTERS, + }, + }); + + const paymentTermsQuery = usePaymentTermsListQuery({ + criteria: { + pageSize: 999, + filters: ACTIVE_ONLY_FILTERS, + }, + }); + + const paymentMethodOptions = useMemo(() => { + return getPaymentMethodOptions(paymentMethodsQuery.data?.items ?? []); + }, [paymentMethodsQuery.data?.items]); + + const paymentTermOptions = useMemo(() => { + return getPaymentTermOptions(paymentTermsQuery.data?.items ?? []); + }, [paymentTermsQuery.data?.items]); + + const proforma = proformaQuery.data; + + const paymentMethodLabel = + paymentMethodOptions.find((option) => option.value === proforma?.paymentMethodId)?.label ?? + null; + + const paymentTermLabel = + paymentTermOptions.find((option) => option.value === proforma?.paymentTermId)?.label ?? null; + + const handleEdit = () => { + if (!proforma) { + return; + } + + navigate({ + pathname: `/proformas/${proforma.id}/edit`, + search: createSearchParams({ + returnTo: currentReturnTo, + }).toString(), + }); + }; + + const handleDownload = () => { + if (!proforma) { + return; + } + + downloadCtrl.download(proforma.id, proforma.proformaReference); + }; + + const handleOpenPreviewPdf = () => { + if (previewPdfUrl) { + window.open(previewPdfUrl, "_blank", "noopener,noreferrer"); + return; + } + + handleDownload(); + }; + + const handlePrintPreview = () => { + handleOpenPreviewPdf(); + }; + + const handleDuplicate = async () => { + if (!proforma) { + return; + } + + try { + const duplicated = await duplicateMutation.mutateAsync({ proformaId: proforma.id }); + + showSuccessToast( + t("proformas.duplicate.success_title"), + duplicated.proformaReference + ? t("proformas.duplicate.success_with_reference", { + reference: duplicated.proformaReference, + }) + : t("proformas.duplicate.success_description") + ); + + navigate({ + pathname: `/proformas/${duplicated.id}/edit`, + search: createSearchParams({ + returnTo: currentReturnTo, + }).toString(), + }); + } catch (error) { + const message = + error instanceof Error + ? error.message + : t("proformas.duplicate.error_unknown_message"); + + showErrorToast(t("proformas.duplicate.error_title"), message); + } + }; + + const handleArchiveToggle = async () => { + if (!proforma) { + return; + } + + try { + if (proforma.isArchived) { + await unarchiveMutation.mutateAsync({ proformaId: proforma.id }); + + showSuccessToast("Proforma desarchivada", "La proforma vuelve a estar disponible."); + } else { + await archiveMutation.mutateAsync({ proformaId: proforma.id }); + + showSuccessToast("Proforma archivada", "La proforma se ha movido al archivo."); + } + + await proformaQuery.refetch(); + } catch (error) { + showErrorToast( + proforma.isArchived ? "No se pudo desarchivar" : "No se pudo archivar", + error instanceof Error ? error.message : "Inténtalo de nuevo más tarde." + ); + } + }; + + const primaryAction = useMemo(() => { + if (!proforma) { + return { + label: "Editar", + onClick: () => undefined, + }; + } + + if (proforma.isArchived) { + return { + label: "Desarchivar", + onClick: handleArchiveToggle, + }; + } + + if (proforma.status === "approved") { + return { + label: "Emitir factura", + onClick: () => issueDialogCtrl.openDialog(prepareIssueProformaTarget(proforma)), + }; + } + + return { + label: "Editar", + onClick: handleEdit, + }; + }, [handleArchiveToggle, issueDialogCtrl, proforma]); + + return { + id, + proforma, + proformaQuery, + paymentMethodLabel, + paymentTermLabel, + previewZoom, + setPreviewZoom, + previewPdfQuery, + previewPdfUrl, + currentReturnTo, + navigateBack, + primaryAction, + issueDialogCtrl, + downloadCtrl, + duplicateMutation, + archiveMutation, + unarchiveMutation, + handleEdit, + handleDownload, + handleOpenPreviewPdf, + handlePrintPreview, + handleDuplicate, + handleArchiveToggle, + }; +}; diff --git a/modules/customer-invoices/src/web/proformas/detail/index.ts b/modules/customer-invoices/src/web/proformas/detail/index.ts new file mode 100644 index 00000000..0d88782e --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/detail/index.ts @@ -0,0 +1,2 @@ +export * from "./controllers"; +export * from "./ui"; diff --git a/modules/customer-invoices/src/web/proformas/detail/ui/index.ts b/modules/customer-invoices/src/web/proformas/detail/ui/index.ts new file mode 100644 index 00000000..c4e34b27 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/detail/ui/index.ts @@ -0,0 +1 @@ +export * from "./pages"; diff --git a/modules/customer-invoices/src/web/proformas/detail/ui/pages/index.ts b/modules/customer-invoices/src/web/proformas/detail/ui/pages/index.ts new file mode 100644 index 00000000..27898614 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/detail/ui/pages/index.ts @@ -0,0 +1 @@ +export * from "./proforma-detail-page"; diff --git a/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx b/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx new file mode 100644 index 00000000..d08e4110 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/detail/ui/pages/proforma-detail-page.tsx @@ -0,0 +1,509 @@ +import { ErrorAlert, NotFoundCard } from "@erp/core/components"; +import { AppContent, BackHistoryButton, FieldValueList, PDFViewer } from "@repo/rdx-ui/components"; +import { MoneyHelper } from "@repo/rdx-utils"; +import { + Alert, + AlertDescription, + AlertTitle, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Separator, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@repo/shadcn-ui/components"; +import { cn } from "@repo/shadcn-ui/lib/utils"; +import { + CheckCircle2Icon, + DownloadIcon, + MoreHorizontalIcon, + PencilIcon, + RefreshCwIcon, + SquarePenIcon, +} from "lucide-react"; +import { type ReactNode, useMemo } from "react"; + +import { IssueProformaDialog } from "../../../issue-proforma"; +import { ProformaStatusBadge } from "../../../list/ui/components"; +import { type Proforma, type ProformaItem, ProformaPageHeader } from "../../../shared"; +import { useProformaDetailPageController } from "../../controllers"; + +const ZOOM_OPTIONS = [ + { label: "Ajustar", value: "fit" }, + { label: "75%", value: "75" }, + { label: "90%", value: "90" }, + { label: "100%", value: "100" }, + { label: "125%", value: "125" }, +]; + +const DOCUMENT_WIDTH = 794; +const DOCUMENT_HEIGHT = 1123; + +const formatDate = (value: string | null | undefined) => { + if (!value) { + return "No informado"; + } + + const [year, month, day] = value.split("-"); + if (!(year && month && day)) { + return value; + } + + return `${day}/${month}/${year}`; +}; + +const formatMoney = (amount: number, currencyCode: string) => { + return MoneyHelper.formatCurrency(amount, 2, currencyCode); +}; + +const formatQuantity = (value: number | null) => { + if (value === null) { + return "-"; + } + + return new Intl.NumberFormat("es-ES", { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(value); +}; + +const getStatusTone = (status: Proforma["status"]) => { + switch (status) { + case "approved": + return "border-emerald-200 bg-emerald-50 text-emerald-900"; + case "sent": + return "border-amber-200 bg-amber-50 text-amber-900"; + case "issued": + return "border-sky-200 bg-sky-50 text-sky-900"; + case "rejected": + return "border-red-200 bg-red-50 text-red-900"; + default: + return "border-slate-200 bg-slate-50 text-slate-900"; + } +}; + +const getStatusSummary = (proforma: Proforma) => { + if (proforma.isArchived) { + return { + title: "Proforma archivada", + description: "Está fuera del flujo principal y puedes recuperarla cuando lo necesites.", + }; + } + + switch (proforma.status) { + case "approved": + return { + title: "Lista para convertirse en factura", + description: "La proforma ya está aprobada y puede emitirse como factura.", + }; + case "sent": + return { + title: "Pendiente de respuesta del cliente", + description: "Se ha enviado y está a la espera de aceptación o rechazo.", + }; + case "issued": + return { + title: "Convertida en factura", + description: "La proforma ya ha sido emitida y forma parte del flujo fiscal.", + }; + case "rejected": + return { + title: "Requiere revisión", + description: "El cliente la ha rechazado y conviene revisar condiciones o importes.", + }; + default: + return { + title: "Pendiente de revisión", + description: "Sigue en borrador y todavía puede ajustarse antes de enviarla.", + }; + } +}; + + +const InspectorCard = ({ + title, + onEdit, + children, +}: { + title: string; + onEdit?: () => void; + children: ReactNode; +}) => { + return ( + + + {title} + {onEdit ? ( + + ) : null} + + {children} + + ); +}; + +const StatusInspectorCard = ({ proforma }: { proforma: Proforma }) => { + const summary = getStatusSummary(proforma); + + return ( + + + {summary.title} + {summary.description} + + ); +}; + +const BillingInspectorCard = ({ + proforma, + paymentMethodLabel, + paymentTermLabel, + onEdit, +}: { + proforma: Proforma; + paymentMethodLabel: string | null; + paymentTermLabel: string | null; + onEdit: () => void; +}) => { + const items = [ + { + label: "Método de pago", + value: paymentMethodLabel ?? "No informado", + }, + { + label: "Plazo", + value: paymentTermLabel ?? "No informado", + }, + { + label: "Moneda", + value: proforma.currencyCode, + }, + ]; + + return ( + + + + ); +}; + +const DatesInspectorCard = ({ proforma, onEdit }: { proforma: Proforma; onEdit: () => void }) => { + const items = [ + { + label: "Fecha proforma", + value: formatDate(proforma.proformaDate), + }, + { + label: "Fecha operación", + value: formatDate(proforma.operationDate), + }, + { + label: "Serie", + value: proforma.series ?? "No informada", + }, + { + label: "Referencia interna", + value: proforma.reference ?? "Sin referencia", + }, + ]; + + return ( + + + + ); +}; + +const NotesInspectorCard = ({ proforma, onEdit }: { proforma: Proforma; onEdit: () => void }) => { + return ( + +
+
+

Descripción

+

{proforma.description ?? "Sin descripción."}

+
+ +
+

Observaciones

+

{proforma.notes ?? "Sin notas adicionales."}

+
+
+
+ ); +}; + +const TotalsInspectorCard = ({ proforma }: { proforma: Proforma }) => { + const items = [ + { + label: "Subtotal", + value: formatMoney(proforma.subtotalAmount, proforma.currencyCode), + }, + { + label: "Descuentos", + value: `-${formatMoney(proforma.totalDiscountAmount, proforma.currencyCode)}`, + valueClassName: "text-red-600", + }, + { + label: "IVA", + value: formatMoney(proforma.ivaAmount, proforma.currencyCode), + }, + { + label: "Total", + value: formatMoney(proforma.totalAmount, proforma.currencyCode), + valueClassName: "text-lg font-semibold text-foreground", + }, + ]; + + return ( + + + + ); +}; + +const ProformaDetailSkeleton = () => { + return ( +
+ + + + + + + + +
+ + + + + + + +
+ + + + + +
+
+
+ ); +}; + +const HeaderMeta = ({ proforma }: { proforma: Proforma }) => { + const items = useMemo( + () => [ + proforma.recipient.name ?? "Cliente sin nombre", + formatDate(proforma.proformaDate), + `Total ${formatMoney(proforma.totalAmount, proforma.currencyCode)}`, + ], + [proforma] + ); + + return ( +
+ {items.map((item, index) => ( +
+ {index > 0 ? | : null} + {item} +
+ ))} +
+ ); +}; + +export const ProformaDetailPage = () => { + const ctrl = useProformaDetailPageController(); + + if (ctrl.proformaQuery.isLoading) { + return ; + } + + if (ctrl.proformaQuery.isError) { + return ( + + + +
+ ctrl.navigateBack()} /> +
+
+ ); + } + + if (!ctrl.proforma) { + return ( + + + + ); + } + + return ( +
+ + + + + + + + + + + + } + /> + + + Duplicar proforma + + + {ctrl.proforma.isArchived ? "Desarchivar" : "Archivar"} + + + +
+ } + backLabel="Volver al listado" + onBack={ctrl.navigateBack} + title={`Proforma ${ctrl.proforma.proformaReference}`} + > +
+
+ + +
+
+ + +
+
+ + + + + + +
+ + + + + + + + + + + + + Líneas incluidas + + + + + + Concepto + Total + + + + {ctrl.proforma.items.slice(0, 4).map((item: ProformaItem) => ( + + + {item.description ?? "Sin descripción"} + + + {formatMoney(item.totalAmount, ctrl.proforma.currencyCode)} + + + ))} + +
+
+
+
+
+
+ + { + if (!open) { + ctrl.issueDialogCtrl.closeDialog(); + } + }} + open={ctrl.issueDialogCtrl.open} + target={ctrl.issueDialogCtrl.target} + /> + + ); +}; diff --git a/modules/customer-invoices/src/web/proformas/index.ts b/modules/customer-invoices/src/web/proformas/index.ts index 1c77bc4e..8a02684c 100644 --- a/modules/customer-invoices/src/web/proformas/index.ts +++ b/modules/customer-invoices/src/web/proformas/index.ts @@ -1,3 +1,4 @@ export * from "./list"; export * from "./create"; export * from "./update"; +export * from "./detail"; diff --git a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.tsx b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.tsx index eb51dca6..757dcc89 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.tsx @@ -43,6 +43,7 @@ import type { ProformaListScope } from "../../../types/proforma-list-filters"; import { ProformaStatusBadge } from "../../components"; type GridActionHandlers = { + onViewClick?: (proforma: ProformaListRow) => void; onPreviewClick?: (proforma: ProformaListRow) => void; onEditClick?: (proforma: ProformaListRow) => void; onIssueClick?: (proforma: ProformaListRow) => void; @@ -297,6 +298,7 @@ export function useProformasGridColumns( : []; const canEdit = isNormalScope && !isIssued && actionHandlers.onEditClick; + const canView = !isDeletedScope && actionHandlers.onViewClick; const canDuplicate = !isDeletedScope && actionHandlers.onDuplicateClick; const canIssue = isNormalScope && !isIssued && isApproved && actionHandlers.onIssueClick; const canDelete = !isDeletedScope && isDraft && actionHandlers.onDeleteClick; @@ -307,6 +309,7 @@ export function useProformasGridColumns( const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation(); const nextTransition = availableTransitions[0] ?? null; + const handleView = () => actionHandlers.onViewClick?.(proforma); const handleEdit = () => actionHandlers.onEditClick?.(proforma); const handleDuplicate = () => actionHandlers.onDuplicateClick?.(proforma); const handleArchive = () => actionHandlers.onArchiveClick?.(proforma); @@ -396,6 +399,13 @@ export function useProformasGridColumns( })(); const menuActions = [ + canView + ? { + icon: ExternalLinkIcon, + label: "Ver", + onClick: handleView, + } + : null, !isIssued && nextTransition && actionHandlers.onChangeStatusClick && diff --git a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx index 13071f1d..1acbdcf1 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx @@ -238,6 +238,15 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => { }); }; + const handleOpenDetailClick = (proformaId: string) => { + navigate({ + pathname: `/proformas/${proformaId}`, + search: createSearchParams({ + returnTo: currentReturnTo, + }).toString(), + }); + }; + const handleDuplicateClick = async (proforma: ProformaListRow) => { try { const duplicated = await duplicateMutation.mutateAsync({ proformaId: proforma.id }); @@ -265,6 +274,7 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => { }; const columns = useProformasGridColumns(scope, { + onViewClick: (proforma) => handleOpenDetailClick(proforma.id), onEditClick: (proforma) => handleEditClick(proforma.id), onDuplicateClick: handleDuplicateClick, onIssueClick: (proformaRow) => @@ -323,9 +333,7 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => { onPageChange={listCtrl.setPageIndex} onPageSizeChange={listCtrl.setPageSize} onRowClick={ - scopeConfig.enablePanel - ? (proformaId) => panelCtrl.openProformaPanel(proformaId, "view") - : undefined + scope !== "deleted" ? (proformaId) => handleOpenDetailClick(proformaId) : undefined } onSortChange={listCtrl.setSort} pageIndex={listCtrl.pageIndex} diff --git a/modules/customer-invoices/src/web/proformas/shared/ui/blocks/index.ts b/modules/customer-invoices/src/web/proformas/shared/ui/blocks/index.ts index db3dd962..ad176a15 100644 --- a/modules/customer-invoices/src/web/proformas/shared/ui/blocks/index.ts +++ b/modules/customer-invoices/src/web/proformas/shared/ui/blocks/index.ts @@ -1 +1,2 @@ export * from "./proforma-layout"; +export * from "./proforma-page-header"; diff --git a/modules/customer-invoices/src/web/proformas/shared/ui/blocks/proforma-page-header.tsx b/modules/customer-invoices/src/web/proformas/shared/ui/blocks/proforma-page-header.tsx new file mode 100644 index 00000000..e345c8f6 --- /dev/null +++ b/modules/customer-invoices/src/web/proformas/shared/ui/blocks/proforma-page-header.tsx @@ -0,0 +1,42 @@ +import { PageFormHeader } from "@erp/core/components"; +import { cn } from "@repo/shadcn-ui/lib/utils"; +import type { ReactNode } from "react"; + +export interface ProformaPageHeaderProps { + title: string; + backLabel?: string; + onBack?: () => void; + statusLabel?: string; + showStatus?: boolean; + actions?: ReactNode; + children?: ReactNode; + className?: string; + contentClassName?: string; +} + +export const ProformaPageHeader = ({ + title, + backLabel = "Volver", + onBack, + statusLabel, + showStatus = false, + actions, + children, + className, + contentClassName, +}: ProformaPageHeaderProps) => { + return ( + + {children} + + ); +}; diff --git a/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.tsx b/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.tsx index 4f7b8a33..5afa186b 100644 --- a/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.tsx +++ b/modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.tsx @@ -1,4 +1,4 @@ -import { PageFormHeader, PageKeyboardShortcutsButton } from "@erp/core/components"; +import { PageKeyboardShortcutsButton } from "@erp/core/components"; import { CancelActionButton, FormActionsBar, @@ -8,6 +8,8 @@ import { } from "@repo/rdx-ui/components"; import type { ReactNode } from "react"; +import { ProformaPageHeader } from "../../../shared"; + export interface ProformaUpdateHeaderLabels { title: string; back: string; @@ -86,7 +88,7 @@ export const ProformaUpdateHeader = ({ ]; return ( - } - backLabel={labels.back} className={className} onBack={onCancel} showStatus={hasChanges} @@ -129,6 +130,6 @@ export const ProformaUpdateHeader = ({ title={labels.title} > {children} - + ); }; diff --git a/packages/rdx-ui/src/components/field-value-list.ts b/packages/rdx-ui/src/components/field-value-list.ts new file mode 100644 index 00000000..8269f3e9 --- /dev/null +++ b/packages/rdx-ui/src/components/field-value-list.ts @@ -0,0 +1 @@ +export * from "./field-value-list.tsx"; diff --git a/packages/rdx-ui/src/components/field-value-list.tsx b/packages/rdx-ui/src/components/field-value-list.tsx new file mode 100644 index 00000000..ccd73f41 --- /dev/null +++ b/packages/rdx-ui/src/components/field-value-list.tsx @@ -0,0 +1,45 @@ +import { cn } from "@repo/shadcn-ui/lib/utils"; + +export interface FieldValueItem { + label: string; + value: string; + valueClassName?: string; +} + +interface FieldValueListProps { + items: FieldValueItem[]; + className?: string; + itemClassName?: string; + labelClassName?: string; + valueClassName?: string; +} + +export const FieldValueList = ({ + items, + className, + itemClassName, + labelClassName, + valueClassName, +}: FieldValueListProps) => { + return ( +
+ {items.map((item) => ( +
+
{item.label}
+
+ {item.value} +
+
+ ))} +
+ ); +}; diff --git a/packages/rdx-ui/src/components/index.ts b/packages/rdx-ui/src/components/index.ts index 64919012..fc242c56 100644 --- a/packages/rdx-ui/src/components/index.ts +++ b/packages/rdx-ui/src/components/index.ts @@ -4,6 +4,7 @@ export * from "./datatable/index.ts"; export * from "./dynamics-tabs.tsx"; export * from "./entity-sheet/index.ts"; export * from "./error-overlay.tsx"; +export * from "./field-value-list.ts"; export * from "./form/index.ts"; export * from "./full-screen-modal.tsx"; export * from "./initials-avatar.tsx"; @@ -12,6 +13,7 @@ export * from "./loading-overlay/index.ts"; export * from "./logo-verifactu.tsx"; export * from "./lookup-dialog/index.ts"; export * from "./multi-select.tsx"; +export * from "./pdf-viewer.tsx"; export * from "./right-panel/index.ts"; export * from "./scroll-to-top.tsx"; export * from "./tailwind-indicator.tsx"; diff --git a/packages/rdx-ui/src/components/pdf-viewer.tsx b/packages/rdx-ui/src/components/pdf-viewer.tsx new file mode 100644 index 00000000..2dbeddb6 --- /dev/null +++ b/packages/rdx-ui/src/components/pdf-viewer.tsx @@ -0,0 +1,283 @@ +import { + Button, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Spinner, +} from "@repo/shadcn-ui/components"; +import { cn } from "@repo/shadcn-ui/lib/utils"; +import { DownloadIcon, ExternalLinkIcon, PrinterIcon, ZoomInIcon, ZoomOutIcon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +const DEFAULT_ZOOM_OPTIONS = [50, 75, 100, 125, 150]; +const FIT_ZOOM_VALUE = "fit"; +const PDF_PAGE_WIDTH = 794; +const PDF_PAGE_HEIGHT = 1123; + +type PDFViewerProps = { + src?: string | null; + title?: string; + description?: string; + className?: string; + loading?: boolean; + error?: string | null; + defaultZoom?: number | "fit"; + zoomOptions?: number[]; + onOpen?: () => void; + onDownload?: () => void; + onPrint?: () => void; +}; + +const clampZoom = (value: number, zoomOptions: number[]) => { + const min = Math.min(...zoomOptions); + const max = Math.max(...zoomOptions); + + return Math.min(Math.max(value, min), max); +}; + +export function PDFViewer({ + src, + title = "Vista previa", + description = "Documento PDF embebido en la pantalla.", + className, + loading = false, + error = null, + defaultZoom = FIT_ZOOM_VALUE, + zoomOptions = DEFAULT_ZOOM_OPTIONS, + onOpen, + onDownload, + onPrint, +}: PDFViewerProps) { + const viewportRef = useRef(null); + const [fitZoom, setFitZoom] = useState(100); + const [zoom, setZoom] = useState(() => + defaultZoom === FIT_ZOOM_VALUE ? FIT_ZOOM_VALUE : String(clampZoom(defaultZoom, zoomOptions)) + ); + + const resolvedSrc = useMemo(() => { + if (!src) { + return null; + } + + const separator = src.includes("#") ? "&" : "#"; + return `${src}${separator}toolbar=0&navpanes=0&scrollbar=1&view=FitH`; + }, [src]); + + useEffect(() => { + if (zoom !== FIT_ZOOM_VALUE) { + return; + } + + const element = viewportRef.current; + if (!element) { + return; + } + + const updateFitZoom = () => { + 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); + setFitZoom(Math.max(Math.round(nextScale * 100), 25)); + }; + + updateFitZoom(); + + const observer = new ResizeObserver(() => updateFitZoom()); + observer.observe(element); + window.addEventListener("resize", updateFitZoom); + + return () => { + observer.disconnect(); + window.removeEventListener("resize", updateFitZoom); + }; + }, [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 handleOpen = () => { + if (onOpen) { + onOpen(); + return; + } + + if (src) { + window.open(src, "_blank", "noopener,noreferrer"); + } + }; + + const handleDownload = () => { + if (onDownload) { + onDownload(); + return; + } + + if (!src) { + return; + } + + const link = document.createElement("a"); + link.href = src; + link.download = ""; + link.click(); + }; + + const handlePrint = () => { + if (onPrint) { + onPrint(); + return; + } + + if (src) { + window.open(src, "_blank", "noopener,noreferrer"); + } + }; + + return ( +
+
+
+

{title}

+

{description}

+
+ +
+ + + + + + + + + + + +
+
+ +
+ {loading ? ( +
+
+ + Cargando vista previa del PDF... +
+
+ ) : null} + + {!loading && error ? ( +
+
+

No se pudo cargar la vista previa

+

{error}

+
+
+ ) : null} + + {!(loading || error) && resolvedSrc ? ( +
+
+