From c9d375f40c4986e8768a564038672a10eb6d8887 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 19 Oct 2025 01:05:02 +0200 Subject: [PATCH] Facturas de cliente --- .../customer-invoices/src/web/hooks/index.ts | 1 + .../web/hooks/use-pinned-preview-sheet.tsx | 140 +++++++ .../web/pages/list/invoice-preview-card.tsx | 349 ++++++++++++++++++ .../src/web/pages/list/invoices-list-grid.tsx | 6 +- .../src/web/pages/list/invoices-list-page.tsx | 59 ++- .../src/components/datatable/data-table.tsx | 1 + 6 files changed, 542 insertions(+), 14 deletions(-) create mode 100644 modules/customer-invoices/src/web/hooks/use-pinned-preview-sheet.tsx create mode 100644 modules/customer-invoices/src/web/pages/list/invoice-preview-card.tsx diff --git a/modules/customer-invoices/src/web/hooks/index.ts b/modules/customer-invoices/src/web/hooks/index.ts index e5084314..d89192ff 100644 --- a/modules/customer-invoices/src/web/hooks/index.ts +++ b/modules/customer-invoices/src/web/hooks/index.ts @@ -3,4 +3,5 @@ export * from "./use-create-customer-invoice-mutation"; export * from "./use-customer-invoices-query"; export * from "./use-invoice-query"; export * from "./use-items-table-navigation"; +export * from "./use-pinned-preview-sheet"; export * from "./use-update-customer-invoice-mutation"; diff --git a/modules/customer-invoices/src/web/hooks/use-pinned-preview-sheet.tsx b/modules/customer-invoices/src/web/hooks/use-pinned-preview-sheet.tsx new file mode 100644 index 00000000..2b740e8c --- /dev/null +++ b/modules/customer-invoices/src/web/hooks/use-pinned-preview-sheet.tsx @@ -0,0 +1,140 @@ +import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@repo/shadcn-ui/components"; +// hooks/use-pinned-preview-sheet.ts +import * as React from "react"; + +type UsePinnedPreviewSheetOptions = { + /** Persiste el pin en localStorage (clave). Si omites, no persiste. */ + persistKey?: string; + /** Anchura del panel anclado (Tailwind). */ + pinnedWidthClass?: string; // p.ej. "w-[420px]" + /** Título del Sheet (no anclado). */ + title?: string | ((item: T | null) => string); +}; + +export type PinnedPreviewSheetAPI = { + /** Estado */ + isOpen: boolean; + isPinned: boolean; + item: T | null; + + /** Acciones */ + open: (item: T) => void; + close: () => void; + togglePin: () => void; + setItem: (item: T | null) => void; + + /** Renderizado: coloca este nodo cerca del listado (al final de la página/feature) */ + PreviewContainer: React.FC<{ + /** Render del cuerpo del preview */ + children: (item: T) => React.ReactNode; + /** Cabecera opcional (si quieres sustituir el SheetHeader) */ + header?: React.ReactNode; + }>; +}; + +export function usePinnedPreviewSheet( + opts?: UsePinnedPreviewSheetOptions +): PinnedPreviewSheetAPI { + const { persistKey, pinnedWidthClass = "w-[420px]", title } = opts ?? {}; + const [isOpen, setOpen] = React.useState(false); + const [item, setItem] = React.useState(null); + const [isPinned, setPinned] = React.useState(() => { + if (!persistKey) return false; + try { + return localStorage.getItem(persistKey) === "1"; + } catch { + return false; + } + }); + + const open = React.useCallback((next: T) => { + setItem(next); + setOpen(true); + }, []); + + const close = React.useCallback(() => { + if (isPinned) return; // Anclado: no cerrar + setOpen(false); + }, [isPinned]); + + const togglePin = React.useCallback(() => { + setPinned((p) => { + const next = !p; + if (persistKey) { + try { + localStorage.setItem(persistKey, next ? "1" : "0"); + } catch { } + } + // Si se fija el pin y hay item, abre “estático”; si se desancla, mostramos Sheet si había abierto + if (!next && item) setOpen(true); + return next; + }); + }, [persistKey, item]); + + const HeaderTitle = React.useMemo(() => { + if (!item) return ""; + if (typeof title === "function") return title(item); + return title ?? ""; + }, [item, title]); + + const PreviewContainer: PinnedPreviewSheetAPI["PreviewContainer"] = React.useCallback( + ({ children, header }) => { + if (!item) return null; + + // Modo anclado: aside fijo sin overlay, no bloquea scroll, accesible. + if (isPinned) { + return ( + + ); + } + + // Modo Sheet (deslizable desde la derecha) + return ( + (o ? setOpen(true) : close())}> + + {header ?? ( + + {HeaderTitle} + + )} +
{children(item)}
+
+ +
+
+
+ ); + }, + [HeaderTitle, close, isOpen, isPinned, item, pinnedWidthClass, togglePin] + ); + + return { isOpen, isPinned, item, open, close, togglePin, setItem, PreviewContainer }; +} diff --git a/modules/customer-invoices/src/web/pages/list/invoice-preview-card.tsx b/modules/customer-invoices/src/web/pages/list/invoice-preview-card.tsx new file mode 100644 index 00000000..c31df059 --- /dev/null +++ b/modules/customer-invoices/src/web/pages/list/invoice-preview-card.tsx @@ -0,0 +1,349 @@ +import { Badge, Button, Card, CardContent, Separator } from "@repo/shadcn-ui/components"; +import { + Calendar, + Copy, + CreditCard, + Download, + Edit, + FileText, + Hash, + Mail, + MapPin, + Pin, + Receipt, + Trash2, + User, + X, +} from "lucide-react"; +import { InvoiceSummaryFormData } from '../../schemas'; + +export type InvoicePreviewCardProps = { + invoice: InvoiceSummaryFormData + isOpen: boolean + isPinned: boolean + onClose: () => void + onTogglePin: () => void +} + + +export const InvoicePreviewCard = ({ + invoice, + isOpen, + isPinned, + onClose, + onTogglePin +}: InvoicePreviewCardProps) => { + + return <> + {/* Overlay - only show when not pinned */} + {isOpen && !isPinned && ( +
+ )} + + {/* Sheet */} +
+ {/* Header with gradient */} +
+
+
+

+ {invoice.is_proforma ? "Proforma" : "Factura"} {invoice.invoice_number} +

+

+ Serie: {invoice.series} • Ref: {invoice.reference} +

+
+
+ + +
+
+ + {/* Status Badge */} + + {invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)} + +
+ + {/* Content */} +
+
+
+ +

Cliente

+
+
+

{invoice.recipient.name}

+
+ +
+ TIN: + {invoice.recipient.tin} +
+
+
+ +
+
{invoice.recipient.street}
+ {invoice.recipient.street2 &&
{invoice.recipient.street2}
} +
+ {invoice.recipient.postal_code} {invoice.recipient.city}, {invoice.recipient.province} +
+
{invoice.recipient.country}
+
+
+
+
+ + + +
+
+ +

Fechas

+
+
+
+ Fecha factura +

{invoice.invoice_date}

+
+
+ + Fecha operación + +

{invoice.operation_date}

+
+
+
+ + + + {invoice.description && ( + <> +
+
+ +

Descripción

+
+

+ {invoice.description} +

+
+ + + )} + + {invoice.items && invoice.items.length > 0 && ( + <> +
+
+ +

Conceptos

+
+
+ {invoice.items.map((item, index) => ( +
+
+
+ {item.concepto} + {item.descripcion} +
+ + {formatCurrency(item.total)} + +
+
+
+ {item.cantidad} × {formatCurrency(item.precio)} +
+ {item.descuento > 0 && ( + + -{item.descuento}% dto. + + )} +
+ {item.impuestos && item.impuestos.length > 0 && ( +
+ {item.impuestos.map((tax, taxIndex) => ( + + {tax} + + ))} +
+ )} +
+ ))} +
+
+ + + )} + +
+
+ +

Resumen Financiero

