Facturas de cliente

This commit is contained in:
David Arranz 2025-10-16 19:59:13 +02:00
parent d539e5b5f1
commit 0420286261
23 changed files with 542 additions and 354 deletions

View File

@ -9,11 +9,12 @@ interface CustomerInvoiceTaxesMultiSelect {
value?: string[]; value?: string[];
onChange: (selectedValues: string[]) => void; onChange: (selectedValues: string[]) => void;
className?: string; className?: string;
inputId?: string;
[key: string]: any; // Allow other props to be passed [key: string]: any; // Allow other props to be passed
} }
export const CustomerInvoiceTaxesMultiSelect = (props: CustomerInvoiceTaxesMultiSelect) => { export const CustomerInvoiceTaxesMultiSelect = (props: CustomerInvoiceTaxesMultiSelect) => {
const { value, onChange, className, ...otherProps } = props; const { value, onChange, className, inputId, ...otherProps } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const taxCatalog = useMemo(() => SpainTaxCatalogProvider(), []); const taxCatalog = useMemo(() => SpainTaxCatalogProvider(), []);
@ -38,6 +39,7 @@ export const CustomerInvoiceTaxesMultiSelect = (props: CustomerInvoiceTaxesMulti
return ( return (
<div className={cn("w-full", "max-w-md")}> <div className={cn("w-full", "max-w-md")}>
<MultiSelect <MultiSelect
id={inputId}
options={catalogLookup} options={catalogLookup}
onValueChange={onChange} onValueChange={onChange}
defaultValue={value} defaultValue={value}

View File

@ -24,7 +24,7 @@ export const InvoiceEditForm = ({
const form = useFormContext<InvoiceFormData>(); const form = useFormContext<InvoiceFormData>();
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("space-y-6", className)}>
<div className="w-full border p-6 bg-background"> <div className="w-full border p-6 bg-background">
<InvoiceBasicInfoFields className="flex flex-col" /> <InvoiceBasicInfoFields className="flex flex-col" />
@ -32,7 +32,7 @@ export const InvoiceEditForm = ({
</div> </div>
<div className='w-full gap-6'> <div className='w-full gap-6'>
<InvoiceItems className="border p-6 bg-background -p-6" /> <InvoiceItems />
</div> </div>
<div className="w-full border p-6 bg-background"> <div className="w-full border p-6 bg-background">
<InvoiceTotals /> <InvoiceTotals />

View File

@ -0,0 +1,22 @@
import { ColumnDef } from '@tanstack/react-table';
// columna de depuración: muestra el row.id interno de TanStack
export const debugIdCol: ColumnDef<any> = ({
id: "__debug_row_id",
header: () => <span className="font-medium">row.id</span>,
cell: ({ row }) => (
<code
// Texto monoespaciado, truncado si es largo
className="font-mono text-xs text-muted-foreground inline-block max-w-[14rem] truncate tabular-nums"
aria-label={`Row id ${row.id}`}
title={row.id}
>
{row.id}<br />
</code>
),
enableSorting: false,
enableHiding: false, // ponlo en true si quieres que sea ocultable en ViewOptions
size: 160,
minSize: 120,
maxSize: 260,
});

View File

@ -1,8 +1,16 @@
import { Button, Input, Label, Textarea } from "@repo/shadcn-ui/components"; import { Button, Input, Label, Textarea } from "@repo/shadcn-ui/components";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import { InvoiceFormData } from '../../../schemas'; import { InvoiceFormData, InvoiceItemFormData } from '../../../schemas';
export function ItemRowEditor({ index, close }: { index: number; close: () => void }) { export function ItemRowEditor({
row,
index,
onClose,
}: {
row: InvoiceItemFormData
index: number
onClose: () => void
}) {
// Editor simple reutilizando el mismo RHF // Editor simple reutilizando el mismo RHF
const { register } = useFormContext<InvoiceFormData>(); const { register } = useFormContext<InvoiceFormData>();
return ( return (
@ -27,7 +35,7 @@ export function ItemRowEditor({ index, close }: { index: number; close: () => vo
</div> </div>
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="secondary" onClick={close}>Close</Button> <Button onClick={onClose}>OK</Button>
</div> </div>
</div> </div>
); );

View File

@ -3,6 +3,7 @@
import { Row, Table } from "@tanstack/react-table"; import { Row, Table } from "@tanstack/react-table";
import { DataTableRowOps } from '@repo/rdx-ui/components';
import { import {
Button, Button,
Tooltip, Tooltip,
@ -19,64 +20,95 @@ interface DataTableRowActionsProps<TData> {
export function ItemDataTableRowActions<TData>({ export function ItemDataTableRowActions<TData>({
row, table row, table
}: DataTableRowActionsProps<TData>) { }: DataTableRowActionsProps<TData>) {
const ops = (table.options.meta as any)?.rowOps as { const ops = (table.options.meta as any)?.rowOps as DataTableRowOps<TData>;
duplicate?: (i: number) => void; const openEditor = (table.options.meta as any)?.openEditor as (i: number, table: Table<TData>) => void;
remove?: (i: number) => void;
move?: (f: number, t: number) => void;
canMoveUp?: (i: number) => boolean;
canMoveDown?: (i: number, last: number) => boolean;
};
const openEditor = (table.options.meta as any)?.openEditor as (i: number) => void;
const lastRow = table.getRowModel().rows.length - 1; const lastRow = table.getRowModel().rows.length - 1;
const rowIndex = row.index; const rowIndex = row.index;
return ( return (
<div className="inline-flex items-center gap-1"> <div className="items-center gap-1 inline-flex">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Edit row" onClick={() => openEditor?.(rowIndex)}> {openEditor && (
<PencilIcon className="size-4" /> <Button
</Button> type='button'
className='cursor-pointer'
variant='ghost'
size='icon-sm'
aria-label='Edit row'
onClick={() => openEditor?.(rowIndex, table)}
>
<PencilIcon className='size-4 text-muted-foreground' />
</Button>
)}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Edit</TooltipContent> <TooltipContent>Edit</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Duplicate row" onClick={() => ops?.duplicate?.(rowIndex)}> {ops?.duplicate && (
<CopyIcon className="size-4" /> <Button
</Button> type='button'
className='cursor-pointer'
variant='ghost'
size='icon-sm'
aria-label='Duplicate row'
onClick={() => ops?.duplicate?.(rowIndex, table)}
>
<CopyIcon className='size-4 text-muted-foreground' />
</Button>
)}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Copy</TooltipContent> <TooltipContent>Copy</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button {ops?.move && (
variant="ghost" size="icon" aria-label="Move up" <Button
disabled={ops?.canMoveUp ? !ops.canMoveUp(rowIndex) : rowIndex === 0} type='button'
onClick={() => ops?.move?.(rowIndex, rowIndex - 1)} className='cursor-pointer'
> variant='ghost'
<ArrowUpIcon className="size-4" /> size='icon-sm'
</Button> aria-label='Move up'
disabled={ops?.canMoveUp ? !ops.canMoveUp(rowIndex, table) : rowIndex === 0}
onClick={() => ops?.move?.(rowIndex, rowIndex - 1, table)}
>
<ArrowUpIcon className='size-4 text-muted-foreground hover:cursor-pointer' />
</Button>
)}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Up</TooltipContent> <TooltipContent>Up</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button {ops?.move && <Button
variant="ghost" size="icon" aria-label="Move down" type='button'
disabled={ops?.canMoveDown ? !ops.canMoveDown(rowIndex, lastRow) : rowIndex === lastRow} className='cursor-pointer'
onClick={() => ops?.move?.(rowIndex, rowIndex + 1)} variant='ghost'
size='icon-sm'
aria-label='Move down'
disabled={ops?.canMoveDown ? !ops.canMoveDown(rowIndex, lastRow, table) : rowIndex === lastRow}
onClick={() => ops?.move?.(rowIndex, rowIndex + 1, table)}
> >
<ArrowDownIcon className="size-4" /> <ArrowDownIcon className='size-4 text-muted-foreground hover:cursor-pointer' />
</Button> </Button>}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Down</TooltipContent> <TooltipContent>Down</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Delete row" onClick={() => ops?.remove?.(rowIndex)}> {ops?.remove && (
<Trash2Icon className="size-4" /> <Button
</Button> className='cursor-pointer'
type='button'
variant='ghost'
size='icon-sm'
aria-label='Delete row'
onClick={() => ops?.remove?.(rowIndex, table)}
>
<Trash2Icon className='size-4 text-muted-foreground hover:cursor-pointer' />
</Button>
)}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Delete</TooltipContent> <TooltipContent>Delete</TooltipContent>
</Tooltip> </Tooltip>

View File

@ -1,9 +1,12 @@
import { DataTable } from '@repo/rdx-ui/components'; 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 { useFieldArray, useFormContext } from "react-hook-form";
import { useInvoiceContext } from '../../../context'; import { useInvoiceContext } from '../../../context';
import { useInvoiceAutoRecalc } from '../../../hooks'; import { useInvoiceAutoRecalc } from '../../../hooks';
import { useTranslation } from '../../../i18n'; import { useTranslation } from '../../../i18n';
import { InvoiceFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas'; import { InvoiceFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
import { debugIdCol } from './debug-id-col';
import { ItemRowEditor } from './item-row-editor'; import { ItemRowEditor } from './item-row-editor';
import { useItemsColumns } from './use-items-columns'; import { useItemsColumns } from './use-items-columns';
@ -18,31 +21,70 @@ export const ItemsEditor = () => {
useInvoiceAutoRecalc(form, context); useInvoiceAutoRecalc(form, context);
const { fields, append, remove, move, insert } = useFieldArray({ const { fields, append, remove, move, insert, update } = useFieldArray({
control, control,
name: "items", name: "items",
}); });
const columns = useItemsColumns(); console.log(fields);
const baseColumns = useWithRowSelection(useItemsColumns(), true);
const columns = useMemo(
() => [...baseColumns, debugIdCol],
[baseColumns]
);
return ( return (
<div className="space-y-0"> <div className="space-y-0">
<DataTable columns={columns} data={fields} <DataTable columns={columns as any} data={fields}
pageSize={999} getRowId={row => String(row?.index)}
getRowId={(r) => (r as any).id}
meta={{ meta={{
rowOps: { tableOps: {
//duplicate: (indexRow: number) => insert(indexRow + 1, { ...getValues(`items.${indexRow}`) /*, id: crypto.randomUUID()*/ }), onAdd: () => append({ ...createEmptyItem() }),
remove: (indexRow: number) => remove(indexRow), appendItem: (item: any) => append(item),
move: (fromIndex: number, toIndex: number) => {
if (toIndex < 0 || toIndex >= fields.length) return;
move(fromIndex, toIndex);
},
canMoveUp: (indexRow: number) => indexRow > 0,
canMoveDown: (indexRow: number, lastIndexRow: number) => indexRow < lastIndexRow,
}, },
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>) => {
const items = getValues("items") || [];
// duplicate in descending order to keep indexes stable
[...indexes].sort((a, b) => b - a).forEach(i => {
const curr = items[i] as any;
if (curr) {
const { id, ...rest } = curr;
append({ ...rest });
}
});
},
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),
},
bulkOps: {
duplicateSelected: (indexes, table) => {
const originalData = indexes.map((i) => {
const { id, ...original } = table.getRowModel().rows[i].original;
return original;
});
insert(indexes[indexes.length - 1] + 1, originalData, { shouldFocus: true });
table.resetRowSelection();
},
removeSelected: (indexes) => indexes.sort((a, b) => b - a).forEach(remove),
moveSelectedUp: (indexes) => indexes.forEach((i) => move(i, i - 1)),
moveSelectedDown: (indexes) => [...indexes].reverse().forEach((i) => move(i, i + 1)),
}
}} }}
renderRowEditor={(index, close) => <ItemRowEditor index={index} close={close} />} /> enableRowSelection
enablePagination={false}
pageSize={999}
readOnly={false}
EditorComponent={ItemRowEditor}
/>
</div > </div >
); );
} }

View File

@ -30,7 +30,7 @@ export function QuantityInputField<TFormValues extends FieldValues>({
name={name} name={name}
render={({ field }) => { render={({ field }) => {
const { value, onChange } = field; const { value, onChange } = field;
console.log(value);
return <FormItem> return <FormItem>
{label ? ( {label ? (
<FormLabel htmlFor={inputId}> <FormLabel htmlFor={inputId}>

View File

@ -1,5 +1,5 @@
import { DataTableColumnHeader } from '@repo/rdx-ui/components'; import { DataTableColumnHeader } from '@repo/rdx-ui/components';
import { Checkbox, Textarea } from "@repo/shadcn-ui/components"; import { InputGroup, InputGroupTextarea } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import * as React from "react"; import * as React from "react";
@ -30,29 +30,6 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
// Atención: Memoizar siempre para evitar reconstrucciones y resets de estado de tabla // Atención: Memoizar siempre para evitar reconstrucciones y resets de estado de tabla
return React.useMemo<ColumnDef<InvoiceItemFormData>[]>(() => [ return React.useMemo<ColumnDef<InvoiceItemFormData>[]>(() => [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="Select row"
className="translate-y-[2px]"
/>
),
enableSorting: false,
enableHiding: false,
size: 48, minSize: 40, maxSize: 64,
meta: { className: "w-[4ch]" }, // ancho aprox. por dígitos
},
{ {
id: 'position', id: 'position',
header: ({ column }) => ( header: ({ column }) => (
@ -72,26 +49,39 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
control={control} control={control}
name={`items.${row.index}.description`} name={`items.${row.index}.description`}
render={({ field }) => ( render={({ field }) => (
<Textarea <InputGroup>
{...field} <InputGroupTextarea {...field}
id={`desc-${row.original.id}`} // ← estable id={`desc-${row.original.id}`} // ← estable
rows={1} rows={1}
aria-label={t("form_fields.item.description.label")} aria-label={t("form_fields.item.description.label")}
spellCheck spellCheck
readOnly={readOnly} readOnly={readOnly}
// auto-grow simple // auto-grow simple
onInput={(e) => { onInput={(e) => {
const el = e.currentTarget; const el = e.currentTarget;
el.style.height = "auto"; el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`; el.style.height = `${el.scrollHeight}px`;
}} }}
className={cn( className={cn(
"min-w-[12rem] max-w-[46rem] w-full resize-none bg-transparent border-dashed transition", "min-w-[12rem] max-w-[46rem] w-full resize-none bg-transparent border-dashed transition",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-background focus-visible:border-solid", "focus-visible:ring-2 focus-visible:ring-ring focus-visible:bg-background focus-visible:border-solid",
"focus:resize-y" "focus:resize-y"
)} )}
data-cell-focus data-cell-focus />
/> {/*<InputGroupAddon align="block-end">
<InputGroupText>Line 1, Column 1</InputGroupText>
<InputGroupButton
variant="default"
className="rounded-full"
size="icon-xs"
disabled
>
<ArrowUpIcon />
<span className="sr-only">Send</span>
</InputGroupButton>
</InputGroupAddon>*/}
</InputGroup>
)} )}
/> />
), ),

View File

@ -47,22 +47,22 @@ import { CustomerInvoiceItemsSortableTableRow } from "./customer-invoice-items-s
export type RowIdData = { [x: string]: any }; export type RowIdData = { [x: string]: any };
declare module "@tanstack/react-table" {
interface TableMeta<TData extends RowData> { /*interface TableMeta<TData extends RowData> {
insertItem: (rowIndex: number, data?: unknown) => void; insertItem: (rowIndex: number, data?: unknown) => void;
appendItem: (data?: unknown) => void; appendItem: (data?: unknown) => void;
pickCatalogArticle?: () => void; pickCatalogArticle?: () => void;
pickBlock?: () => void; pickBlock?: () => void;
duplicateItems: (rowIndex?: number) => void; duplicateItems: (rowIndex?: number) => void;
deleteItems: (rowIndex?: number | number[]) => void; deleteItems: (rowIndex?: number | number[]) => void;
updateItem: ( updateItem: (
rowIndex: number, rowIndex: number,
rowData: TData & RowIdData, rowData: TData & RowIdData,
fieldName: string, fieldName: string,
value: unknown value: unknown
) => void; ) => void;
}
} }
}*/
export interface CustomerInvoiceItemsSortableProps { export interface CustomerInvoiceItemsSortableProps {
id: UniqueIdentifier; id: UniqueIdentifier;

View File

@ -50,7 +50,8 @@ export const InvoiceUpdateComp = ({
return invoiceData return invoiceData
? invoiceDtoToFormAdapter.fromDto(invoiceData, context) ? invoiceDtoToFormAdapter.fromDto(invoiceData, context)
: defaultCustomerInvoiceFormData : defaultCustomerInvoiceFormData
}, [invoiceData, context, defaultCustomerInvoiceFormData]) }, [invoiceData, context]);
const form = useHookForm<InvoiceFormData>({ const form = useHookForm<InvoiceFormData>({
resolverSchema: InvoiceFormSchema, resolverSchema: InvoiceFormSchema,
@ -58,7 +59,6 @@ export const InvoiceUpdateComp = ({
disabled: !invoiceData || isUpdating disabled: !invoiceData || isUpdating
}); });
const handleSubmit = (formData: InvoiceFormData) => { const handleSubmit = (formData: InvoiceFormData) => {
mutate( mutate(
{ id: invoice_id, data: formData }, { id: invoice_id, data: formData },
@ -81,8 +81,6 @@ export const InvoiceUpdateComp = ({
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario // Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
}; };
console.log("InvoiceUpdateComp")
return ( return (
<UnsavedChangesProvider isDirty={form.formState.isDirty}> <UnsavedChangesProvider isDirty={form.formState.isDirty}>
<AppHeader> <AppHeader>

View File

@ -56,172 +56,3 @@ export const InvoiceUpdatePage = () => {
); );
}; };
/*
const invoiceId = useUrlParamId();
const { t } = useTranslation();
const navigate = useNavigate();
const taxCatalog = useMemo(() => SpainTaxCatalogProvider(), [])
// 1) Estado de carga de la factura (query)
const {
data: invoiceData,
isLoading: isLoadingInvoice,
isError: isLoadError,
error: loadError,
} = useInvoiceQuery(invoiceId, { enabled: !!invoiceId });
// 2) Estado de actualización (mutación)
const {
mutate,
isPending: isUpdating,
isError: isUpdateError,
error: updateError,
} = useUpdateCustomerInvoice();
const context = useInvoiceContext();
// 3) Form hook
const form = useHookForm<InvoiceFormData>({
resolverSchema: InvoiceFormSchema,
defaultValues: defaultCustomerInvoiceFormData,
values: invoiceData ? invoiceDtoToFormAdapter.fromDto(invoiceData, taxCatalog) : undefined,
disabled: isUpdating,
});
// 4) Activa recálculo automático de los totales de la factura cuando hay algún cambio en importes
useInvoiceAutoRecalc(form, {
taxCatalog,
currency_code: invoiceData?.currency_code || 'EUR'
});
const handleSubmit = (formData: InvoiceFormData) => {
const { dirtyFields } = form.formState;
if (!formHasAnyDirty(dirtyFields)) {
showWarningToast("No hay cambios para guardar");
return;
}
const patchData = pickFormDirtyValues(formData, dirtyFields);
console.log(patchData);
mutate(
{ id: invoiceId!, data: patchData },
{
onSuccess(data) {
showSuccessToast(t("pages.update.successTitle"), t("pages.update.successMsg"));
// 🔹 limpiar el form e isDirty pasa a false
form.reset(data as unknown as InvoiceFormData);
},
onError(error) {
showErrorToast(t("pages.update.errorTitle"), error.message);
},
}
);
};
if (isLoadingInvoice) {
return <CustomerInvoiceEditorSkeleton />;
}
if (isLoadError) {
return (
<>
<AppBreadcrumb />
<AppContent>
<ErrorAlert
title={t("pages.update.loadErrorTitle", "No se pudo cargar la factura")}
message={
(loadError as Error)?.message ??
t("pages.update.loadErrorMsg", "Inténtalo de nuevo más tarde.")
}
/>
<div className='flex items-center justify-end'>
<BackHistoryButton />
</div>
</AppContent>
</>
);
}
if (!invoiceData)
return (
<>
<AppBreadcrumb />
<AppContent>
<NotFoundCard
title={t("pages.update.notFoundTitle", "Factura de cliente no encontrada")}
message={t("pages.update.notFoundMsg", "Revisa el identificador o vuelve al listado.")}
/>
</AppContent>
</>
);
return (
<InvoiceProvider
taxCatalog={taxCatalog}
company_id={invoiceData.company_id}
status={invoiceData.status}
language_code={invoiceData.language_code}
currency_code={invoiceData.currency_code}
>
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
<AppHeader>
<AppBreadcrumb />
<PageHeader
status={invoiceData.status}
title={
<>
{t("pages.edit.title")} {invoiceData.invoice_number}
</>
}
description={t("pages.edit.description")}
icon={<FilePenIcon className='size-12 text-primary stroke-1' aria-hidden />}
rightSlot={
<FormCommitButtonGroup
isLoading={isUpdating}
disabled={isUpdating}
cancel={{ to: "/customer-invoices/list", disabled: isUpdating }}
submit={{ formId: "customer-invoice-update-form", disabled: isUpdating }}
onBack={handleBack}
onReset={handleReset}
/>
}
/>
</AppHeader>
<AppContent>
{
isUpdateError && (
<ErrorAlert
title={t("pages.update.errorTitle", "No se pudo guardar los cambios")}
message={
(updateError as Error)?.message ??
t("pages.update.errorMsg", "Revisa los datos e inténtalo de nuevo.")
}
/>
)
}
<FormProvider {...form}>
<CustomerInvoiceEditForm
formId={"customer-invoice-update-form"} // para que el botón del header pueda hacer submit
onSubmit={handleSubmit}
onError={handleError}
className='max-w-full'
/>
</FormProvider>
</AppContent >
</UnsavedChangesProvider >
</InvoiceProvider >
);
};
*/

View File

@ -34,6 +34,7 @@ export function DataTableColumnHeader<TData, TValue>({
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="data-[state=open]:bg-accent -ml-3 h-8 text-xs text-muted-foreground text-nowrap cursor-pointer" className="data-[state=open]:bg-accent -ml-3 h-8 text-xs text-muted-foreground text-nowrap cursor-pointer"

View File

@ -1,5 +1,5 @@
import { Column } from "@tanstack/react-table" import { Column } from "@tanstack/react-table"
import { Check, PlusCircle } from "lucide-react" import { Check, PlusCircleIcon } from "lucide-react"
import * as React from "react" import * as React from "react"
import { import {
@ -36,8 +36,8 @@ export function DataTableFacetedFilter<TData, TValue>({
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 border-dashed"> <Button type="button" variant="outline" size="sm" className="h-8 border-dashed">
<PlusCircle /> <PlusCircleIcon />
{title} {title}
{selectedValues?.size > 0 && ( {selectedValues?.size > 0 && (
<> <>

View File

@ -54,6 +54,7 @@ export function DataTablePagination<TData>({
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button <Button
type="button"
variant="outline" variant="outline"
size="icon" size="icon"
className="hidden size-8 lg:flex" className="hidden size-8 lg:flex"
@ -64,6 +65,7 @@ export function DataTablePagination<TData>({
<ChevronsLeft /> <ChevronsLeft />
</Button> </Button>
<Button <Button
type="button"
variant="outline" variant="outline"
size="icon" size="icon"
className="size-8" className="size-8"
@ -74,6 +76,7 @@ export function DataTablePagination<TData>({
<ChevronLeft /> <ChevronLeft />
</Button> </Button>
<Button <Button
type="button"
variant="outline" variant="outline"
size="icon" size="icon"
className="size-8" className="size-8"
@ -84,6 +87,7 @@ export function DataTablePagination<TData>({
<ChevronRight /> <ChevronRight />
</Button> </Button>
<Button <Button
type="button"
variant="outline" variant="outline"
size="icon" size="icon"
className="hidden size-8 lg:flex" className="hidden size-8 lg:flex"

View File

@ -1,27 +1,169 @@
"use client" import { Button, Separator, Tooltip, TooltipContent, TooltipTrigger } from '@repo/shadcn-ui/components'
import { cn } from '@repo/shadcn-ui/lib/utils'
import { Button } from '@repo/shadcn-ui/components'
import { Table } from "@tanstack/react-table" import { Table } from "@tanstack/react-table"
import { ArrowDownIcon, ArrowUpIcon, CopyPlusIcon, PlusIcon, ScanIcon, TrashIcon } from 'lucide-react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from "../../locales/i18n.ts"
import { DataTableViewOptions } from './data-table-view-options.tsx' import { DataTableViewOptions } from './data-table-view-options.tsx'
import { DataTableMeta } from './data-table.tsx'
interface DataTableToolbarProps<TData> { interface DataTableToolbarProps<TData> {
table: Table<TData> table: Table<TData>
showViewOptions?: boolean
} }
export function DataTableToolbar<TData>({ export function DataTableToolbar<TData>({
table, table,
showViewOptions = true,
}: DataTableToolbarProps<TData>) { }: DataTableToolbarProps<TData>) {
const isFiltered = table.getState().columnFilters.length > 0
const { t } = useTranslation()
const meta = table.options.meta as DataTableMeta<TData>
| undefined
const rowSelection = table.getSelectedRowModel().rows;
const selectedCount = rowSelection.length;
const hasSelection = selectedCount > 0;
const selectedRowIndexes = useMemo(() => rowSelection.map((row) => row.index), [rowSelection]);
const handleAdd = useCallback(() => meta?.tableOps?.onAdd?.(table), [meta])
const handleDuplicateSelected = useCallback(
() => meta?.bulkOps?.duplicateSelected?.(selectedRowIndexes, table),
[meta, selectedRowIndexes]
)
const handleMoveSelectedUp = useCallback(
() => meta?.bulkOps?.moveSelectedUp?.(selectedRowIndexes, table),
[meta, selectedRowIndexes]
)
const handleMoveSelectedDown = useCallback(
() => meta?.bulkOps?.moveSelectedDown?.(selectedRowIndexes, table),
[meta, selectedRowIndexes]
)
const handleRemoveSelected = useCallback(
() => meta?.bulkOps?.removeSelected?.(selectedRowIndexes, table),
[meta, selectedRowIndexes]
)
return ( return (
<div className="flex items-center justify-between"> <div
<div className="flex flex-1 items-center gap-2"> className={cn(
"flex items-center justify-between gap-2 py-2",
"border-b border-muted px-1 sm:px-2"
)}
>
{/* IZQUIERDA: acciones globales y sobre selección */}
<div className="flex flex-1 items-center gap-2 flex-wrap">
{meta?.tableOps?.onAdd && (
<Button
type='button'
size="sm"
onClick={handleAdd}
aria-label={t("components.datatable.actions.add")}
>
<PlusIcon className="size-4 mr-1" aria-hidden="true" />
<span>{t("components.datatable.actions.add")}</span>
</Button>
)}
{hasSelection && (
<>
<Separator orientation="vertical" className="h-5 mx-1" />
{meta?.bulkOps?.duplicateSelected && (
<Button
type='button'
size="sm"
variant="outline"
onClick={handleDuplicateSelected}
aria-label={t("components.datatable.actions.duplicate")}
>
<CopyPlusIcon className="size-4 mr-1" aria-hidden="true" />
<span>{t("components.datatable.actions.duplicate")}</span>
</Button>
)}
{meta?.bulkOps?.moveSelectedUp && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type='button'
size="sm"
variant="outline"
onClick={handleMoveSelectedUp}
aria-label={t("components.datatable.actions.move_up")}
>
<ArrowUpIcon className="size-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("components.datatable.actions.move_up")}</TooltipContent>
</Tooltip>
)}
{meta?.bulkOps?.moveSelectedDown && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type='button'
size="sm"
variant="outline"
onClick={handleMoveSelectedDown}
aria-label={t("components.datatable.actions.move_down")}
>
<ArrowDownIcon className="size-4" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("components.datatable.actions.move_down")}</TooltipContent>
</Tooltip>
)}
{meta?.bulkOps?.removeSelected && (
<>
<Separator orientation="vertical" className="h-5 mx-1 w-1 bg-red-500" />
<Button
type='button'
size="sm"
variant="destructive"
onClick={handleRemoveSelected}
aria-label={t("components.datatable.actions.remove")}
>
<TrashIcon className="size-4 mr-1" aria-hidden="true" />
<span>{t("components.datatable.actions.remove")}</span>
</Button>
</>
)}
<Separator orientation="vertical" className="h-6 mx-1 bg-muted/50" />
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
disabled={!table.getSelectedRowModel().rows.length}
onClick={() => table.resetRowSelection()}
>
<ScanIcon className="size-4 mr-1" aria-hidden="true" />
<span>Quitar selección</span>
</Button>
</TooltipTrigger>
<TooltipContent>Quita la selección</TooltipContent>
</Tooltip>
</>
)}
</div> </div>
{/* DERECHA: opciones de vista / filtros */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DataTableViewOptions table={table} /> {showViewOptions && <DataTableViewOptions table={table} />}
<Button size="sm">Add Task</Button>
</div> </div>
</div> </div>
) )

View File

@ -21,6 +21,7 @@ export function DataTableViewOptions<TData>({
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="ml-auto hidden h-8 lg:flex" className="ml-auto hidden h-8 lg:flex"

View File

@ -4,7 +4,10 @@ import {
ColumnDef, ColumnDef,
ColumnFiltersState, ColumnFiltersState,
ColumnSizingState, ColumnSizingState,
Row,
SortingState, SortingState,
Table,
TableMeta,
VisibilityState, VisibilityState,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
@ -18,68 +21,91 @@ import {
import * as React from "react" import * as React from "react"
import { import {
Button,
Dialog, Dialog,
DialogContent, DialogContent,
Table, TableBody, DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
TableBody,
TableCell, TableCell,
Table as TableComp,
TableFooter,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow
} from '@repo/shadcn-ui/components' } from '@repo/shadcn-ui/components'
import { DataTablePagination } from './data-table-pagination.tsx' import { DataTablePagination } from './data-table-pagination.tsx'
import { DataTableToolbar } from "./data-table-toolbar.tsx" import { DataTableToolbar } from "./data-table-toolbar.tsx"
import { useTranslation } from "../../locales/i18n.ts" import { useTranslation } from "../../locales/i18n.ts"
export type DataTableOps<TData> = {
onAdd?: (table: Table<TData>) => void;
}
type DataTableRowOps = { export type DataTableRowOps<TData> = {
duplicate?(index: number): void; duplicate?(index: number, table: Table<TData>): void;
remove?(index: number): void; remove?(index: number, table: Table<TData>): void;
move?(from: number, to: number): void; move?(from: number, to: number, table: Table<TData>): void;
canMoveUp?(index: number): boolean; canMoveUp?(index: number, table: Table<TData>): boolean;
canMoveDown?(index: number, lastIndex: number): boolean; canMoveDown?(index: number, lastIndex: number, table: Table<TData>): boolean;
}; };
interface DataTableProps<TData, TValue> { export type DataTableBulkRowOps<TData> = {
duplicateSelected?: (indexes: number[], table: Table<TData>) => void;
removeSelected?: (indexes: number[], table: Table<TData>) => void;
moveSelectedUp?: (indexes: number[], table: Table<TData>) => void;
moveSelectedDown?: (indexes: number[], table: Table<TData>) => void;
};
export type DataTableMeta<TData> = TableMeta<TData> & {
tableOps?: DataTableOps<TData>
rowOps?: DataTableRowOps<TData>
bulkOps?: DataTableBulkRowOps<TData>
}
export interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[] columns: ColumnDef<TData, TValue>[]
data: TData[], data: TData[]
meta?: Record<string, any>, meta?: DataTableMeta<TData>
getRowId?: (row: TData, index: number) => string; // Configuración
pageSize?: number; readOnly?: boolean
enableRowSelection?: boolean; enablePagination?: boolean
pageSize?: number
enableRowSelection?: boolean
EditorComponent?: React.ComponentType<{ row: TData; index: number; onClose: () => void }>
renderRowEditor?: (index: number, close: () => void) => React.ReactNode; // editor modal opcional. Se muestra dentro de un Dialog. getRowId?: (row: Row<TData>, index: number) => string;
} }
export function DataTable<TData, TValue>({ export function DataTable<TData, TValue>({
columns, columns,
data, data,
meta, meta,
getRowId, readOnly = false,
enablePagination = true,
pageSize = 25, pageSize = 25,
enableRowSelection = true, enableRowSelection = false,
renderRowEditor, EditorComponent,
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const { t } = useTranslation(); const { t } = useTranslation();
const [rowSelection, setRowSelection] = React.useState({})
const [rowSelection, setRowSelection] = React.useState({}); const [sorting, setSorting] = React.useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({}); const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]); const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [sorting, setSorting] = React.useState<SortingState>([]); const [colSizes, setColSizes] = React.useState<ColumnSizingState>({})
const [colSizes, setColSizes] = React.useState<ColumnSizingState>({}); const [editIndex, setEditIndex] = React.useState<number | null>(null)
const [editIndex, setEditIndex] = React.useState<number | null>(null);
const openEditor = React.useCallback((i: number) => setEditIndex(i), []);
const closeEditor = React.useCallback(() => setEditIndex(null), []);
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns,
columnResizeMode: "onChange", columnResizeMode: "onChange",
onColumnSizingChange: setColSizes, onColumnSizingChange: setColSizes,
getRowId: getRowId ?? ((row: any, idx) => (row?.id ? String(row.id) : String(idx))), getRowId: (row: any, i) => row.id ?? String(i),
meta,
state: { state: {
columnSizing: colSizes, columnSizing: colSizes,
sorting, sorting,
@ -87,15 +113,15 @@ export function DataTable<TData, TValue>({
rowSelection, rowSelection,
columnFilters, columnFilters,
}, },
initialState: { pagination: { pageSize } }, initialState: {
meta: { ...meta, openEditor }, pagination: { pageSize },
},
enableRowSelection, enableRowSelection,
getCoreRowModel: getCoreRowModel(),
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
onSortingChange: setSorting, onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters, onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
@ -103,12 +129,14 @@ export function DataTable<TData, TValue>({
getFacetedUniqueValues: getFacetedUniqueValues(), getFacetedUniqueValues: getFacetedUniqueValues(),
}) })
const handleCloseEditor = React.useCallback(() => setEditIndex(null), [])
return ( return (
<div className="flex flex-col gap-4"> <div className='flex flex-col gap-0'>
<DataTableToolbar table={table} /> <DataTableToolbar table={table} showViewOptions={false} />
<div className="overflow-hidden rounded-md border"> <div className="overflow-hidden rounded-md border">
<Table className="w-full text-sm"> <TableComp className="w-full text-sm">
<TableHeader className="sticky top-0 bg-muted hover:bg-muted z-10"> <TableHeader className="sticky top-0 bg-muted hover:bg-muted z-10">
{table.getHeaderGroups().map((hg) => ( {table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}> <TableRow key={hg.id}>
@ -136,8 +164,8 @@ export function DataTable<TData, TValue>({
<TableBody> <TableBody>
{table.getRowModel().rows.length ? ( {table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row, i) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}> <TableRow key={row.id} data-state={row.getIsSelected() && "selected"} className='group'>
{row.getVisibleCells().map((cell) => { {row.getVisibleCells().map((cell) => {
const w = cell.column.getSize(); const w = cell.column.getSize();
const minW = cell.column.columnDef.minSize; const minW = cell.column.columnDef.minSize;
@ -160,24 +188,47 @@ export function DataTable<TData, TValue>({
)) ))
) : ( ) : (
<TableRow> <TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center"> <TableCell
{t("components.datatabla.empty")} colSpan={columns.length}
className='h-24 text-center text-muted-foreground'
>
{t("components.datatable.empty")}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
</TableBody> </TableBody>
</Table> <TableFooter>
</TableFooter>
</TableComp>
</div> </div>
<DataTablePagination table={table} /> {enablePagination && <DataTablePagination table={table} />}
{!!renderRowEditor && editIndex !== null && ( {EditorComponent && editIndex !== null && (
<Dialog open onOpenChange={(open) => (!open ? closeEditor() : null)}> <Dialog open onOpenChange={handleCloseEditor}>
<DialogContent className="max-w-xl"> <DialogContent className="max-w-3xl">
{renderRowEditor(editIndex, closeEditor)} <DialogHeader>
<DialogTitle>{t("components.datatable.editor.title")}</DialogTitle>
<DialogDescription>{t("components.datatable.editor.subtitle")}</DialogDescription>
</DialogHeader>
<div className="mt-4">
<EditorComponent
row={data[editIndex]}
index={editIndex}
onClose={handleCloseEditor}
/>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={handleCloseEditor}>
{t("common.close")}
</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
)} )}
</div> </div>
); )
} }

View File

@ -1,3 +1,4 @@
export * from "./data-table-column-header.tsx"; export * from "./data-table-column-header.tsx";
export * from "./data-table.tsx"; export * from "./data-table.tsx";
export * from "./with-row-selection.tsx";

View File

@ -19,7 +19,7 @@ export function UserNav() {
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full"> <Button type="button" variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-9 w-9"> <Avatar className="h-9 w-9">
<AvatarImage src="/avatars/03.png" alt="@shadcn" /> <AvatarImage src="/avatars/03.png" alt="@shadcn" />
<AvatarFallback>SC</AvatarFallback> <AvatarFallback>SC</AvatarFallback>

View File

@ -0,0 +1,49 @@
import { Checkbox } from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import * as React from "react";
// Columna estable (definida una vez)
const selectionCol: ColumnDef<any, unknown> = {
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
aria-label="Select row"
className="translate-y-[2px]"
/>
),
enableSorting: false,
enableHiding: false,
size: 36, minSize: 36, maxSize: 36,
};
// Función pura (sin hooks)
export function withRowSelection<T>(
base: ColumnDef<T, unknown>[],
enabled: boolean
): ColumnDef<T, unknown>[] {
if (!enabled) return base; // misma referencia si está desactivado
// Evita duplicar si ya viene incluida
if (base.length > 0 && base[0].id === selectionCol.id) return base;
return [selectionCol as ColumnDef<T, unknown>, ...base];
}
// Custom hook ergonómico
export function useWithRowSelection<T>(
baseColumns: ColumnDef<T, unknown>[],
enabled: boolean
) {
return React.useMemo(
() => withRowSelection(baseColumns, enabled),
[baseColumns, enabled]
);
}

View File

@ -12,7 +12,12 @@
"desc": "Desc", "desc": "Desc",
"hide": "Hide", "hide": "Hide",
"empty": "No results found", "empty": "No results found",
"actions": "Actions" "columns": {
"actions": "Actions"
},
"actions": {
"add": "Add new line"
}
}, },
"loading_indicator": { "loading_indicator": {
"title": "Loading...", "title": "Loading...",

View File

@ -1,6 +1,9 @@
{ {
"common": { "common": {
"actions": "Actions", "cancel": "Cancelar",
"save": "Guardar",
"close": "Cerrar",
"actions": "Acciones",
"invalid_date": "Fecha incorrecta o no válida", "invalid_date": "Fecha incorrecta o no válida",
"required": "•", "required": "•",
"modified": "modificado", "modified": "modificado",
@ -12,7 +15,16 @@
"desc": "Desc", "desc": "Desc",
"hide": "Ocultar", "hide": "Ocultar",
"empty": "No hay resultados", "empty": "No hay resultados",
"actions": "Acciones" "columns": {
"actions": "Acciones"
},
"actions": {
"add": "Añadir línea",
"duplicate": "Duplicar",
"remove": "Eliminar",
"move_up": "Subir",
"move_down": "Bajar"
}
}, },
"loading_indicator": { "loading_indicator": {
"title": "Cargando...", "title": "Cargando...",

View File

@ -8,9 +8,6 @@
"plugins": [{ "name": "typescript-plugin-css-modules" }] "plugins": [{ "name": "typescript-plugin-css-modules" }]
}, },
"include": [ "include": ["src"],
"src",
"../../modules/customer-invoices/src/web/components/editor/items/items-data-table-row-actions.tsx"
],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }