diff --git a/modules/core/src/common/helpers/money-helper.ts b/modules/core/src/common/helpers/money-helper.ts index a538111a..b7064b4c 100644 --- a/modules/core/src/common/helpers/money-helper.ts +++ b/modules/core/src/common/helpers/money-helper.ts @@ -12,7 +12,7 @@ export const formatCurrency = ( style: "currency", currency, maximumFractionDigits: scale, - minimumFractionDigits: Number.isInteger(amount) ? 0 : 0, + minimumFractionDigits: Number.isInteger(amount) ? 0 : scale, useGrouping: true, }).format(amount); }; diff --git a/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/cancel-form-button.tsx b/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/cancel-form-button.tsx index e9363e2e..71c9bdd1 100644 --- a/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/cancel-form-button.tsx +++ b/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/cancel-form-button.tsx @@ -1,4 +1,5 @@ import { Button } from "@repo/shadcn-ui/components"; +import { cn } from '@repo/shadcn-ui/lib/utils'; import { XIcon } from "lucide-react"; import * as React from "react"; import { useCallback } from "react"; @@ -53,7 +54,7 @@ export const CancelFormButton = ({ type='button' variant={variant} size={size} - className={className} + className={cn("cursor-pointer", className)} onClick={handleClick} disabled={disabled} aria-disabled={disabled} diff --git a/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/form-commit-button-group.tsx b/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/form-commit-button-group.tsx index 00f1fc5c..a4d823f5 100644 --- a/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/form-commit-button-group.tsx +++ b/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/form-commit-button-group.tsx @@ -50,7 +50,7 @@ const alignToJustify: Record = { between: "justify-between", }; -export const FormCommitButtonGroup = ({ +export const UpdateCommitButtonGroup = ({ className, align = "end", gap = "gap-2", diff --git a/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/submit-form-button.tsx b/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/submit-form-button.tsx index 5241c1a7..3a98a79c 100644 --- a/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/submit-form-button.tsx +++ b/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/submit-form-button.tsx @@ -78,7 +78,7 @@ export const SubmitFormButton = ({ data-state={dataState} onClick={handleClick} data-testid={dataTestId} - className={cn("min-w-[100px] font-medium", hasChanges && "ring-2 ring-primary/20", className)} + className={cn("min-w-[100px] cursor-pointer font-medium", hasChanges && "ring-2 ring-primary/20", className)} > {children ? ( children diff --git a/modules/customer-invoices/src/web/components/editor/invoice-edit-form.tsx b/modules/customer-invoices/src/web/components/editor/invoice-edit-form.tsx index 8b3098a1..c9e32c39 100644 --- a/modules/customer-invoices/src/web/components/editor/invoice-edit-form.tsx +++ b/modules/customer-invoices/src/web/components/editor/invoice-edit-form.tsx @@ -25,19 +25,19 @@ export const InvoiceEditForm = ({ return (
-
-
- - +
+
+ +
-
+
-
- +
+
-
+
diff --git a/modules/customer-invoices/src/web/components/editor/invoice-totals.tsx b/modules/customer-invoices/src/web/components/editor/invoice-totals.tsx index 5c653993..1cc51340 100644 --- a/modules/customer-invoices/src/web/components/editor/invoice-totals.tsx +++ b/modules/customer-invoices/src/web/components/editor/invoice-totals.tsx @@ -1,5 +1,6 @@ import { formatCurrency } from "@erp/core"; import { FieldDescription, FieldGroup, FieldLegend, FieldSet, Separator } from '@repo/shadcn-ui/components'; +import { cn } from '@repo/shadcn-ui/lib/utils'; import { ReceiptIcon } from "lucide-react"; import { ComponentProps } from "react"; import { useFormContext, useWatch } from "react-hook-form"; @@ -22,51 +23,42 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => { return (
- + {t("form_groups.totals.title")} - {t("form_groups.totals.description")} - - {/* Sección: Subtotal y Descuentos */} -
-
- Subtotal - - {formatCurrency(getValues('subtotal_amount'), 2, currency_code, language_code)} + {t("form_groups.totals.description")} + + +
+ {/* Sección: Subtotal y Descuentos */} +
+ Subtotal sin descuento + + {formatCurrency(getValues('subtotal_amount'), 2, currency_code, language_code)} +
-
- - -
-

Descuento global

- -
-
-
- Descuento global - -
- -{formatCurrency(getValues("discount_amount"), 2, currency_code, language_code)} +
+
+ Descuento global +
+ -{formatCurrency(getValues("discount_amount"), 2, currency_code, language_code)}
-
- - {/* Sección: Base Imponible */} -
-
- Base imponible - + {/* Sección: Base Imponible */} +
+ Base imponible + {formatCurrency(getValues('taxable_amount'), 2, currency_code, language_code)}
@@ -77,7 +69,7 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => { {/* Sección: Impuestos */}

Impuestos y retenciones

@@ -98,7 +90,7 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => { return ( -
+
{taxesInGroup?.map((item) => { const tax = taxCatalog.findByCode(item.tax_code).match( (t) => t, @@ -107,10 +99,10 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => { return (
- {tax?.name} - + {tax?.name} + {formatCurrency( item.taxes_amount, 2, @@ -126,23 +118,23 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => { ); })} - -
- Total de impuestos - {formatCurrency(getValues('taxes_amount'), 2, currency_code, language_code)} -
- -
- - -
-
-
- Total de la factura - {formatCurrency(getValues('total_amount'), 2, currency_code, language_code)} -
+
+ Total de impuestos + + {formatCurrency(getValues('taxes_amount'), 2, currency_code, language_code)} +
+ + + +
+ Total de la factura + + {formatCurrency(getValues('total_amount'), 2, currency_code, language_code)} + +
+
); diff --git a/modules/customer-invoices/src/web/components/editor/items/items-editor.tsx b/modules/customer-invoices/src/web/components/editor/items/items-editor.tsx index 59f084f2..303feb9a 100644 --- a/modules/customer-invoices/src/web/components/editor/items/items-editor.tsx +++ b/modules/customer-invoices/src/web/components/editor/items/items-editor.tsx @@ -1,5 +1,4 @@ import { DataTable, useWithRowSelection } from '@repo/rdx-ui/components'; -import { Table } from '@tanstack/react-table'; import { useMemo } from 'react'; import { useFieldArray, useFormContext } from "react-hook-form"; import { useInvoiceContext } from '../../../context'; @@ -35,17 +34,16 @@ export const ItemsEditor = () => { return (
String(row?.index)} meta={{ tableOps: { onAdd: () => append({ ...createEmptyItem() }), - appendItem: (item: any) => append(item), + //appendItem: (item: any) => append(item), }, rowOps: { remove: (i: number) => remove(i), move: (from: number, to: number) => move(from, to), - insertItem: (index: number, item: any) => insert(index, item), - duplicateItems: (indexes: number[], table: Table) => { + //insertItem: (index: number, item: any) => insert(index, item), + /*duplicateItems: (indexes: number[], table: Table) => { const items = getValues("items") || []; // duplicate in descending order to keep indexes stable [...indexes].sort((a, b) => b - a).forEach(i => { @@ -55,12 +53,12 @@ export const ItemsEditor = () => { append({ ...rest }); } }); - }, - deleteItems: (indexes: number[]) => { + },*/ + /*deleteItems: (indexes: number[]) => { // remove in descending order to avoid shifting issues [...indexes].sort((a, b) => b - a).forEach(i => remove(i)); - }, - updateItem: (index: number, item: any) => update(index, item), + },*/ + //updateItem: (index: number, item: any) => update(index, item), }, bulkOps: { duplicateSelected: (indexes, table) => { diff --git a/modules/customer-invoices/src/web/components/editor/recipient/invoice-recipient.tsx b/modules/customer-invoices/src/web/components/editor/recipient/invoice-recipient.tsx index c7fe7a5c..26cb91e3 100644 --- a/modules/customer-invoices/src/web/components/editor/recipient/invoice-recipient.tsx +++ b/modules/customer-invoices/src/web/components/editor/recipient/invoice-recipient.tsx @@ -18,10 +18,12 @@ export const InvoiceRecipient = (props: ComponentProps<"fieldset">) => { {t('form_groups.recipient.title')} {t("form_groups.recipient.description")} - + + diff --git a/modules/customer-invoices/src/web/components/editor/recipient/recipient-modal-selector-field.tsx b/modules/customer-invoices/src/web/components/editor/recipient/recipient-modal-selector-field.tsx index 46ae3b44..abbca324 100644 --- a/modules/customer-invoices/src/web/components/editor/recipient/recipient-modal-selector-field.tsx +++ b/modules/customer-invoices/src/web/components/editor/recipient/recipient-modal-selector-field.tsx @@ -1,12 +1,19 @@ import { CustomerModalSelector } from "@erp/customers/components"; -import { FormField, FormItem } from "@repo/shadcn-ui/components"; +import { Field, FieldLabel } from "@repo/shadcn-ui/components"; +import { cn } from '@repo/shadcn-ui/lib/utils'; import { CustomerSummary } from 'node_modules/@erp/customers/src/web/schemas'; -import { Control, FieldPath, FieldValues } from "react-hook-form"; +import { Control, Controller, FieldPath, FieldValues } from "react-hook-form"; type CustomerModalSelectorFieldProps = { control: Control; name: FieldPath; + + label?: string; + description?: string; + + orientation?: "vertical" | "horizontal" | "responsive", + disabled?: boolean; required?: boolean; readOnly?: boolean; @@ -17,6 +24,13 @@ type CustomerModalSelectorFieldProps = { export function RecipientModalSelectorField({ control, name, + + label, + description, + + orientation = 'vertical', + + disabled = false, required = false, readOnly = false, @@ -28,14 +42,15 @@ export function RecipientModalSelectorField({ return ( - { + render={({ field, fieldState }) => { const { name, value, onChange, onBlur, ref } = field; - //console.log({ name, value, onChange, onBlur, ref }); + return ( - + + {label && {label}} ({ ...initialRecipient as CustomerSummary }} /> - + ); }} /> diff --git a/modules/customer-invoices/src/web/components/page-header.tsx b/modules/customer-invoices/src/web/components/page-header.tsx index 82b9c21b..c71011e0 100644 --- a/modules/customer-invoices/src/web/components/page-header.tsx +++ b/modules/customer-invoices/src/web/components/page-header.tsx @@ -1,4 +1,6 @@ +import { Button } from '@repo/shadcn-ui/components'; import { cn } from '@repo/shadcn-ui/lib/utils'; +import { ChevronLeftIcon } from 'lucide-react'; // features/common/components/page-header.tsx import type { ReactNode } from "react"; import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge"; @@ -20,15 +22,19 @@ interface PageHeaderProps { export function PageHeader({ icon, title, description, status, rightSlot, className }: PageHeaderProps) { return ( -
-
+
+
{/* Lado izquierdo */} -
+
+ {icon &&
{icon}
} +
-

{title}

+

{title}

{status && }
{description &&

{description}

} diff --git a/modules/customer-invoices/src/web/hooks/use-invoice-preview.tsx b/modules/customer-invoices/src/web/hooks/use-invoice-preview.tsx new file mode 100644 index 00000000..a9a1b1ef --- /dev/null +++ b/modules/customer-invoices/src/web/hooks/use-invoice-preview.tsx @@ -0,0 +1,103 @@ +import { FC, ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from "react-dom"; + +export type UseInvoicePreviewOptions = { + persistKey?: string; // clave para guardar el pin en localStorage + pinnedWidthClass?: string; // ancho al anclar (Tailwind), p.ej. "w-[500px]" + onOpenChange?: (open: boolean) => void; // callback opcional +}; + +export type InvoicePreviewHook = { + isOpen: boolean; + isPinned: boolean; + item: T | null; + open: (item: T) => void; + close: () => void; + togglePin: () => void; + + /** Añade margen derecho al listado si está anclado */ + containerClassName: string; + + /** Renderiza el preview en un portal (body). Debes pasar tu Card como children render-prop */ + PreviewPortal: FC<{ + children: (p: { + item: T; + isOpen: boolean; + isPinned: boolean; + onClose: () => void; + onTogglePin: () => void; + }) => ReactNode + }>; +}; + +export function useInvoicePreview( + opts?: UseInvoicePreviewOptions +): InvoicePreviewHook { + const { persistKey = "invoice-preview-pin", pinnedWidthClass = "w-[500px]", onOpenChange } = opts ?? {}; + const [isOpen, setOpen] = useState(false); + const [item, setItem] = useState(null); + const [isPinned, setPinned] = useState(() => { + try { return localStorage.getItem(persistKey) === "1"; } catch { return false; } + }); + + // Guardar y restaurar foco al cerrar (mejor accesibilidad) + const lastFocusedRef = useRef(null); + const rememberFocus = () => { lastFocusedRef.current = (document.activeElement as HTMLElement) ?? null; }; + const restoreFocus = () => { lastFocusedRef.current?.focus?.(); }; + + const open = useCallback((next: T) => { + rememberFocus(); + setItem(next); + setOpen(true); + onOpenChange?.(true); + }, [onOpenChange]); + + const close = useCallback(() => { + if (isPinned) return; // anclado no se cierra + setOpen(false); + onOpenChange?.(false); + setTimeout(restoreFocus, 0); + }, [isPinned, onOpenChange]); + + const togglePin = useCallback(() => { + setPinned((prev) => { + const next = !prev; + try { localStorage.setItem(persistKey, next ? "1" : "0"); } catch { } + return next; + }); + }, [persistKey]); + + // Bloqueo de scroll cuando está abierto y NO anclado + useEffect(() => { + if (isOpen && !isPinned) { + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { document.body.style.overflow = prev; }; + } + }, [isOpen, isPinned]); + + // Cerrar con ESC (solo si no está anclado) + useEffect(() => { + if (!isOpen || isPinned) return; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isOpen, isPinned, close]); + + const containerClassName = isPinned ? `mr-[--preview-width] [--preview-width:theme(spacing.0)] ${pinnedWidthClass ? "" : ""}` : ""; + // Nota: preferimos aplicar el margen directamente en el layout (ver uso abajo) + + const PreviewPortal: InvoicePreviewHook["PreviewPortal"] = useCallback(({ children }) => { + if (!item) return null; + const node = children({ + item, + isOpen, + isPinned, + onClose: close, + onTogglePin: togglePin, + }); + return createPortal(node as ReactNode, document.body); + }, [item, isOpen, isPinned, close, togglePin]); + + return { isOpen, isPinned, item, open, close, togglePin, containerClassName, PreviewPortal }; +} 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 index 2b740e8c..afec517b 100644 --- a/modules/customer-invoices/src/web/hooks/use-pinned-preview-sheet.tsx +++ b/modules/customer-invoices/src/web/hooks/use-pinned-preview-sheet.tsx @@ -1,75 +1,95 @@ -import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@repo/shadcn-ui/components"; +import { Sheet, SheetContent } from "@repo/shadcn-ui/components"; // hooks/use-pinned-preview-sheet.ts import * as React from "react"; +import { createPortal } from 'react-dom'; 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); + persistKey?: string; // clave localStorage para “pin” + widthClass?: string; // ancho del panel: p. ej. "w-[500px]" + onOpenChange?: (open: boolean) => void; + title?: string | ((item: T | null) => string); // Título del Sheet (no anclado) }; -export type PinnedPreviewSheetAPI = { +export type PinnedPreviewSheet = { /** 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; + /** Añade margen al contenedor de la lista cuando está anclado */ + listRightMarginClass: string; + + /** Renderiza el panel (Sheet o aside) */ + Preview: React.FC<{ + children: (ctx: { + item: T; + isPinned: boolean; + close: () => void; + togglePin: () => void; + }) => React.ReactNode }>; }; -export function usePinnedPreviewSheet( - opts?: UsePinnedPreviewSheetOptions -): PinnedPreviewSheetAPI { - const { persistKey, pinnedWidthClass = "w-[420px]", title } = opts ?? {}; +export function usePinnedPreviewSheet({ + persistKey = "preview-pin", + widthClass = "w-[500px]", + onOpenChange, + title, +}: UsePinnedPreviewSheetOptions = {}): PinnedPreviewSheet { 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; - } + try { return localStorage.getItem(persistKey) === "1"; } catch { return false; } }); + // recordar/restaurar foco para accesibilidad + const lastFocused = React.useRef(null); + const rememberFocus = () => { lastFocused.current = document.activeElement as HTMLElement | null; }; + const restoreFocus = () => { lastFocused.current?.focus?.(); }; + const open = React.useCallback((next: T) => { + rememberFocus(); setItem(next); setOpen(true); - }, []); + onOpenChange?.(true); + }, [onOpenChange]); const close = React.useCallback(() => { - if (isPinned) return; // Anclado: no cerrar + if (isPinned) return; setOpen(false); - }, [isPinned]); + onOpenChange?.(false); + setTimeout(restoreFocus, 0); + }, [isPinned, onOpenChange]); 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; + const n = !p; + try { localStorage.setItem(persistKey, n ? "1" : "0"); } catch { } + return n; }); - }, [persistKey, item]); + }, [persistKey]); + + // Bloquea scroll solo en modo Sheet + React.useEffect(() => { + if (isOpen && !isPinned) { + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { document.body.style.overflow = prev; }; + } + }, [isOpen, isPinned]); + + // Cerrar con ESC solo en modo Sheet + React.useEffect(() => { + if (!isOpen || isPinned) return; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isOpen, isPinned, close]); + + const listRightMarginClass = isPinned ? "mr-[500px]" : ""; // ajusta si cambias widthClass const HeaderTitle = React.useMemo(() => { if (!item) return ""; @@ -77,64 +97,32 @@ export function usePinnedPreviewSheet( return title ?? ""; }, [item, title]); - const PreviewContainer: PinnedPreviewSheetAPI["PreviewContainer"] = React.useCallback( - ({ children, header }) => { - if (!item) return null; + const Preview: PinnedPreviewSheet["Preview"] = React.useCallback(({ children }) => { + 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)}
-
- -
-
-
+ // Panel anclado: aside estático sin overlay + if (isPinned) { + return createPortal( + , + document.body ); - }, - [HeaderTitle, close, isOpen, isPinned, item, pinnedWidthClass, togglePin] - ); + } - return { isOpen, isPinned, item, open, close, togglePin, setItem, PreviewContainer }; -} + // Panel no anclado: Sheet de shadcn/ui (con overlay y accesibilidad) + return createPortal( + (o ? setOpen(true) : close())}> + + {children({ item, isPinned: false, close, togglePin })} + + , + document.body + ); + }, [item, isPinned, isOpen, widthClass, close, togglePin]); + + return { isOpen, isPinned, item, open, close, togglePin, listRightMarginClass, Preview }; +} \ No newline at end of file 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 deleted file mode 100644 index c31df059..00000000 --- a/modules/customer-invoices/src/web/pages/list/invoice-preview-card.tsx +++ /dev/null @@ -1,349 +0,0 @@ -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/invoice-preview-panel.tsx b/modules/customer-invoices/src/web/pages/list/invoice-preview-panel.tsx new file mode 100644 index 00000000..6c407b7f --- /dev/null +++ b/modules/customer-invoices/src/web/pages/list/invoice-preview-panel.tsx @@ -0,0 +1,204 @@ +import { Badge, Button, Separator } from "@repo/shadcn-ui/components"; +import { + Calendar, + Copy, + CreditCard, + Download, + Edit, + FileText, + Hash, + Mail, + MapPin, + Pin, + Trash2, + User, + X +} from "lucide-react"; +import { InvoiceSummaryFormData } from '../../schemas'; + + +export type InvoicePreviewPanelProps = { + invoice: InvoiceSummaryFormData; + isPinned: boolean; + onClose: () => void; + onTogglePin: () => void; +}; + +export function InvoicePreviewPanel({ + invoice, + isPinned, + onClose, + onTogglePin, +}: InvoicePreviewPanelProps) { + return ( +
+ {/* Header */} +
+
+
+

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

+

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

+
+
+ + {!isPinned && ( + + )} +
+
+ + {invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)} + +
+ + {/* Body */} +
+
+
+ +

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}

+
+ + + )} + +
+
+ +

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} +
+ + )} + + + +
+ Total impuestos + {invoice.taxes_amoun_fmt} +
+ + + +
+ Total + + {invoice.total_amount_fmt} + +
+
+
+
+ + {/* Footer acciones */} +
+
+ + + + +
+ +
+
+ ); +} \ No newline at end of file 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 808eb55a..a8a94ae6 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 @@ -6,8 +6,10 @@ import { DataTable, SkeletonDataTable } from '@repo/rdx-ui/components'; import { Button, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Spinner } from '@repo/shadcn-ui/components'; import { FileDownIcon, FilterIcon, SearchIcon, XIcon } from 'lucide-react'; import { useNavigate } from "react-router-dom"; +import { usePinnedPreviewSheet } from '../../hooks'; import { useTranslation } from "../../i18n"; import { InvoiceSummaryFormData, InvoicesPageFormData } from '../../schemas'; +import { InvoicePreviewPanel } from './invoice-preview-panel'; import { useInvoicesListColumns } from './use-invoices-list-columns'; export type InvoiceUpdateCompProps = { @@ -40,9 +42,21 @@ export const InvoicesListGrid = ({ const { t } = useTranslation(); const navigate = useNavigate(); + // Hook con Sheet de shadcn + const preview = usePinnedPreviewSheet({ + persistKey: "invoice-preview-pin", + widthClass: "w-[500px]", + }); + const [statusFilter, setStatusFilter] = useState("todas"); - const columns = useInvoicesListColumns(); + const columns = useInvoicesListColumns({ + onEdit: (invoice) => navigate(`/customer-invoices/${invoice.id}/edit`), + onDuplicate: (invoice) => null, //duplicateInvoice(inv.id), + onDownloadPdf: (invoice) => null, //downloadInvoicePdf(inv.id), + onSendEmail: (invoice) => null, //sendInvoiceEmail(inv.id), + onDelete: (invoice) => null, //confirmDelete(inv.id), + }); const { items, total_items } = invoicesPage; // Navegación accesible (click o teclado) @@ -97,12 +111,12 @@ export const InvoicesListGrid = ({ }; const handleRowClick = useCallback( - (invoice: InvoiceSummaryFormData, _index: number, e: React.MouseEvent) => { + (invoice: InvoiceSummaryFormData, _i: number, e: React.MouseEvent) => { const url = `/customer-invoices/${invoice.id}/edit`; - if (e.metaKey || e.ctrlKey) window.open(url, "_blank", "noopener,noreferrer"); - else navigate(url); + if (e.metaKey || e.ctrlKey) { window.open(url, "_blank", "noopener,noreferrer"); return; } + preview.open(invoice); }, - [navigate] + [preview] ); if (loading) { @@ -123,9 +137,7 @@ export const InvoicesListGrid = ({
{/* Barra de filtros */}
-
- - +
-
+
+
+ +
- + + {({ item, isPinned, close, togglePin }) => ( + + )} +
); 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 bd6f5321..f50dc16d 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 @@ -1,15 +1,13 @@ 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 { useCallback, useMemo, useState } from 'react'; +import { PlusIcon } from "lucide-react"; +import { useMemo, useState } from 'react'; import { useNavigate } from "react-router-dom"; import { InvoicesListGrid, PageHeader } from '../../components'; -import { useInvoicesQuery, usePinnedPreviewSheet } from '../../hooks'; +import { useInvoicesQuery } 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(); @@ -48,12 +46,6 @@ 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); @@ -71,18 +63,6 @@ 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 ( @@ -101,7 +81,6 @@ export const InvoiceListPage = () => { } rightSlot={ <>} @@ -117,8 +96,9 @@ export const InvoiceListPage = () => {
-
+
{ onPageSizeChange={handlePageSizeChange} searchValue={search} onSearchChange={handleSearchChange} - onRowClick={handleRowClick} />
- - - {/* Contenedor del preview (Sheet o aside anclado) */} - - {(invoice) => } -
diff --git a/modules/customer-invoices/src/web/pages/list/use-invoices-list-columns.tsx b/modules/customer-invoices/src/web/pages/list/use-invoices-list-columns.tsx index 59575ee1..abab0489 100644 --- a/modules/customer-invoices/src/web/pages/list/use-invoices-list-columns.tsx +++ b/modules/customer-invoices/src/web/pages/list/use-invoices-list-columns.tsx @@ -1,120 +1,233 @@ import { formatDate } from '@erp/core/client'; import { DataTableColumnHeader } from '@repo/rdx-ui/components'; +import { + Button, DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, + Tooltip, TooltipContent, TooltipTrigger +} from '@repo/shadcn-ui/components'; import type { ColumnDef } from "@tanstack/react-table"; +import { CopyIcon, DownloadIcon, EditIcon, MailIcon, MoreVerticalIcon, Trash2Icon } from 'lucide-react'; import * as React from "react"; import { CustomerInvoiceStatusBadge } from '../../components'; import { useTranslation } from '../../i18n'; -import { InvoiceSummaryFormData } from '../../schemas/invoice-resume.form.schema'; +import { InvoiceSummaryFormData } from '../../schemas'; +type InvoiceActionHandlers = { + onEdit?: (invoice: InvoiceSummaryFormData) => void; + onDuplicate?: (invoice: InvoiceSummaryFormData) => void; + onDownloadPdf?: (invoice: InvoiceSummaryFormData) => void; + onSendEmail?: (invoice: InvoiceSummaryFormData) => void; + onDelete?: (invoice: InvoiceSummaryFormData) => void; +}; - -export function useInvoicesListColumns(): ColumnDef[] { - //const { t, readOnly, currency_code, language_code } = useInvoiceContext(); +export function useInvoicesListColumns( + handlers: InvoiceActionHandlers = {} +): ColumnDef[] { const { t } = useTranslation(); + const { + onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete, + } = handlers; - // Atención: Memoizar siempre para evitar reconstrucciones y resets de estado de tabla return React.useMemo[]>(() => [ + // Nº { accessorKey: "invoice_number", header: ({ column }) => ( - + ), cell: ({ row }) => ( -
- {row.getValue('invoice_number')} +
+ {row.getValue("invoice_number")}
), enableHiding: false, enableSorting: false, - size: 32, - }, { + size: 160, + minSize: 120, + }, + // Estado + { accessorKey: "status", header: ({ column }) => ( - - ), - cell: ({ row }) => ( - + ), + cell: ({ row }) => , enableSorting: false, - size: 32, - }, { + size: 140, + minSize: 120, + }, + // Serie + { accessorKey: "series", header: ({ column }) => ( - - ), - cell: ({ row }) => ( -
- {row.getValue('series')} -
- + ), + cell: ({ row }) =>
{row.getValue("series")}
, enableSorting: false, - size: 32, - }, { + size: 120, + minSize: 100, + }, + // Fecha factura + { accessorKey: "invoice_date", header: ({ column }) => ( - + ), cell: ({ row }) => ( -
- {formatDate(row.getValue('invoice_date'))} +
+ {formatDate(row.getValue("invoice_date"))}
- ), enableSorting: false, - size: 32, - }, { + size: 140, + minSize: 120, + }, + // Fecha operación + { accessorKey: "operation_date", header: ({ column }) => ( - + ), cell: ({ row }) => ( -
- {formatDate(row.getValue('operation_date'))} +
+ {formatDate(row.getValue("operation_date"))}
- ), enableSorting: false, - size: 32, - }, { + size: 140, + minSize: 120, + }, + // TIN + { id: "recipient_tin", accessorKey: "recipient.tin", header: ({ column }) => ( - + ), cell: ({ row }) => ( -
- {row.getValue('recipient_tin')} -
+
{row.getValue("recipient_tin")}
), enableSorting: false, - size: 32, - }, { + size: 160, + minSize: 140, + }, + // Cliente + { accessorKey: "recipient.name", id: "recipient_name", header: ({ column }) => ( - + ), cell: ({ row }) => ( -
- {row.getValue('recipient_name')} +
+ {row.getValue("recipient_name")}
), enableSorting: false, - size: 32, - }, { + size: 260, + minSize: 200, + }, + // Total + { accessorKey: "total_amount_fmt", header: ({ column }) => ( - + ), cell: ({ row }) => ( -
- {row.getValue('total_amount_fmt')} -
+
{row.getValue("total_amount_fmt")}
), enableSorting: false, - size: 32, - } + size: 140, + minSize: 120, + }, - ], [t]); + // ───────────────────────────── + // Acciones + // ───────────────────────────── + { + id: "actions", + header: () => {t("common.actions")}, + enableSorting: false, + enableHiding: false, + size: 110, + minSize: 96, + cell: ({ row }) => { + const invoice = row.original; + const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation(); + + return ( +
+ {/* Editar (acción primaria) */} + + + + + {t("common.edit")} + + + {/* Menú demás acciones */} + + + + + + onDuplicate?.(invoice)} + className="cursor-pointer" + > + + {t("common.duplicate")} + + + onDownloadPdf?.(invoice)} + className="cursor-pointer" + > + + {t("common.download_pdf")} + + + onSendEmail?.(invoice)} + className="cursor-pointer" + > + + {t("common.send_email")} + + onDelete?.(invoice)} + className="text-destructive focus:text-destructive-foreground focus:bg-destructive cursor-pointer" + > + + {t("common.delete")} + + + + +
+ ); + }, + }, + ], [t, onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete]); } diff --git a/modules/customer-invoices/src/web/pages/update/invoice-update-comp.tsx b/modules/customer-invoices/src/web/pages/update/invoice-update-comp.tsx index 6fd7324f..6e87c091 100644 --- a/modules/customer-invoices/src/web/pages/update/invoice-update-comp.tsx +++ b/modules/customer-invoices/src/web/pages/update/invoice-update-comp.tsx @@ -1,11 +1,10 @@ import { - FormCommitButtonGroup, UnsavedChangesProvider, + UpdateCommitButtonGroup, useHookForm } from "@erp/core/hooks"; -import { AppBreadcrumb, AppContent, AppHeader } from "@repo/rdx-ui/components"; +import { AppContent, AppHeader } from "@repo/rdx-ui/components"; import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers"; -import { FilePenIcon } from "lucide-react"; import { useMemo } from 'react'; import { FieldErrors, FormProvider } from "react-hook-form"; import { useNavigate } from "react-router-dom"; @@ -86,24 +85,25 @@ export const InvoiceUpdateComp = ({ return ( - } rightSlot={ - navigate(-1)} /> + + + } /> - ; + +export type FormCommitButtonGroupProps = { + className?: string; + align?: Align; // default "end" + gap?: string; // default "gap-2" + reverseOrderOnMobile?: boolean; // default true (Cancel debajo en móvil) + + isLoading?: boolean; + disabled?: boolean; + preventDoubleSubmit?: boolean; // Evita múltiples submits mientras loading + + cancel?: CancelFormButtonProps & { show?: boolean }; + submit?: GroupSubmitButtonProps; // props directas a SubmitButton + + onReset?: () => void; + onDelete?: () => void; + onPreview?: () => void; + onDuplicate?: () => void; + onBack?: () => void; +}; + +const alignToJustify: Record = { + start: "justify-start", + center: "justify-center", + end: "justify-end", + between: "justify-between", +}; + +export const FormCommitButtonGroup = ({ + className, + align = "end", + gap = "gap-2", + reverseOrderOnMobile = true, + + isLoading, + disabled = false, + preventDoubleSubmit = true, + + cancel, + submit, + + onReset, + onDelete, + onPreview, + onDuplicate, + onBack, +}: FormCommitButtonGroupProps) => { + const showCancel = cancel?.show ?? true; + const hasSecondaryActions = onReset || onPreview || onDuplicate || onBack || onDelete; + + // ⛳️ RHF opcional: auto-detectar isSubmitting si no se pasó isLoading + let rhfIsSubmitting = false; + try { + const ctx = useFormContext(); + rhfIsSubmitting = !!ctx?.formState?.isSubmitting; + } catch { + // No hay provider de RHF; ignorar + } + const busy = isLoading ?? rhfIsSubmitting; + const computedDisabled = !!(disabled || (preventDoubleSubmit && busy)); + + return ( +
+ {submit && } + {showCancel && } + + {/* Menú de acciones adicionales */} + {hasSecondaryActions && ( + + + + + + {onReset && ( + + + Deshacer cambios + + )} + {onPreview && ( + + + Vista previa + + )} + {onDuplicate && ( + + + Duplicar + + )} + {onBack && ( + + + Volver + + )} + {onDelete && ( + <> + + + + Eliminar + + + )} + + + )} +
+ ); +}; diff --git a/modules/customers/src/web/components/customer-modal-selector/customer-card.tsx b/modules/customers/src/web/components/customer-modal-selector/customer-card.tsx index 07045c4d..39e6aa91 100644 --- a/modules/customers/src/web/components/customer-modal-selector/customer-card.tsx +++ b/modules/customers/src/web/components/customer-modal-selector/customer-card.tsx @@ -1,11 +1,14 @@ -import { Button, Separator } from "@repo/shadcn-ui/components"; import { - CreditCard, + Button, Item, + ItemContent, + ItemDescription, + ItemFooter, + ItemTitle +} from "@repo/shadcn-ui/components"; +import { EyeIcon, - MapPinHouseIcon, RefreshCwIcon, - UserIcon, - UserPlusIcon, + UserPlusIcon } from "lucide-react"; import { CustomerSummary } from "../../schemas"; @@ -16,7 +19,7 @@ interface CustomerCardProps { onChangeCustomer?: () => void; onAddNewCustomer?: () => void; - className: string; + className?: string; } export const CustomerCard = ({ @@ -35,83 +38,70 @@ export const CustomerCard = ({ customer.country; return ( -
-
- {/* Avatar mejorado con gradiente sutil */} -
- -
- -
- {/* Nombre del cliente */} -

- {customer.name} -

- - {/* NIF/CIF con icono */} - {customer.tin && ( -
- - {customer.tin} -
- )} - - {/* Separador si hay dirección */} - {hasAddress && } - + + + + {customer.name} + + + + {customer.tin && ({customer.tin})} {/* Dirección con mejor estructura */} {hasAddress && ( -
-
- -
- {customer.street &&
{customer.street}
} - {customer.street2 &&
{customer.street2}
} -
- {customer.postal_code && {customer.postal_code}} - {customer.city && {customer.city}} -
-
- {customer.province && {customer.province}} - {customer.country && {customer.country}} -
-
+
+ + {customer.street &&
{customer.street}
} + {customer.street2 &&
{customer.street2}
} +
+ {customer.postal_code && {customer.postal_code}} + {customer.city && {customer.city}} +
+
+ {customer.province && {customer.province}} + {customer.country && {customer.country}}
- )} -
-
- -
- - - -
-
+ )} + + + +
+ + +
+
+ ); }; diff --git a/modules/customers/src/web/pages/create/customer-create-page.tsx b/modules/customers/src/web/pages/create/customer-create-page.tsx index 524cb291..6ca11588 100644 --- a/modules/customers/src/web/pages/create/customer-create-page.tsx +++ b/modules/customers/src/web/pages/create/customer-create-page.tsx @@ -1,7 +1,7 @@ import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components"; import { useNavigate } from "react-router-dom"; -import { FormCommitButtonGroup, UnsavedChangesProvider, useHookForm } from "@erp/core/hooks"; +import { UnsavedChangesProvider, UpdateCommitButtonGroup, useHookForm } from "@erp/core/hooks"; import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers"; import { FieldErrors, FormProvider } from "react-hook-form"; import { CustomerEditForm, ErrorAlert } from "../../components"; @@ -74,7 +74,7 @@ export const CustomerCreatePage = () => { {t("pages.create.description")}

- ({ {/* Botón añadir */} {!readOnly && meta?.tableOps?.onAdd && (