+
+
+
+ Subtotal + + {invoice.subtotal_amount_fmt} + +
+ + {invoice.discount_amount > 0 && ( + <> +
+ + Descuento ({invoice.discount_percentage}%) + + + -{invoice.discount_amount_fmt} + +
+
+ Base imponible + + {invoice.taxable_amount_fmt} + +
+ + )} + + + + {/*invoice.taxes && invoice.taxes.length > 0 && ( +
+ {invoice.taxes.map((tax, index) => ( +
+ + {tax.name} {tax.rate}% + + + {invoice.taxable_amount_fmt} + +
+ ))} +
+ )*/} + +
+ Total impuestos + + {invoice.taxes_amount_fmt} + +
+ + + +
+ Total + + {invoice.total_amount_fmt} + +
+
+
+
+ + {/* Actions Footer */} +
+
+ + + + +
+ +
+
+ ; + + return ( + + +
+ + {invoice.invoice_number} +
+
+ Cliente + + {invoice.recipient?.name} + +
+
+ Fecha + {invoice.invoice_date} +
+
+ Importe + {invoice.total_amount?.formatted} +
+
+ Estado + {invoice.status} +
+
+
+ ); +} diff --git a/modules/customer-invoices/src/web/pages/list/invoices-list-grid.tsx b/modules/customer-invoices/src/web/pages/list/invoices-list-grid.tsx index 74349b23..808eb55a 100644 --- a/modules/customer-invoices/src/web/pages/list/invoices-list-grid.tsx +++ b/modules/customer-invoices/src/web/pages/list/invoices-list-grid.tsx @@ -21,6 +21,8 @@ export type InvoiceUpdateCompProps = { searchValue: string; onSearchChange: (value: string) => void; + + onRowClick?: (row: InvoiceSummaryFormData, index: number, event: React.MouseEvent) => void; } @@ -33,11 +35,11 @@ export const InvoicesListGrid = ({ onPageChange, onPageSizeChange, searchValue, onSearchChange, + onRowClick }: InvoiceUpdateCompProps) => { const { t } = useTranslation(); const navigate = useNavigate(); - const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("todas"); const columns = useInvoicesListColumns(); @@ -183,7 +185,7 @@ export const InvoicesListGrid = ({ totalItems={total_items} onPageChange={onPageChange} onPageSizeChange={onPageSizeChange} - onRowClick={handleRowClick} + onRowClick={onRowClick} />
diff --git a/modules/customer-invoices/src/web/pages/list/invoices-list-page.tsx b/modules/customer-invoices/src/web/pages/list/invoices-list-page.tsx index f1a1fe60..bd6f5321 100644 --- a/modules/customer-invoices/src/web/pages/list/invoices-list-page.tsx +++ b/modules/customer-invoices/src/web/pages/list/invoices-list-page.tsx @@ -2,12 +2,14 @@ import { ErrorAlert } from '@erp/customers/components'; import { AppBreadcrumb, AppContent, AppHeader, BackHistoryButton, useDebounce } from "@repo/rdx-ui/components"; import { Button } from "@repo/shadcn-ui/components"; import { FilePenIcon, PlusIcon } from "lucide-react"; -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useNavigate } from "react-router-dom"; import { InvoicesListGrid, PageHeader } from '../../components'; -import { useInvoicesQuery } from '../../hooks'; +import { useInvoicesQuery, usePinnedPreviewSheet } from '../../hooks'; import { useTranslation } from "../../i18n"; +import { InvoiceSummaryFormData } from '../../schemas'; import { invoiceResumeDtoToFormAdapter } from '../../schemas/invoice-resume-dto.adapter'; +import { InvoicePreviewCard } from './invoice-preview-card'; export const InvoiceListPage = () => { const { t } = useTranslation(); @@ -45,6 +47,13 @@ export const InvoiceListPage = () => { } }, [data]); + + const invoicePreview = usePinnedPreviewSheet({ + persistKey: "invoice-preview-pin", + pinnedWidthClass: "w-[440px]", + title: (inv) => (inv ? `Factura ${inv.invoice_number}` : "Proforma"), + }); + const handlePageChange = (newPageIndex: number) => { // TanStack usa pageIndex 0-based → API usa 0-based también setPageIndex(newPageIndex); @@ -62,6 +71,18 @@ export const InvoiceListPage = () => { setPageIndex(0); }; + const handleRowClick = useCallback( + (invoice: InvoiceSummaryFormData, _index: number, e: React.MouseEvent) => { + const url = `/customer-invoices/${invoice.id}/edit`; + if (e.metaKey || e.ctrlKey) { + window.open(url, "_blank", "noopener,noreferrer"); + return; + } + invoicePreview.open(invoice); // <-- abre o actualiza el preview + }, + [invoicePreview] + ); + if (isError || !invoicesPageData) { return ( @@ -105,16 +126,30 @@ export const InvoiceListPage = () => {
- +
+ +
+ + + {/* Contenedor del preview (Sheet o aside anclado) */} + + {(invoice) => } +
diff --git a/packages/rdx-ui/src/components/datatable/data-table.tsx b/packages/rdx-ui/src/components/datatable/data-table.tsx index db963392..26b955ab 100644 --- a/packages/rdx-ui/src/components/datatable/data-table.tsx +++ b/packages/rdx-ui/src/components/datatable/data-table.tsx @@ -233,6 +233,7 @@ export function DataTable({ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onRowClick?.(row.original, rowIndex, e as any); }} + onClick={(e) => onRowClick?.(row.original, rowIndex, e)} onDoubleClick={ !readOnly && !onRowClick ? () => setEditIndex(rowIndex) : undefined } >