Facturas de cliente

This commit is contained in:
David Arranz 2025-10-19 21:04:16 +02:00
parent c9d375f40c
commit 2fc9deea85
23 changed files with 955 additions and 745 deletions

View File

@ -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);
};

View File

@ -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}

View File

@ -50,7 +50,7 @@ const alignToJustify: Record<Align, string> = {
between: "justify-between",
};
export const FormCommitButtonGroup = ({
export const UpdateCommitButtonGroup = ({
className,
align = "end",
gap = "gap-2",

View File

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

View File

@ -25,19 +25,19 @@ export const InvoiceEditForm = ({
return (
<form noValidate id={formId} onSubmit={form.handleSubmit(onSubmit, onError)} >
<section className={cn("space-y-6", className)}>
<div className="w-full border p-6 bg-background">
<InvoiceBasicInfoFields className="flex flex-col" />
<InvoiceRecipient className='lg:col-span-1 border p-6 bg-background' />
<section className={cn("bg-white rounded-xl border shadow-xl space-y-6", className)}>
<div className="w-full p-6 bg-transparent grid grid-cols-1 lg:grid-cols-3 gap-6">
<InvoiceRecipient className="flex flex-col" />
<InvoiceBasicInfoFields className="flex flex-col lg:col-span-2" />
</div>
<div className='w-full gap-6'>
<div className='w-full gap-6 px-6'>
<InvoiceItems />
</div>
<div className="w-full border p-6 bg-background">
<InvoiceTotals />
<div className="w-full p-6 grid grid-cols-1 lg:grid-cols-2">
<InvoiceTotals className='lg:col-start-2' />
</div>
<div className="w-full border p-6 bg-background">
<div className="w-full p-6">
<FormDebug />
</div>

View File

@ -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 (
<FieldSet {...props}>
<FieldLegend>
<FieldLegend className='hidden'>
<ReceiptIcon className='size-6 text-muted-foreground' />{t("form_groups.totals.title")}
</FieldLegend>
<FieldDescription>{t("form_groups.totals.description")}</FieldDescription>
<FieldGroup className='grid grid-cols-1'>
{/* Sección: Subtotal y Descuentos */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-semibold text-base">Subtotal</span>
<span className="font-bold text-lg tabular-nums pr-4">
{formatCurrency(getValues('subtotal_amount'), 2, currency_code, language_code)}</span>
<FieldDescription className='hidden'>{t("form_groups.totals.description")}</FieldDescription>
<FieldGroup className='grid grid-cols-1 border rounded-lg bg-muted/10 p-6 gap-4'>
<div className='space-y-1.5'>
{/* Sección: Subtotal y Descuentos */}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Subtotal sin descuento</span>
<span className="font-medium tabular-nums text-muted-foreground">
{formatCurrency(getValues('subtotal_amount'), 2, currency_code, language_code)}
</span>
</div>
</div>
<Separator />
<div className="space-y-1.5">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Descuento global</h3>
<div className="rounded-lg bg-accent/30 p-4 space-y-2.5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className='text-sm font-medium'>Descuento global</span>
<PercentageInputField
control={control}
name={"discount_percentage"}
readOnly={readOnly}
inputId={"header-discount-percentage"}
showSuffix={true}
className='w-20 h-9 text-right tabular-nums'
/>
</div>
<span className="font-medium text-destructive tabular-nums">-{formatCurrency(getValues("discount_amount"), 2, currency_code, language_code)}</span>
<div className="flex justify-between text-sm">
<div className="flex items-center gap-3">
<span className="text-muted-foreground">Descuento global</span>
<PercentageInputField
control={control}
name={"discount_percentage"}
readOnly={readOnly}
inputId={"header-discount-percentage"}
showSuffix={true}
className={cn("w-20 text-right tabular-nums bg-background", "border-input border text-sm shadow-xs")}
/>
</div>
<span className="font-medium text-destructive tabular-nums">-{formatCurrency(getValues("discount_amount"), 2, currency_code, language_code)}</span>
</div>
</div>
<Separator />
{/* Sección: Base Imponible */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-semibold text-base">Base imponible</span>
<span className="font-bold text-lg tabular-nums pr-4">
{/* Sección: Base Imponible */}
<div className="flex justify-between text-sm">
<span className="text-foreground">Base imponible</span>
<span className="font-medium tabular-nums">
{formatCurrency(getValues('taxable_amount'), 2, currency_code, language_code)}
</span>
</div>
@ -77,7 +69,7 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
{/* Sección: Impuestos */}
<div className="space-y-1.5">
<h3
className="text-sm font-semibold text-muted-foreground uppercase tracking-wide"
className="text-xs font-semibold text-muted-foreground uppercase tracking-wide"
>
Impuestos y retenciones
</h3>
@ -98,7 +90,7 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
return (
<div key={`tax-group-${group}`} className="rounded-lg bg-accent/30 p-4 space-y-1.5">
<div key={`tax-group-${group}`} className="space-y-1.5 leading-3">
{taxesInGroup?.map((item) => {
const tax = taxCatalog.findByCode(item.tax_code).match(
(t) => t,
@ -107,10 +99,10 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
return (
<div
key={`${group}:${item.tax_code}`}
className="flex items-center justify-between"
className="flex items-center justify-between text-sm"
>
<span className="text-sm font-medium">{tax?.name}</span>
<span className="font-medium tabular-nums">
<span className="text-muted-foreground text-sm">{tax?.name}</span>
<span className="font-medium tabular-nums text-sm text-muted-foreground">
{formatCurrency(
item.taxes_amount,
2,
@ -126,23 +118,23 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
);
})}
<div className="flex items-center justify-between text-base mt-3">
<span className="font-semibold text-base">Total de impuestos</span>
<span className="font-bold text-lg tabular-nums pr-4">{formatCurrency(getValues('taxes_amount'), 2, currency_code, language_code)}</span>
</div>
</div>
<Separator className='bg-foreground' />
<div className="space-y-1.5">
<div className="rounded-lg bg-primary p-4">
<div className="flex items-center justify-between">
<span className="text-lg font-bold text-background/90">Total de la factura</span>
<span className="text-2xl font-bold text-background">{formatCurrency(getValues('total_amount'), 2, currency_code, language_code)}</span>
</div>
<div className="flex justify-between text-sm mt-3">
<span className="text-foreground">Total de impuestos</span>
<span className="font-medium tabular-nums">
{formatCurrency(getValues('taxes_amount'), 2, currency_code, language_code)}
</span>
</div>
</div>
<Separator />
<div className="flex justify-between text-sm mt-3">
<span className="font-semibold text-foreground">Total de la factura</span>
<span className="font-semibold tabular-nums">
{formatCurrency(getValues('total_amount'), 2, currency_code, language_code)}
</span>
</div>
</FieldGroup>
</FieldSet >
);

View File

@ -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 (
<div className="space-y-0">
<DataTable columns={columns as any} data={fields}
getRowId={row => 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<InvoiceFormData>) => {
//insertItem: (index: number, item: any) => insert(index, item),
/*duplicateItems: (indexes: number[], table: Table<InvoiceFormData>) => {
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) => {

View File

@ -18,10 +18,12 @@ export const InvoiceRecipient = (props: ComponentProps<"fieldset">) => {
{t('form_groups.recipient.title')}
</FieldLegend>
<FieldDescription className='hidden'>{t("form_groups.recipient.description")}</FieldDescription>
<FieldGroup className='grid grid-cols-1'>
<FieldGroup className='flex flex-row flex-wrap gap-6 xl:flex-nowrap'>
<RecipientModalSelectorField
control={control}
name='customer_id'
label={t('form_groups.customer.title')}
initialRecipient={recipient}
/>
</FieldGroup>

View File

@ -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<TFormValues extends FieldValues> = {
control: Control<TFormValues>;
name: FieldPath<TFormValues>;
label?: string;
description?: string;
orientation?: "vertical" | "horizontal" | "responsive",
disabled?: boolean;
required?: boolean;
readOnly?: boolean;
@ -17,6 +24,13 @@ type CustomerModalSelectorFieldProps<TFormValues extends FieldValues> = {
export function RecipientModalSelectorField<TFormValues extends FieldValues>({
control,
name,
label,
description,
orientation = 'vertical',
disabled = false,
required = false,
readOnly = false,
@ -28,14 +42,15 @@ export function RecipientModalSelectorField<TFormValues extends FieldValues>({
return (
<FormField
<Controller
control={control}
name={name}
render={({ field }) => {
render={({ field, fieldState }) => {
const { name, value, onChange, onBlur, ref } = field;
//console.log({ name, value, onChange, onBlur, ref });
return (
<FormItem className={className}>
<Field data-invalid={fieldState.invalid} orientation={orientation} className={cn("gap-1", className)}>
{label && <FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={name}>{label}</FieldLabel>}
<CustomerModalSelector
value={value}
disabled={isDisabled}
@ -45,7 +60,7 @@ export function RecipientModalSelectorField<TFormValues extends FieldValues>({
...initialRecipient as CustomerSummary
}}
/>
</FormItem>
</Field>
);
}}
/>

View File

@ -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 (
<div className={cn("border-b bg-card -px-4", className)}>
<div className='mx-auto w-full px-6 pt-2 pb-8'>
<div className={cn("border-b bg-card -px-4 pt-4", className)}>
<div className="mx-auto px-6 py-4">
<div className='flex items-center justify-between'>
{/* Lado izquierdo */}
<div className='flex items-center gap-3'>
<div className='flex items-center gap-4'>
<Button variant="ghost" size="icon" className="cursor-pointer" onClick={() => window.history.back()}>
<ChevronLeftIcon className="size-5" />
</Button>
{icon && <div className='shrink-0'>{icon}</div>}
<div>
<div className='flex items-center gap-3'>
<h1 className='text-lg font-semibold text-foreground'>{title}</h1>
<h1 className='text-xl font-semibold text-foreground'>{title}</h1>
{status && <CustomerInvoiceStatusBadge status={status} />}
</div>
{description && <p className='text-sm text-muted-foreground'>{description}</p>}

View File

@ -0,0 +1,103 @@
import { FC, ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from "react-dom";
export type UseInvoicePreviewOptions<T> = {
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<T> = {
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<T = unknown>(
opts?: UseInvoicePreviewOptions<T>
): InvoicePreviewHook<T> {
const { persistKey = "invoice-preview-pin", pinnedWidthClass = "w-[500px]", onOpenChange } = opts ?? {};
const [isOpen, setOpen] = useState(false);
const [item, setItem] = useState<T | null>(null);
const [isPinned, setPinned] = useState<boolean>(() => {
try { return localStorage.getItem(persistKey) === "1"; } catch { return false; }
});
// Guardar y restaurar foco al cerrar (mejor accesibilidad)
const lastFocusedRef = useRef<HTMLElement | null>(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<T>["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 };
}

View File

@ -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<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);
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<T> = {
export type PinnedPreviewSheet<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;
/** 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<T = unknown>(
opts?: UsePinnedPreviewSheetOptions<T>
): PinnedPreviewSheetAPI<T> {
const { persistKey, pinnedWidthClass = "w-[420px]", title } = opts ?? {};
export function usePinnedPreviewSheet<T = unknown>({
persistKey = "preview-pin",
widthClass = "w-[500px]",
onOpenChange,
title,
}: UsePinnedPreviewSheetOptions<T> = {}): PinnedPreviewSheet<T> {
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;
}
try { return localStorage.getItem(persistKey) === "1"; } catch { return false; }
});
// recordar/restaurar foco para accesibilidad
const lastFocused = React.useRef<HTMLElement | null>(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<T = unknown>(
return title ?? "";
}, [item, title]);
const PreviewContainer: PinnedPreviewSheetAPI<T>["PreviewContainer"] = React.useCallback(
({ children, header }) => {
if (!item) return null;
const Preview: PinnedPreviewSheet<T>["Preview"] = React.useCallback(({ children }) => {
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>
// Panel anclado: aside estático sin overlay
if (isPinned) {
return createPortal(
<aside
aria-label="Vista previa anclada"
className={`fixed inset-y-0 right-0 ${widthClass} bg-background border-l z-40`}
>
{children({ item, isPinned: true, close, togglePin })}
</aside>,
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(
<Sheet open={isOpen} onOpenChange={(o) => (o ? setOpen(true) : close())}>
<SheetContent side="right" className={`${widthClass} p-0`}>
{children({ item, isPinned: false, close, togglePin })}
</SheetContent>
</Sheet>,
document.body
);
}, [item, isPinned, isOpen, widthClass, close, togglePin]);
return { isOpen, isPinned, item, open, close, togglePin, listRightMarginClass, Preview };
}

View File

@ -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 && (
<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

@ -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 (
<div className="flex h-full flex-col">
{/* Header */}
<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"}
aria-label={isPinned ? "Desanclar panel" : "Anclar panel"}
>
<Pin className={`h-4 w-4 ${isPinned ? "fill-current" : ""}`} />
</Button>
{!isPinned && (
<Button
size="icon"
variant="ghost"
onClick={onClose}
className="text-white hover:bg-white/20"
aria-label="Cerrar vista previa"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
<Badge variant="outline" className="bg-white/20 text-white border-white/30">
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
</Badge>
</div>
{/* Body */}
<div className="p-6 overflow-y-auto flex-1">
<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" />
</>
)}
<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 />
<div className="flex justify-between text-sm">
<span className="text-gray-600">Total impuestos</span>
<span className="text-gray-900 font-medium">{invoice.taxes_amoun_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>
{/* Footer acciones */}
<div className="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>
);
}

View File

@ -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<InvoiceSummaryFormData>({
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 = ({
<div className="flex flex-col gap-4">
{/* Barra de filtros */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1" role="search" aria-label={t("pages.list.searchPlaceholder")}>
<div className="relative flex-1" aria-label={t("pages.list.searchPlaceholder")}>
<InputGroup className='bg-background' data-disabled={loading}>
<InputGroupInput
placeholder={t("common.search_placeholder")}
@ -170,23 +182,34 @@ export const InvoicesListGrid = ({
Exportar
</Button>
</div>
<div className="overflow-hidden">
<div className="relative flex">
<div className={preview.isPinned ? "flex-1 mr-[500px]" : "flex-1"}>
<DataTable
columns={columns}
data={items}
readOnly
enableRowSelection
enablePagination
manualPagination
pageIndex={pageIndex}
pageSize={pageSize}
totalItems={total_items}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onRowClick={handleRowClick}
/>
</div>
<DataTable
columns={columns}
data={items}
readOnly
enableRowSelection
enablePagination
manualPagination
pageIndex={pageIndex} // DataTable usa 0-based
pageSize={pageSize}
totalItems={total_items}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onRowClick={onRowClick}
/>
<preview.Preview>
{({ item, isPinned, close, togglePin }) => (
<InvoicePreviewPanel
invoice={item}
isPinned={isPinned}
onClose={close}
onTogglePin={togglePin}
/>
)}
</preview.Preview>
</div>
</div>
);

View File

@ -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<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);
@ -71,18 +63,6 @@ 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>
@ -101,7 +81,6 @@ export const InvoiceListPage = () => {
<AppBreadcrumb />
<PageHeader
title={t("pages.list.title")}
icon={<FilePenIcon className='size-6 text-primary' aria-hidden />}
rightSlot={
<></>}
@ -117,8 +96,9 @@ export const InvoiceListPage = () => {
<div className='flex items-center space-x-2'>
<Button
onClick={() => navigate("/customer-invoices/create")}
className="bg-gradient-to-r from-blue-600 to-violet-600 hover:from-blue-700 hover:to-violet-700 text-white shadow-lg shadow-blue-500/30"
variant={'default'}
aria-label={t("pages.create.title")}
className='cursor-pointer'
>
<PlusIcon className="mr-2 h-4 w-4" aria-hidden />
{t("pages.create.title")}
@ -126,7 +106,7 @@ export const InvoiceListPage = () => {
</div>
</div>
<div className='flex flex-col w-full h-full py-3'>
<div className={invoicePreview.isPinned ? "flex-1 mr-[440px]" : "flex-1"}>
<div className={"flex-1"}>
<InvoicesListGrid
invoicesPage={invoicesPageData}
loading={isLoading}
@ -136,20 +116,8 @@ export const InvoiceListPage = () => {
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

@ -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<InvoiceSummaryFormData>[] {
//const { t, readOnly, currency_code, language_code } = useInvoiceContext();
export function useInvoicesListColumns(
handlers: InvoiceActionHandlers = {}
): ColumnDef<InvoiceSummaryFormData>[] {
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<ColumnDef<InvoiceSummaryFormData>[]>(() => [
// Nº
{
accessorKey: "invoice_number",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.invoice_number")} className='text-left' />
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.invoice_number")} className="text-left" />
),
cell: ({ row }) => (
<div className='font-semibold text-left text-primary'>
{row.getValue('invoice_number')}
<div className="font-semibold text-left text-primary">
{row.getValue("invoice_number")}
</div>
),
enableHiding: false,
enableSorting: false,
size: 32,
}, {
size: 160,
minSize: 120,
},
// Estado
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.status")} className='text-left' />
),
cell: ({ row }) => (
<CustomerInvoiceStatusBadge status={row.getValue('status')} />
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.status")} className="text-left" />
),
cell: ({ row }) => <CustomerInvoiceStatusBadge status={row.getValue("status")} />,
enableSorting: false,
size: 32,
}, {
size: 140,
minSize: 120,
},
// Serie
{
accessorKey: "series",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.series")} className='text-left' />
),
cell: ({ row }) => (
<div className='font-medium text-left'>
{row.getValue('series')}
</div>
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.series")} className="text-left" />
),
cell: ({ row }) => <div className="font-medium text-left">{row.getValue("series")}</div>,
enableSorting: false,
size: 32,
}, {
size: 120,
minSize: 100,
},
// Fecha factura
{
accessorKey: "invoice_date",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.invoice_date")} className='text-left tabular-nums' />
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.invoice_date")} className="text-left tabular-nums" />
),
cell: ({ row }) => (
<div className='font-medium text-left tabular-nums'>
{formatDate(row.getValue('invoice_date'))}
<div className="font-medium text-left tabular-nums">
{formatDate(row.getValue("invoice_date"))}
</div>
),
enableSorting: false,
size: 32,
}, {
size: 140,
minSize: 120,
},
// Fecha operación
{
accessorKey: "operation_date",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.operation_date")} className='text-left tabular-nums' />
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.operation_date")} className="text-left tabular-nums" />
),
cell: ({ row }) => (
<div className='font-medium text-left tabular-nums'>
{formatDate(row.getValue('operation_date'))}
<div className="font-medium text-left tabular-nums">
{formatDate(row.getValue("operation_date"))}
</div>
),
enableSorting: false,
size: 32,
}, {
size: 140,
minSize: 120,
},
// TIN
{
id: "recipient_tin",
accessorKey: "recipient.tin",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.recipient_tin")} className='text-left tabular-nums' />
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.recipient_tin")} className="text-left tabular-nums" />
),
cell: ({ row }) => (
<div className='font-medium text-left tabular-nums'>
{row.getValue('recipient_tin')}
</div>
<div className="font-medium text-left tabular-nums">{row.getValue("recipient_tin")}</div>
),
enableSorting: false,
size: 32,
}, {
size: 160,
minSize: 140,
},
// Cliente
{
accessorKey: "recipient.name",
id: "recipient_name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.recipient_name")} className='text-left tabular-nums' />
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.recipient_name")} className="text-left" />
),
cell: ({ row }) => (
<div className='font-semibold text-left tabular-nums'>
{row.getValue('recipient_name')}
<div className="font-semibold text-left truncate" title={row.getValue("recipient_name")}>
{row.getValue("recipient_name")}
</div>
),
enableSorting: false,
size: 32,
}, {
size: 260,
minSize: 200,
},
// Total
{
accessorKey: "total_amount_fmt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.total_amount")} className='text-right tabular-nums' />
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.total_amount")} className="text-right tabular-nums" />
),
cell: ({ row }) => (
<div className='font-semibold text-right tabular-nums'>
{row.getValue('total_amount_fmt')}
</div>
<div className="font-semibold text-right tabular-nums">{row.getValue("total_amount_fmt")}</div>
),
enableSorting: false,
size: 32,
}
size: 140,
minSize: 120,
},
], [t]);
// ─────────────────────────────
// Acciones
// ─────────────────────────────
{
id: "actions",
header: () => <span className="sr-only">{t("common.actions")}</span>,
enableSorting: false,
enableHiding: false,
size: 110,
minSize: 96,
cell: ({ row }) => {
const invoice = row.original;
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
return (
<div className="flex items-center justify-end gap-1 pr-1" onClick={stop} onKeyDown={stop}>
{/* Editar (acción primaria) */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="sm"
variant="ghost"
className='cursor-pointer text-muted-foreground hover:text-primary'
aria-label={t("common.edit")}
onClick={(e) => {
e.stopPropagation();
onEdit?.(invoice);
}}
>
<EditIcon className="size-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.edit")}</TooltipContent>
</Tooltip>
{/* Menú demás acciones */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="sm"
variant="ghost"
className='cursor-pointer text-muted-foreground hover:text-primary'
aria-label={t("common.more_actions")}
onClick={stop}
>
<MoreVerticalIcon className="size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={() => onDuplicate?.(invoice)}
className="cursor-pointer"
>
<CopyIcon className="mr-2 size-4" />
{t("common.duplicate")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDownloadPdf?.(invoice)}
className="cursor-pointer"
>
<DownloadIcon className="mr-2 size-4" />
{t("common.download_pdf")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSendEmail?.(invoice)}
className="cursor-pointer"
>
<MailIcon className="mr-2 size-4" />
{t("common.send_email")}
</DropdownMenuItem> <DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete?.(invoice)}
className="text-destructive focus:text-destructive-foreground focus:bg-destructive cursor-pointer"
>
<Trash2Icon className="mr-2 size-4 text-destructive focus:text-destructive-foreground" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
], [t, onEdit, onDuplicate, onDownloadPdf, onSendEmail, onDelete]);
}

View File

@ -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 (
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
<AppHeader>
<AppBreadcrumb />
<PageHeader
title={`${t("pages.edit.title")} ${invoiceData.invoice_number}`}
icon={<FilePenIcon className='size-6 text-primary' aria-hidden />}
rightSlot={
<FormCommitButtonGroup
<UpdateCommitButtonGroup
isLoading={isPending}
submit={{ formId: "invoice-update-form", disabled: isPending }}
submit={{ formId: "invoice-update-form", variant: 'default', disabled: isPending, label: t("pages.edit.actions.save_draft") }}
cancel={{ to: "/customer-invoices/list" }}
onBack={() => navigate(-1)}
/>
}
/>
</AppHeader>
<AppContent>
<FormProvider {...form}>
<InvoiceEditForm
formId="invoice-update-form"
onSubmit={handleSubmit}

View File

@ -0,0 +1,154 @@
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
ArrowLeftIcon,
CopyIcon,
EyeIcon,
MoreHorizontalIcon,
RotateCcwIcon,
Trash2Icon,
} from "lucide-react";
import { useFormContext } from "react-hook-form";
import { CancelFormButton, CancelFormButtonProps } from "./cancel-form-button";
import { SubmitButtonProps, SubmitFormButton } from "./submit-form-button";
type Align = "start" | "center" | "end" | "between";
type GroupSubmitButtonProps = Omit<SubmitButtonProps, "isLoading" | "preventDoubleSubmit">;
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<Align, string> = {
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 (
<div
className={cn(
"flex",
reverseOrderOnMobile ? "flex-col-reverse sm:flex-row" : "flex-row",
alignToJustify[align],
gap,
className
)}
>
{submit && <SubmitFormButton {...submit} />}
{showCancel && <CancelFormButton {...cancel} />}
{/* Menú de acciones adicionales */}
{hasSecondaryActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='ghost' size='sm' disabled={computedDisabled} className='px-2'>
<MoreHorizontalIcon className='h-4 w-4' />
<span className='sr-only'>Más acciones</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-48'>
{onReset && (
<DropdownMenuItem
onClick={onReset}
disabled={computedDisabled}
className='text-muted-foreground'
>
<RotateCcwIcon className='mr-2 h-4 w-4' />
Deshacer cambios
</DropdownMenuItem>
)}
{onPreview && (
<DropdownMenuItem onClick={onPreview} className='text-muted-foreground'>
<EyeIcon className='mr-2 h-4 w-4' />
Vista previa
</DropdownMenuItem>
)}
{onDuplicate && (
<DropdownMenuItem onClick={onDuplicate} className='text-muted-foreground'>
<CopyIcon className='mr-2 h-4 w-4' />
Duplicar
</DropdownMenuItem>
)}
{onBack && (
<DropdownMenuItem onClick={onBack} className='text-muted-foreground'>
<ArrowLeftIcon className='mr-2 h-4 w-4' />
Volver
</DropdownMenuItem>
)}
{onDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onDelete}
className='text-destructive focus:text-destructive'
>
<Trash2Icon className='mr-2 h-4 w-4' />
Eliminar
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
};

View File

@ -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 (
<div className={className}>
<div className='flex items-start gap-4'>
{/* Avatar mejorado con gradiente sutil */}
<div className='flex size-12 items-center justify-center rounded-full bg-muted group-hover:bg-primary/15'>
<UserIcon className='size-6 text-muted-foreground group-hover:text-primary' />
</div>
<div className='flex-1 min-w-0 '>
{/* Nombre del cliente */}
<h3 className='font-semibold text-foreground text-lg leading-tight mb-1 text-left text-balance'>
{customer.name}
</h3>
{/* NIF/CIF con icono */}
{customer.tin && (
<div className='flex items-center gap-2 text-sm text-muted-foreground mb-3'>
<CreditCard className='h-4 w-4 shrink-0' />
<span className='font-medium'>{customer.tin}</span>
</div>
)}
{/* Separador si hay dirección */}
{hasAddress && <Separator className='my-3' />}
<Item variant="outline" className={className}>
<ItemContent>
<ItemTitle className='flex gap-2 w-full justify-between'>
<span className='grow'>{customer.name}</span>
<Button
type='button'
variant='ghost'
size='sm'
className='cursor-pointer'
onClick={onViewCustomer}
>
<EyeIcon className='size-4 text-muted-foreground' />
<span className='sr-only'>Ver ficha completa</span>
</Button>
</ItemTitle>
<ItemDescription className='text-sm text-muted-foreground'>
{customer.tin && (<span>{customer.tin}</span>)}
{/* Dirección con mejor estructura */}
{hasAddress && (
<div className='space-y-2'>
<div className='flex items-start gap-2 text-sm text-muted-foreground'>
<MapPinHouseIcon className='h-4 w-4 shrink-0 mt-0.5 text-primary/60' />
<div className='space-y-0.5 leading-relaxed flex-1 text-left'>
{customer.street && <div>{customer.street}</div>}
{customer.street2 && <div>{customer.street2}</div>}
<div className='flex flex-wrap gap-x-2'>
{customer.postal_code && <span>{customer.postal_code}</span>}
{customer.city && <span>{customer.city}</span>}
</div>
<div className='flex flex-wrap gap-x-2'>
{customer.province && <span>{customer.province}</span>}
{customer.country && <span>{customer.country}</span>}
</div>
</div>
<div className='x'>
{customer.street && <div>{customer.street}</div>}
{customer.street2 && <div>{customer.street2}</div>}
<div className='flex flex-wrap gap-x-2'>
{customer.postal_code && <span>{customer.postal_code}</span>}
{customer.city && <span>{customer.city}</span>}
</div>
<div className='flex flex-wrap gap-x-2'>
{customer.province && <span>{customer.province}</span>}
{customer.country && <span>{customer.country}</span>}
</div>
</div>
)}
</div>
</div>
<Separator className='my-4' />
<div className='flex flex-wrap gap-2'>
<Button
variant='outline'
size='sm'
onClick={onViewCustomer}
className='flex-1 min-w-[140px] gap-2 bg-transparent'
>
<EyeIcon className='h-4 w-4' />
Ver ficha completa
</Button>
<Button
variant='outline'
size='sm'
onClick={onChangeCustomer}
className='flex-1 min-w-[140px] gap-2 bg-transparent'
>
<RefreshCwIcon className='h-4 w-4' />
Cambiar de cliente
</Button>
<Button
variant='outline'
size='sm'
onClick={onAddNewCustomer}
className='flex-1 min-w-[140px] gap-2 bg-transparent'
>
<UserPlusIcon className='h-4 w-4' />
Nuevo cliente
</Button>
</div>
</div>
)}
</ItemDescription>
</ItemContent>
<ItemFooter>
<div className='flex flex-wrap gap-2'>
<Button
type="button"
variant='outline'
size='sm'
onClick={onChangeCustomer}
className='flex-1 min-w-36 gap-2 cursor-pointer'
>
<RefreshCwIcon className='size-4' />
<span className='text-sm text-muted-foreground'>
Cambiar de cliente
</span>
</Button>
<Button
type="button"
variant='outline'
size='sm'
onClick={onAddNewCustomer}
className='flex-1 min-w-36 gap-2 cursor-pointer'
>
<UserPlusIcon className='size-4' />
<span className='text-sm text-muted-foreground'>
Nuevo cliente
</span>
</Button>
</div>
</ItemFooter>
</Item>
);
};

View File

@ -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")}
</p>
</div>
<FormCommitButtonGroup
<UpdateCommitButtonGroup
isLoading={isCreating}
disabled={isCreating}
cancel={{

View File

@ -74,8 +74,10 @@ export function DataTableToolbar<TData>({
{/* Botón añadir */}
{!readOnly && meta?.tableOps?.onAdd && (
<Button
className='cursor-pointer'
type="button"
size="sm"
variant={'outline'}
onClick={handleAdd}
aria-label={t("components.datatable.actions.add")}
>
@ -91,6 +93,7 @@ export function DataTableToolbar<TData>({
{!readOnly && meta?.bulkOps?.duplicateSelected && (
<Button
className='cursor-pointer'
type="button"
size="sm"
variant="outline"

View File

@ -182,8 +182,7 @@ export function DataTable<TData, TValue>({
// Render principal
return (
<div
className="overflow-hidden transition-[max-height] duration-300 ease-in-out"
style={{ maxHeight: `${table.getRowModel().rows.length * 56}px` }} // 56≈altura fila
className="transition-[max-height] duration-300 ease-in-out"
>
<div className="flex flex-col gap-0">
<DataTableToolbar table={table} showViewOptions={!readOnly} />
@ -191,7 +190,7 @@ export function DataTable<TData, TValue>({
<div className="overflow-hidden rounded-md border">
<TableComp className="w-full text-sm">
{/* CABECERA */}
<TableHeader className="sticky top-0 z-10">
<TableHeader className="sticky top-0 z-10 bg-muted">
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => {