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", style: "currency",
currency, currency,
maximumFractionDigits: scale, maximumFractionDigits: scale,
minimumFractionDigits: Number.isInteger(amount) ? 0 : 0, minimumFractionDigits: Number.isInteger(amount) ? 0 : scale,
useGrouping: true, useGrouping: true,
}).format(amount); }).format(amount);
}; };

View File

@ -1,4 +1,5 @@
import { Button } from "@repo/shadcn-ui/components"; import { Button } from "@repo/shadcn-ui/components";
import { cn } from '@repo/shadcn-ui/lib/utils';
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { useCallback } from "react"; import { useCallback } from "react";
@ -53,7 +54,7 @@ export const CancelFormButton = ({
type='button' type='button'
variant={variant} variant={variant}
size={size} size={size}
className={className} className={cn("cursor-pointer", className)}
onClick={handleClick} onClick={handleClick}
disabled={disabled} disabled={disabled}
aria-disabled={disabled} aria-disabled={disabled}

View File

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

View File

@ -78,7 +78,7 @@ export const SubmitFormButton = ({
data-state={dataState} data-state={dataState}
onClick={handleClick} onClick={handleClick}
data-testid={dataTestId} 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 ? (
children children

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import { DataTable, useWithRowSelection } from '@repo/rdx-ui/components'; import { DataTable, useWithRowSelection } from '@repo/rdx-ui/components';
import { Table } from '@tanstack/react-table';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useFieldArray, useFormContext } from "react-hook-form"; import { useFieldArray, useFormContext } from "react-hook-form";
import { useInvoiceContext } from '../../../context'; import { useInvoiceContext } from '../../../context';
@ -35,17 +34,16 @@ export const ItemsEditor = () => {
return ( return (
<div className="space-y-0"> <div className="space-y-0">
<DataTable columns={columns as any} data={fields} <DataTable columns={columns as any} data={fields}
getRowId={row => String(row?.index)}
meta={{ meta={{
tableOps: { tableOps: {
onAdd: () => append({ ...createEmptyItem() }), onAdd: () => append({ ...createEmptyItem() }),
appendItem: (item: any) => append(item), //appendItem: (item: any) => append(item),
}, },
rowOps: { rowOps: {
remove: (i: number) => remove(i), remove: (i: number) => remove(i),
move: (from: number, to: number) => move(from, to), move: (from: number, to: number) => move(from, to),
insertItem: (index: number, item: any) => insert(index, item), //insertItem: (index: number, item: any) => insert(index, item),
duplicateItems: (indexes: number[], table: Table<InvoiceFormData>) => { /*duplicateItems: (indexes: number[], table: Table<InvoiceFormData>) => {
const items = getValues("items") || []; const items = getValues("items") || [];
// duplicate in descending order to keep indexes stable // duplicate in descending order to keep indexes stable
[...indexes].sort((a, b) => b - a).forEach(i => { [...indexes].sort((a, b) => b - a).forEach(i => {
@ -55,12 +53,12 @@ export const ItemsEditor = () => {
append({ ...rest }); append({ ...rest });
} }
}); });
}, },*/
deleteItems: (indexes: number[]) => { /*deleteItems: (indexes: number[]) => {
// remove in descending order to avoid shifting issues // remove in descending order to avoid shifting issues
[...indexes].sort((a, b) => b - a).forEach(i => remove(i)); [...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: { bulkOps: {
duplicateSelected: (indexes, table) => { duplicateSelected: (indexes, table) => {

View File

@ -18,10 +18,12 @@ export const InvoiceRecipient = (props: ComponentProps<"fieldset">) => {
{t('form_groups.recipient.title')} {t('form_groups.recipient.title')}
</FieldLegend> </FieldLegend>
<FieldDescription className='hidden'>{t("form_groups.recipient.description")}</FieldDescription> <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 <RecipientModalSelectorField
control={control} control={control}
name='customer_id' name='customer_id'
label={t('form_groups.customer.title')}
initialRecipient={recipient} initialRecipient={recipient}
/> />
</FieldGroup> </FieldGroup>

View File

@ -1,12 +1,19 @@
import { CustomerModalSelector } from "@erp/customers/components"; 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 { 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> = { type CustomerModalSelectorFieldProps<TFormValues extends FieldValues> = {
control: Control<TFormValues>; control: Control<TFormValues>;
name: FieldPath<TFormValues>; name: FieldPath<TFormValues>;
label?: string;
description?: string;
orientation?: "vertical" | "horizontal" | "responsive",
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
readOnly?: boolean; readOnly?: boolean;
@ -17,6 +24,13 @@ type CustomerModalSelectorFieldProps<TFormValues extends FieldValues> = {
export function RecipientModalSelectorField<TFormValues extends FieldValues>({ export function RecipientModalSelectorField<TFormValues extends FieldValues>({
control, control,
name, name,
label,
description,
orientation = 'vertical',
disabled = false, disabled = false,
required = false, required = false,
readOnly = false, readOnly = false,
@ -28,14 +42,15 @@ export function RecipientModalSelectorField<TFormValues extends FieldValues>({
return ( return (
<FormField <Controller
control={control} control={control}
name={name} name={name}
render={({ field }) => { render={({ field, fieldState }) => {
const { name, value, onChange, onBlur, ref } = field; const { name, value, onChange, onBlur, ref } = field;
//console.log({ name, value, onChange, onBlur, ref });
return ( 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 <CustomerModalSelector
value={value} value={value}
disabled={isDisabled} disabled={isDisabled}
@ -45,7 +60,7 @@ export function RecipientModalSelectorField<TFormValues extends FieldValues>({
...initialRecipient as CustomerSummary ...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 { cn } from '@repo/shadcn-ui/lib/utils';
import { ChevronLeftIcon } from 'lucide-react';
// features/common/components/page-header.tsx // features/common/components/page-header.tsx
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge"; import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge";
@ -20,15 +22,19 @@ interface PageHeaderProps {
export function PageHeader({ icon, title, description, status, rightSlot, className }: PageHeaderProps) { export function PageHeader({ icon, title, description, status, rightSlot, className }: PageHeaderProps) {
return ( return (
<div className={cn("border-b bg-card -px-4", className)}> <div className={cn("border-b bg-card -px-4 pt-4", className)}>
<div className='mx-auto w-full px-6 pt-2 pb-8'> <div className="mx-auto px-6 py-4">
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
{/* Lado izquierdo */} {/* 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>} {icon && <div className='shrink-0'>{icon}</div>}
<div> <div>
<div className='flex items-center gap-3'> <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} />} {status && <CustomerInvoiceStatusBadge status={status} />}
</div> </div>
{description && <p className='text-sm text-muted-foreground'>{description}</p>} {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 // hooks/use-pinned-preview-sheet.ts
import * as React from "react"; import * as React from "react";
import { createPortal } from 'react-dom';
type UsePinnedPreviewSheetOptions<T> = { type UsePinnedPreviewSheetOptions<T> = {
/** Persiste el pin en localStorage (clave). Si omites, no persiste. */ persistKey?: string; // clave localStorage para “pin”
persistKey?: string; widthClass?: string; // ancho del panel: p. ej. "w-[500px]"
/** Anchura del panel anclado (Tailwind). */ onOpenChange?: (open: boolean) => void;
pinnedWidthClass?: string; // p.ej. "w-[420px]" title?: string | ((item: T | null) => string); // Título del Sheet (no anclado)
/** Título del Sheet (no anclado). */
title?: string | ((item: T | null) => string);
}; };
export type PinnedPreviewSheetAPI<T> = { export type PinnedPreviewSheet<T> = {
/** Estado */ /** Estado */
isOpen: boolean; isOpen: boolean;
isPinned: boolean; isPinned: boolean;
item: T | null; item: T | null;
/** Acciones */
open: (item: T) => void; open: (item: T) => void;
close: () => void; close: () => void;
togglePin: () => void; togglePin: () => void;
setItem: (item: T | null) => void;
/** Renderizado: coloca este nodo cerca del listado (al final de la página/feature) */ /** Añade margen al contenedor de la lista cuando está anclado */
PreviewContainer: React.FC<{ listRightMarginClass: string;
/** Render del cuerpo del preview */
children: (item: T) => React.ReactNode; /** Renderiza el panel (Sheet o aside) */
/** Cabecera opcional (si quieres sustituir el SheetHeader) */ Preview: React.FC<{
header?: React.ReactNode; children: (ctx: {
item: T;
isPinned: boolean;
close: () => void;
togglePin: () => void;
}) => React.ReactNode
}>; }>;
}; };
export function usePinnedPreviewSheet<T = unknown>( export function usePinnedPreviewSheet<T = unknown>({
opts?: UsePinnedPreviewSheetOptions<T> persistKey = "preview-pin",
): PinnedPreviewSheetAPI<T> { widthClass = "w-[500px]",
const { persistKey, pinnedWidthClass = "w-[420px]", title } = opts ?? {}; onOpenChange,
title,
}: UsePinnedPreviewSheetOptions<T> = {}): PinnedPreviewSheet<T> {
const [isOpen, setOpen] = React.useState(false); const [isOpen, setOpen] = React.useState(false);
const [item, setItem] = React.useState<T | null>(null); const [item, setItem] = React.useState<T | null>(null);
const [isPinned, setPinned] = React.useState<boolean>(() => { 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) => { const open = React.useCallback((next: T) => {
rememberFocus();
setItem(next); setItem(next);
setOpen(true); setOpen(true);
}, []); onOpenChange?.(true);
}, [onOpenChange]);
const close = React.useCallback(() => { const close = React.useCallback(() => {
if (isPinned) return; // Anclado: no cerrar if (isPinned) return;
setOpen(false); setOpen(false);
}, [isPinned]); onOpenChange?.(false);
setTimeout(restoreFocus, 0);
}, [isPinned, onOpenChange]);
const togglePin = React.useCallback(() => { const togglePin = React.useCallback(() => {
setPinned((p) => { setPinned((p) => {
const next = !p; const n = !p;
if (persistKey) { try { localStorage.setItem(persistKey, n ? "1" : "0"); } catch { }
try { return n;
localStorage.setItem(persistKey, next ? "1" : "0");
} catch { }
}
// Si se fija el pin y hay item, abre “estático”; si se desancla, mostramos Sheet si había abierto
if (!next && item) setOpen(true);
return next;
}); });
}, [persistKey, item]); }, [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(() => { const HeaderTitle = React.useMemo(() => {
if (!item) return ""; if (!item) return "";
@ -77,64 +97,32 @@ export function usePinnedPreviewSheet<T = unknown>(
return title ?? ""; return title ?? "";
}, [item, title]); }, [item, title]);
const PreviewContainer: PinnedPreviewSheetAPI<T>["PreviewContainer"] = React.useCallback( const Preview: PinnedPreviewSheet<T>["Preview"] = React.useCallback(({ children }) => {
({ children, header }) => { if (!item) return null;
if (!item) return null;
// Modo anclado: aside fijo sin overlay, no bloquea scroll, accesible. // Panel anclado: aside estático sin overlay
if (isPinned) { if (isPinned) {
return ( return createPortal(
<aside <aside
aria-label="Vista previa anclada" aria-label="Vista previa anclada"
className={`fixed inset-y-0 right-0 ${pinnedWidthClass} bg-background border-l z-30 flex flex-col`} className={`fixed inset-y-0 right-0 ${widthClass} bg-background border-l z-40`}
> >
<div className="flex items-center justify-between p-3 border-b"> {children({ item, isPinned: true, close, togglePin })}
{header ?? ( </aside>,
<div className="flex items-center gap-2"> document.body
<span className="text-sm font-medium">{HeaderTitle}</span>
<span className="text-xs text-muted-foreground">(anclado)</span>
</div>
)}
<button
type="button"
onClick={togglePin}
className="text-xs underline"
aria-label="Desanclar panel"
>
Desanclar
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto p-3">{children(item)}</div>
</aside>
);
}
// Modo Sheet (deslizable desde la derecha)
return (
<Sheet open={isOpen} onOpenChange={(o) => (o ? setOpen(true) : close())}>
<SheetContent side="right" className={pinnedWidthClass}>
{header ?? (
<SheetHeader>
<SheetTitle className="text-base">{HeaderTitle}</SheetTitle>
</SheetHeader>
)}
<div className="mt-3">{children(item)}</div>
<div className="mt-4">
<button
type="button"
onClick={togglePin}
className="text-xs underline"
aria-label="Anclar panel"
>
Anclar
</button>
</div>
</SheetContent>
</Sheet>
); );
}, }
[HeaderTitle, close, isOpen, isPinned, item, pinnedWidthClass, togglePin]
);
return { isOpen, isPinned, item, open, close, togglePin, setItem, PreviewContainer }; // 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 { 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 { FileDownIcon, FilterIcon, SearchIcon, XIcon } from 'lucide-react';
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { usePinnedPreviewSheet } from '../../hooks';
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { InvoiceSummaryFormData, InvoicesPageFormData } from '../../schemas'; import { InvoiceSummaryFormData, InvoicesPageFormData } from '../../schemas';
import { InvoicePreviewPanel } from './invoice-preview-panel';
import { useInvoicesListColumns } from './use-invoices-list-columns'; import { useInvoicesListColumns } from './use-invoices-list-columns';
export type InvoiceUpdateCompProps = { export type InvoiceUpdateCompProps = {
@ -40,9 +42,21 @@ export const InvoicesListGrid = ({
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); 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 [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; const { items, total_items } = invoicesPage;
// Navegación accesible (click o teclado) // Navegación accesible (click o teclado)
@ -97,12 +111,12 @@ export const InvoicesListGrid = ({
}; };
const handleRowClick = useCallback( const handleRowClick = useCallback(
(invoice: InvoiceSummaryFormData, _index: number, e: React.MouseEvent) => { (invoice: InvoiceSummaryFormData, _i: number, e: React.MouseEvent) => {
const url = `/customer-invoices/${invoice.id}/edit`; const url = `/customer-invoices/${invoice.id}/edit`;
if (e.metaKey || e.ctrlKey) window.open(url, "_blank", "noopener,noreferrer"); if (e.metaKey || e.ctrlKey) { window.open(url, "_blank", "noopener,noreferrer"); return; }
else navigate(url); preview.open(invoice);
}, },
[navigate] [preview]
); );
if (loading) { if (loading) {
@ -123,9 +137,7 @@ export const InvoicesListGrid = ({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Barra de filtros */} {/* Barra de filtros */}
<div className="flex flex-col sm:flex-row gap-4 mb-6"> <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}> <InputGroup className='bg-background' data-disabled={loading}>
<InputGroupInput <InputGroupInput
placeholder={t("common.search_placeholder")} placeholder={t("common.search_placeholder")}
@ -170,23 +182,34 @@ export const InvoicesListGrid = ({
Exportar Exportar
</Button> </Button>
</div> </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 <preview.Preview>
columns={columns} {({ item, isPinned, close, togglePin }) => (
data={items} <InvoicePreviewPanel
readOnly invoice={item}
enableRowSelection isPinned={isPinned}
enablePagination onClose={close}
onTogglePin={togglePin}
manualPagination />
pageIndex={pageIndex} // DataTable usa 0-based )}
pageSize={pageSize} </preview.Preview>
totalItems={total_items}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onRowClick={onRowClick}
/>
</div> </div>
</div> </div>
); );

View File

@ -1,15 +1,13 @@
import { ErrorAlert } from '@erp/customers/components'; import { ErrorAlert } from '@erp/customers/components';
import { AppBreadcrumb, AppContent, AppHeader, BackHistoryButton, useDebounce } from "@repo/rdx-ui/components"; import { AppBreadcrumb, AppContent, AppHeader, BackHistoryButton, useDebounce } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components"; import { Button } from "@repo/shadcn-ui/components";
import { FilePenIcon, PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { useCallback, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { InvoicesListGrid, PageHeader } from '../../components'; import { InvoicesListGrid, PageHeader } from '../../components';
import { useInvoicesQuery, usePinnedPreviewSheet } from '../../hooks'; import { useInvoicesQuery } from '../../hooks';
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { InvoiceSummaryFormData } from '../../schemas';
import { invoiceResumeDtoToFormAdapter } from '../../schemas/invoice-resume-dto.adapter'; import { invoiceResumeDtoToFormAdapter } from '../../schemas/invoice-resume-dto.adapter';
import { InvoicePreviewCard } from './invoice-preview-card';
export const InvoiceListPage = () => { export const InvoiceListPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -48,12 +46,6 @@ export const InvoiceListPage = () => {
}, [data]); }, [data]);
const invoicePreview = usePinnedPreviewSheet<InvoiceSummaryFormData>({
persistKey: "invoice-preview-pin",
pinnedWidthClass: "w-[440px]",
title: (inv) => (inv ? `Factura ${inv.invoice_number}` : "Proforma"),
});
const handlePageChange = (newPageIndex: number) => { const handlePageChange = (newPageIndex: number) => {
// TanStack usa pageIndex 0-based → API usa 0-based también // TanStack usa pageIndex 0-based → API usa 0-based también
setPageIndex(newPageIndex); setPageIndex(newPageIndex);
@ -71,18 +63,6 @@ export const InvoiceListPage = () => {
setPageIndex(0); 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) { if (isError || !invoicesPageData) {
return ( return (
<AppContent> <AppContent>
@ -101,7 +81,6 @@ export const InvoiceListPage = () => {
<AppBreadcrumb /> <AppBreadcrumb />
<PageHeader <PageHeader
title={t("pages.list.title")} title={t("pages.list.title")}
icon={<FilePenIcon className='size-6 text-primary' aria-hidden />}
rightSlot={ rightSlot={
<></>} <></>}
@ -117,8 +96,9 @@ export const InvoiceListPage = () => {
<div className='flex items-center space-x-2'> <div className='flex items-center space-x-2'>
<Button <Button
onClick={() => navigate("/customer-invoices/create")} 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")} aria-label={t("pages.create.title")}
className='cursor-pointer'
> >
<PlusIcon className="mr-2 h-4 w-4" aria-hidden /> <PlusIcon className="mr-2 h-4 w-4" aria-hidden />
{t("pages.create.title")} {t("pages.create.title")}
@ -126,7 +106,7 @@ export const InvoiceListPage = () => {
</div> </div>
</div> </div>
<div className='flex flex-col w-full h-full py-3'> <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 <InvoicesListGrid
invoicesPage={invoicesPageData} invoicesPage={invoicesPageData}
loading={isLoading} loading={isLoading}
@ -136,20 +116,8 @@ export const InvoiceListPage = () => {
onPageSizeChange={handlePageSizeChange} onPageSizeChange={handlePageSizeChange}
searchValue={search} searchValue={search}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
onRowClick={handleRowClick}
/> />
</div> </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> </div>
</AppContent> </AppContent>
</> </>

View File

@ -1,120 +1,233 @@
import { formatDate } from '@erp/core/client'; import { formatDate } from '@erp/core/client';
import { DataTableColumnHeader } from '@repo/rdx-ui/components'; 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 type { ColumnDef } from "@tanstack/react-table";
import { CopyIcon, DownloadIcon, EditIcon, MailIcon, MoreVerticalIcon, Trash2Icon } from 'lucide-react';
import * as React from "react"; import * as React from "react";
import { CustomerInvoiceStatusBadge } from '../../components'; import { CustomerInvoiceStatusBadge } from '../../components';
import { useTranslation } from '../../i18n'; 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(
export function useInvoicesListColumns(): ColumnDef<InvoiceSummaryFormData>[] { handlers: InvoiceActionHandlers = {}
//const { t, readOnly, currency_code, language_code } = useInvoiceContext(); ): ColumnDef<InvoiceSummaryFormData>[] {
const { t } = useTranslation(); 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>[]>(() => [ return React.useMemo<ColumnDef<InvoiceSummaryFormData>[]>(() => [
// Nº
{ {
accessorKey: "invoice_number", accessorKey: "invoice_number",
header: ({ column }) => ( 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 }) => ( cell: ({ row }) => (
<div className='font-semibold text-left text-primary'> <div className="font-semibold text-left text-primary">
{row.getValue('invoice_number')} {row.getValue("invoice_number")}
</div> </div>
), ),
enableHiding: false, enableHiding: false,
enableSorting: false, enableSorting: false,
size: 32, size: 160,
}, { minSize: 120,
},
// Estado
{
accessorKey: "status", accessorKey: "status",
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.status")} className='text-left' /> <DataTableColumnHeader column={column} title={t("pages.list.grid_columns.status")} className="text-left" />
),
cell: ({ row }) => (
<CustomerInvoiceStatusBadge status={row.getValue('status')} />
), ),
cell: ({ row }) => <CustomerInvoiceStatusBadge status={row.getValue("status")} />,
enableSorting: false, enableSorting: false,
size: 32, size: 140,
}, { minSize: 120,
},
// Serie
{
accessorKey: "series", accessorKey: "series",
header: ({ column }) => ( header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.series")} className='text-left' /> <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>
), ),
cell: ({ row }) => <div className="font-medium text-left">{row.getValue("series")}</div>,
enableSorting: false, enableSorting: false,
size: 32, size: 120,
}, { minSize: 100,
},
// Fecha factura
{
accessorKey: "invoice_date", accessorKey: "invoice_date",
header: ({ column }) => ( 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 }) => ( cell: ({ row }) => (
<div className='font-medium text-left tabular-nums'> <div className="font-medium text-left tabular-nums">
{formatDate(row.getValue('invoice_date'))} {formatDate(row.getValue("invoice_date"))}
</div> </div>
), ),
enableSorting: false, enableSorting: false,
size: 32, size: 140,
}, { minSize: 120,
},
// Fecha operación
{
accessorKey: "operation_date", accessorKey: "operation_date",
header: ({ column }) => ( 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 }) => ( cell: ({ row }) => (
<div className='font-medium text-left tabular-nums'> <div className="font-medium text-left tabular-nums">
{formatDate(row.getValue('operation_date'))} {formatDate(row.getValue("operation_date"))}
</div> </div>
), ),
enableSorting: false, enableSorting: false,
size: 32, size: 140,
}, { minSize: 120,
},
// TIN
{
id: "recipient_tin", id: "recipient_tin",
accessorKey: "recipient.tin", accessorKey: "recipient.tin",
header: ({ column }) => ( 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 }) => ( cell: ({ row }) => (
<div className='font-medium text-left tabular-nums'> <div className="font-medium text-left tabular-nums">{row.getValue("recipient_tin")}</div>
{row.getValue('recipient_tin')}
</div>
), ),
enableSorting: false, enableSorting: false,
size: 32, size: 160,
}, { minSize: 140,
},
// Cliente
{
accessorKey: "recipient.name", accessorKey: "recipient.name",
id: "recipient_name", id: "recipient_name",
header: ({ column }) => ( 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 }) => ( cell: ({ row }) => (
<div className='font-semibold text-left tabular-nums'> <div className="font-semibold text-left truncate" title={row.getValue("recipient_name")}>
{row.getValue('recipient_name')} {row.getValue("recipient_name")}
</div> </div>
), ),
enableSorting: false, enableSorting: false,
size: 32, size: 260,
}, { minSize: 200,
},
// Total
{
accessorKey: "total_amount_fmt", accessorKey: "total_amount_fmt",
header: ({ column }) => ( 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 }) => ( cell: ({ row }) => (
<div className='font-semibold text-right tabular-nums'> <div className="font-semibold text-right tabular-nums">{row.getValue("total_amount_fmt")}</div>
{row.getValue('total_amount_fmt')}
</div>
), ),
enableSorting: false, 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 { import {
FormCommitButtonGroup,
UnsavedChangesProvider, UnsavedChangesProvider,
UpdateCommitButtonGroup,
useHookForm useHookForm
} from "@erp/core/hooks"; } 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 { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import { FilePenIcon } from "lucide-react";
import { useMemo } from 'react'; import { useMemo } from 'react';
import { FieldErrors, FormProvider } from "react-hook-form"; import { FieldErrors, FormProvider } from "react-hook-form";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@ -86,24 +85,25 @@ export const InvoiceUpdateComp = ({
return ( return (
<UnsavedChangesProvider isDirty={form.formState.isDirty}> <UnsavedChangesProvider isDirty={form.formState.isDirty}>
<AppHeader> <AppHeader>
<AppBreadcrumb />
<PageHeader <PageHeader
title={`${t("pages.edit.title")} ${invoiceData.invoice_number}`} title={`${t("pages.edit.title")} ${invoiceData.invoice_number}`}
icon={<FilePenIcon className='size-6 text-primary' aria-hidden />}
rightSlot={ rightSlot={
<FormCommitButtonGroup <UpdateCommitButtonGroup
isLoading={isPending} 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" }} cancel={{ to: "/customer-invoices/list" }}
onBack={() => navigate(-1)} onBack={() => navigate(-1)}
/> />
} }
/> />
</AppHeader> </AppHeader>
<AppContent> <AppContent>
<FormProvider {...form}> <FormProvider {...form}>
<InvoiceEditForm <InvoiceEditForm
formId="invoice-update-form" formId="invoice-update-form"
onSubmit={handleSubmit} 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 { import {
CreditCard, Button, Item,
ItemContent,
ItemDescription,
ItemFooter,
ItemTitle
} from "@repo/shadcn-ui/components";
import {
EyeIcon, EyeIcon,
MapPinHouseIcon,
RefreshCwIcon, RefreshCwIcon,
UserIcon, UserPlusIcon
UserPlusIcon,
} from "lucide-react"; } from "lucide-react";
import { CustomerSummary } from "../../schemas"; import { CustomerSummary } from "../../schemas";
@ -16,7 +19,7 @@ interface CustomerCardProps {
onChangeCustomer?: () => void; onChangeCustomer?: () => void;
onAddNewCustomer?: () => void; onAddNewCustomer?: () => void;
className: string; className?: string;
} }
export const CustomerCard = ({ export const CustomerCard = ({
@ -35,83 +38,70 @@ export const CustomerCard = ({
customer.country; customer.country;
return ( return (
<div className={className}> <Item variant="outline" className={className}>
<div className='flex items-start gap-4'> <ItemContent>
{/* Avatar mejorado con gradiente sutil */} <ItemTitle className='flex gap-2 w-full justify-between'>
<div className='flex size-12 items-center justify-center rounded-full bg-muted group-hover:bg-primary/15'> <span className='grow'>{customer.name}</span>
<UserIcon className='size-6 text-muted-foreground group-hover:text-primary' /> <Button
</div> type='button'
variant='ghost'
<div className='flex-1 min-w-0 '> size='sm'
{/* Nombre del cliente */} className='cursor-pointer'
<h3 className='font-semibold text-foreground text-lg leading-tight mb-1 text-left text-balance'> onClick={onViewCustomer}
{customer.name} >
</h3> <EyeIcon className='size-4 text-muted-foreground' />
<span className='sr-only'>Ver ficha completa</span>
{/* NIF/CIF con icono */} </Button>
{customer.tin && ( </ItemTitle>
<div className='flex items-center gap-2 text-sm text-muted-foreground mb-3'> <ItemDescription className='text-sm text-muted-foreground'>
<CreditCard className='h-4 w-4 shrink-0' /> {customer.tin && (<span>{customer.tin}</span>)}
<span className='font-medium'>{customer.tin}</span>
</div>
)}
{/* Separador si hay dirección */}
{hasAddress && <Separator className='my-3' />}
{/* Dirección con mejor estructura */} {/* Dirección con mejor estructura */}
{hasAddress && ( {hasAddress && (
<div className='space-y-2'> <div className='x'>
<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' /> {customer.street && <div>{customer.street}</div>}
<div className='space-y-0.5 leading-relaxed flex-1 text-left'> {customer.street2 && <div>{customer.street2}</div>}
{customer.street && <div>{customer.street}</div>} <div className='flex flex-wrap gap-x-2'>
{customer.street2 && <div>{customer.street2}</div>} {customer.postal_code && <span>{customer.postal_code}</span>}
<div className='flex flex-wrap gap-x-2'> {customer.city && <span>{customer.city}</span>}
{customer.postal_code && <span>{customer.postal_code}</span>} </div>
{customer.city && <span>{customer.city}</span>} <div className='flex flex-wrap gap-x-2'>
</div> {customer.province && <span>{customer.province}</span>}
<div className='flex flex-wrap gap-x-2'> {customer.country && <span>{customer.country}</span>}
{customer.province && <span>{customer.province}</span>}
{customer.country && <span>{customer.country}</span>}
</div>
</div>
</div> </div>
</div> </div>
)}
</div>
</div>
<Separator className='my-4' /> )}
<div className='flex flex-wrap gap-2'> </ItemDescription>
<Button </ItemContent>
variant='outline' <ItemFooter>
size='sm' <div className='flex flex-wrap gap-2'>
onClick={onViewCustomer} <Button
className='flex-1 min-w-[140px] gap-2 bg-transparent' type="button"
> variant='outline'
<EyeIcon className='h-4 w-4' /> size='sm'
Ver ficha completa onClick={onChangeCustomer}
</Button> className='flex-1 min-w-36 gap-2 cursor-pointer'
<Button >
variant='outline' <RefreshCwIcon className='size-4' />
size='sm' <span className='text-sm text-muted-foreground'>
onClick={onChangeCustomer} Cambiar de cliente
className='flex-1 min-w-[140px] gap-2 bg-transparent' </span>
> </Button>
<RefreshCwIcon className='h-4 w-4' /> <Button
Cambiar de cliente type="button"
</Button> variant='outline'
<Button size='sm'
variant='outline' onClick={onAddNewCustomer}
size='sm' className='flex-1 min-w-36 gap-2 cursor-pointer'
onClick={onAddNewCustomer} >
className='flex-1 min-w-[140px] gap-2 bg-transparent' <UserPlusIcon className='size-4' />
> <span className='text-sm text-muted-foreground'>
<UserPlusIcon className='h-4 w-4' /> Nuevo cliente
Nuevo cliente </span>
</Button> </Button>
</div> </div>
</div> </ItemFooter>
</Item>
); );
}; };

View File

@ -1,7 +1,7 @@
import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components"; import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components";
import { useNavigate } from "react-router-dom"; 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 { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import { FieldErrors, FormProvider } from "react-hook-form"; import { FieldErrors, FormProvider } from "react-hook-form";
import { CustomerEditForm, ErrorAlert } from "../../components"; import { CustomerEditForm, ErrorAlert } from "../../components";
@ -74,7 +74,7 @@ export const CustomerCreatePage = () => {
{t("pages.create.description")} {t("pages.create.description")}
</p> </p>
</div> </div>
<FormCommitButtonGroup <UpdateCommitButtonGroup
isLoading={isCreating} isLoading={isCreating}
disabled={isCreating} disabled={isCreating}
cancel={{ cancel={{

View File

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

View File

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