Facturas de cliente

This commit is contained in:
David Arranz 2025-10-19 01:05:02 +02:00
parent 94be2d066f
commit c9d375f40c
6 changed files with 542 additions and 14 deletions

View File

@ -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";

View File

@ -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<T> = {
/** 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<T> = {
/** 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<T = unknown>(
opts?: UsePinnedPreviewSheetOptions<T>
): PinnedPreviewSheetAPI<T> {
const { persistKey, pinnedWidthClass = "w-[420px]", title } = opts ?? {};
const [isOpen, setOpen] = React.useState(false);
const [item, setItem] = React.useState<T | null>(null);
const [isPinned, setPinned] = React.useState<boolean>(() => {
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<T>["PreviewContainer"] = React.useCallback(
({ children, header }) => {
if (!item) return null;
// Modo anclado: aside fijo sin overlay, no bloquea scroll, accesible.
if (isPinned) {
return (
<aside
aria-label="Vista previa anclada"
className={`fixed inset-y-0 right-0 ${pinnedWidthClass} bg-background border-l z-30 flex flex-col`}
>
<div className="flex items-center justify-between p-3 border-b">
{header ?? (
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{HeaderTitle}</span>
<span className="text-xs text-muted-foreground">(anclado)</span>
</div>
)}
<button
type="button"
onClick={togglePin}
className="text-xs underline"
aria-label="Desanclar panel"
>
Desanclar
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto p-3">{children(item)}</div>
</aside>
);
}
// Modo Sheet (deslizable desde la derecha)
return (
<Sheet open={isOpen} onOpenChange={(o) => (o ? setOpen(true) : close())}>
<SheetContent side="right" className={pinnedWidthClass}>
{header ?? (
<SheetHeader>
<SheetTitle className="text-base">{HeaderTitle}</SheetTitle>
</SheetHeader>
)}
<div className="mt-3">{children(item)}</div>
<div className="mt-4">
<button
type="button"
onClick={togglePin}
className="text-xs underline"
aria-label="Anclar panel"
>
Anclar
</button>
</div>
</SheetContent>
</Sheet>
);
},
[HeaderTitle, close, isOpen, isPinned, item, pinnedWidthClass, togglePin]
);
return { isOpen, isPinned, item, open, close, togglePin, setItem, PreviewContainer };
}

View File

@ -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 && (
<div className='fixed inset-0 bg-black/20 z-40 transition-opacity' onClick={onClose} />
)}
{/* Sheet */}
<div
className={`fixed top-0 right-0 h-full bg-white shadow-2xl z-50 transition-transform duration-300 ease-in-out ${isOpen ? "translate-x-0" : "translate-x-full"
} ${isPinned ? "w-[500px]" : "w-[600px]"}`}
>
{/* Header with gradient */}
<div className='bg-gradient-to-r from-blue-600 to-violet-600 p-6 text-white'>
<div className='flex items-start justify-between mb-4'>
<div>
<h2 className='text-2xl font-bold mb-1'>
{invoice.is_proforma ? "Proforma" : "Factura"} {invoice.invoice_number}
</h2>
<p className='text-blue-100 text-sm'>
Serie: {invoice.series} Ref: {invoice.reference}
</p>
</div>
<div className='flex gap-2'>
<Button
size='icon'
variant='ghost'
onClick={onTogglePin}
className={`text-white hover:bg-white/20 ${isPinned ? "bg-white/30" : ""}`}
title={isPinned ? "Desanclar" : "Anclar"}
>
<Pin className={`h-4 w-4 ${isPinned ? "fill-current" : ""}`} />
</Button>
<Button
size='icon'
variant='ghost'
onClick={onClose}
className='text-white hover:bg-white/20'
>
<X className='h-4 w-4' />
</Button>
</div>
</div>
{/* Status Badge */}
<Badge variant='outline' className='bg-white/20 text-white border-white/30'>
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
</Badge>
</div>
{/* Content */}
<div className='p-6 overflow-y-auto h-[calc(100%-180px)]'>
<div className='mb-6'>
<div className='flex items-center gap-2 mb-3'>
<User className='h-5 w-5 text-blue-600' />
<h3 className='font-semibold text-gray-900'>Cliente</h3>
</div>
<div className='bg-gradient-to-r from-blue-50 to-violet-50 p-4 rounded-lg space-y-2'>
<p className='text-gray-700 font-semibold text-lg'>{invoice.recipient.name}</p>
<div className='flex items-start gap-2 text-sm'>
<Hash className='h-4 w-4 text-gray-500 mt-0.5' />
<div>
<span className='text-gray-500'>TIN:</span>
<span className='ml-2 text-gray-700 font-medium'>{invoice.recipient.tin}</span>
</div>
</div>
<div className='flex items-start gap-2 text-sm'>
<MapPin className='h-4 w-4 text-gray-500 mt-0.5' />
<div className='text-gray-600'>
<div>{invoice.recipient.street}</div>
{invoice.recipient.street2 && <div>{invoice.recipient.street2}</div>}
<div>
{invoice.recipient.postal_code} {invoice.recipient.city}, {invoice.recipient.province}
</div>
<div>{invoice.recipient.country}</div>
</div>
</div>
</div>
</div>
<Separator className='my-6' />
<div className='mb-6'>
<div className='flex items-center gap-2 mb-3'>
<Calendar className='h-5 w-5 text-blue-600' />
<h3 className='font-semibold text-gray-900'>Fechas</h3>
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='bg-blue-50 p-3 rounded-lg'>
<span className='text-xs font-medium text-gray-600 block mb-1'>Fecha factura</span>
<p className='text-gray-900 font-semibold'>{invoice.invoice_date}</p>
</div>
<div className='bg-violet-50 p-3 rounded-lg'>
<span className='text-xs font-medium text-gray-600 block mb-1'>
Fecha operación
</span>
<p className='text-gray-900 font-semibold'>{invoice.operation_date}</p>
</div>
</div>
</div>
<Separator className='my-6' />
{invoice.description && (
<>
<div className='mb-6'>
<div className='flex items-center gap-2 mb-3'>
<FileText className='h-5 w-5 text-blue-600' />
<h3 className='font-semibold text-gray-900'>Descripción</h3>
</div>
<p className='text-gray-600 text-sm bg-gray-50 p-3 rounded-lg'>
{invoice.description}
</p>
</div>
<Separator className='my-6' />
</>
)}
{invoice.items && invoice.items.length > 0 && (
<>
<div className='mb-6'>
<div className='flex items-center gap-2 mb-3'>
<Receipt className='h-5 w-5 text-blue-600' />
<h3 className='font-semibold text-gray-900'>Conceptos</h3>
</div>
<div className='space-y-3'>
{invoice.items.map((item, index) => (
<div
key={index}
className='bg-gradient-to-r from-blue-50 to-violet-50 p-4 rounded-lg'
>
<div className='flex justify-between items-start mb-2'>
<div className='flex-1'>
<span className='font-semibold text-gray-900 block'>{item.concepto}</span>
<span className='text-sm text-gray-600'>{item.descripcion}</span>
</div>
<span className='font-bold text-gray-900 ml-4'>
{formatCurrency(item.total)}
</span>
</div>
<div className='flex items-center justify-between text-sm text-gray-600 mt-2'>
<div>
{item.cantidad} × {formatCurrency(item.precio)}
</div>
{item.descuento > 0 && (
<Badge
variant='outline'
className='bg-amber-100 text-amber-700 border-amber-300 text-xs'
>
-{item.descuento}% dto.
</Badge>
)}
</div>
{item.impuestos && item.impuestos.length > 0 && (
<div className='flex gap-1 mt-2'>
{item.impuestos.map((tax, taxIndex) => (
<Badge
key={taxIndex}
variant='outline'
className='bg-blue-100 text-blue-700 border-blue-300 text-xs'
>
{tax}
</Badge>
))}
</div>
)}
</div>
))}
</div>
</div>
<Separator className='my-6' />
</>
)}
<div className='mb-6'>
<div className='flex items-center gap-2 mb-3'>
<CreditCard className='h-5 w-5 text-blue-600' />
<h3 className='font-semibold text-gray-900'>Resumen Financiero</h3>
</div>
<div className='bg-gradient-to-br from-blue-50 to-violet-50 p-4 rounded-lg space-y-3'>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Subtotal</span>
<span className='text-gray-900 font-medium'>
{invoice.subtotal_amount_fmt}
</span>
</div>
{invoice.discount_amount > 0 && (
<>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>
Descuento ({invoice.discount_percentage}%)
</span>
<span className='text-red-600 font-medium'>
-{invoice.discount_amount_fmt}
</span>
</div>
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Base imponible</span>
<span className='text-gray-900 font-medium'>
{invoice.taxable_amount_fmt}
</span>
</div>
</>
)}
<Separator />
{/*invoice.taxes && invoice.taxes.length > 0 && (
<div className='space-y-2'>
{invoice.taxes.map((tax, index) => (
<div key={index} className='flex justify-between text-sm'>
<span className='text-gray-600'>
{tax.name} {tax.rate}%
</span>
<span className='text-gray-900 font-medium'>
{invoice.taxable_amount_fmt}
</span>
</div>
))}
</div>
)*/}
<div className='flex justify-between text-sm'>
<span className='text-gray-600'>Total impuestos</span>
<span className='text-gray-900 font-medium'>
{invoice.taxes_amount_fmt}
</span>
</div>
<Separator />
<div className='flex justify-between items-center pt-2'>
<span className='font-bold text-gray-900 text-lg'>Total</span>
<span className='text-3xl font-bold bg-gradient-to-r from-blue-600 to-violet-600 bg-clip-text text-transparent'>
{invoice.total_amount_fmt}
</span>
</div>
</div>
</div>
</div>
{/* Actions Footer */}
<div className='absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-r from-blue-50 to-violet-50 border-t border-gray-200'>
<div className='grid grid-cols-2 gap-3'>
<Button
variant='outline'
className='border-blue-200 text-blue-600 hover:bg-blue-50 bg-transparent'
>
<Edit className='mr-2 h-4 w-4' />
Editar
</Button>
<Button
variant='outline'
className='border-violet-200 text-violet-600 hover:bg-violet-50 bg-transparent'
>
<Copy className='mr-2 h-4 w-4' />
Duplicar
</Button>
<Button
variant='outline'
className='border-blue-200 text-blue-600 hover:bg-blue-50 bg-transparent'
>
<Download className='mr-2 h-4 w-4' />
Descargar
</Button>
<Button
variant='outline'
className='border-violet-200 text-violet-600 hover:bg-violet-50 bg-transparent'
>
<Mail className='mr-2 h-4 w-4' />
Enviar
</Button>
</div>
<Button
variant='outline'
className='w-full mt-3 border-red-200 text-red-600 hover:bg-red-50 bg-transparent'
>
<Trash2 className='mr-2 h-4 w-4' />
Eliminar factura
</Button>
</div>
</div>
</>;
return (
<Card className="shadow-none border-0">
<CardContent className="p-0 space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium tabular-nums">{invoice.invoice_number}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Cliente</span>
<span className="font-medium truncate max-w-[220px]" title={invoice.recipient?.name}>
{invoice.recipient?.name}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Fecha</span>
<span className="tabular-nums">{invoice.invoice_date}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Importe</span>
<span className="font-semibold tabular-nums">{invoice.total_amount?.formatted}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Estado</span>
<span className="font-medium">{invoice.status}</span>
</div>
</CardContent>
</Card>
);
}

View File

@ -21,6 +21,8 @@ export type InvoiceUpdateCompProps = {
searchValue: string;
onSearchChange: (value: string) => void;
onRowClick?: (row: InvoiceSummaryFormData, index: number, event: React.MouseEvent<HTMLTableRowElement>) => 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}
/>
</div>
</div>

View File

@ -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<InvoiceSummaryFormData>({
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<HTMLTableRowElement>) => {
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 (
<AppContent>
@ -105,16 +126,30 @@ export const InvoiceListPage = () => {
</div>
</div>
<div className='flex flex-col w-full h-full py-3'>
<InvoicesListGrid
invoicesPage={invoicesPageData}
loading={isLoading}
pageIndex={pageIndex}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
searchValue={search}
onSearchChange={handleSearchChange}
/>
<div className={invoicePreview.isPinned ? "flex-1 mr-[440px]" : "flex-1"}>
<InvoicesListGrid
invoicesPage={invoicesPageData}
loading={isLoading}
pageIndex={pageIndex}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
searchValue={search}
onSearchChange={handleSearchChange}
onRowClick={handleRowClick}
/>
</div>
{/* Contenedor del preview (Sheet o aside anclado) */}
<invoicePreview.PreviewContainer>
{(invoice) => <InvoicePreviewCard invoice={invoice}
isOpen={invoicePreview.isOpen}
isPinned={invoicePreview.isPinned}
onClose={invoicePreview.close}
onTogglePin={invoicePreview.togglePin}
/>}
</invoicePreview.PreviewContainer>
</div>
</AppContent>
</>

View File

@ -233,6 +233,7 @@ export function DataTable<TData, TValue>({
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
} >