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

View File

@ -24,7 +24,7 @@ export const InvoiceEditForm = ({
const form = useFormContext<InvoiceFormData>();
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)}>
<div className="w-full border p-6 bg-background">
<InvoiceBasicInfoFields className="flex flex-col" />
@ -32,7 +32,7 @@ export const InvoiceEditForm = ({
</div>
<div className='w-full gap-6'>
<InvoiceItems className="border p-6 bg-background -p-6" />
<InvoiceItems />
</div>
<div className="w-full border p-6 bg-background">
<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 { 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
const { register } = useFormContext<InvoiceFormData>();
return (
@ -27,7 +35,7 @@ export function ItemRowEditor({ index, close }: { index: number; close: () => vo
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={close}>Close</Button>
<Button onClick={onClose}>OK</Button>
</div>
</div>
);

View File

@ -3,6 +3,7 @@
import { Row, Table } from "@tanstack/react-table";
import { DataTableRowOps } from '@repo/rdx-ui/components';
import {
Button,
Tooltip,
@ -19,64 +20,95 @@ interface DataTableRowActionsProps<TData> {
export function ItemDataTableRowActions<TData>({
row, table
}: DataTableRowActionsProps<TData>) {
const ops = (table.options.meta as any)?.rowOps as {
duplicate?: (i: number) => 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 ops = (table.options.meta as any)?.rowOps as DataTableRowOps<TData>;
const openEditor = (table.options.meta as any)?.openEditor as (i: number, table: Table<TData>) => void;
const lastRow = table.getRowModel().rows.length - 1;
const rowIndex = row.index;
return (
<div className="inline-flex items-center gap-1">
<div className="items-center gap-1 inline-flex">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Edit row" onClick={() => openEditor?.(rowIndex)}>
<PencilIcon className="size-4" />
</Button>
{openEditor && (
<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>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Duplicate row" onClick={() => ops?.duplicate?.(rowIndex)}>
<CopyIcon className="size-4" />
</Button>
{ops?.duplicate && (
<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>
<TooltipContent>Copy</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost" size="icon" aria-label="Move up"
disabled={ops?.canMoveUp ? !ops.canMoveUp(rowIndex) : rowIndex === 0}
onClick={() => ops?.move?.(rowIndex, rowIndex - 1)}
>
<ArrowUpIcon className="size-4" />
</Button>
{ops?.move && (
<Button
type='button'
className='cursor-pointer'
variant='ghost'
size='icon-sm'
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>
<TooltipContent>Up</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost" size="icon" aria-label="Move down"
disabled={ops?.canMoveDown ? !ops.canMoveDown(rowIndex, lastRow) : rowIndex === lastRow}
onClick={() => ops?.move?.(rowIndex, rowIndex + 1)}
{ops?.move && <Button
type='button'
className='cursor-pointer'
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" />
</Button>
<ArrowDownIcon className='size-4 text-muted-foreground hover:cursor-pointer' />
</Button>}
</TooltipTrigger>
<TooltipContent>Down</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Delete row" onClick={() => ops?.remove?.(rowIndex)}>
<Trash2Icon className="size-4" />
</Button>
{ops?.remove && (
<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>
<TooltipContent>Delete</TooltipContent>
</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 { useInvoiceContext } from '../../../context';
import { useInvoiceAutoRecalc } from '../../../hooks';
import { useTranslation } from '../../../i18n';
import { InvoiceFormData, defaultCustomerInvoiceItemFormData } from '../../../schemas';
import { debugIdCol } from './debug-id-col';
import { ItemRowEditor } from './item-row-editor';
import { useItemsColumns } from './use-items-columns';
@ -18,31 +21,70 @@ export const ItemsEditor = () => {
useInvoiceAutoRecalc(form, context);
const { fields, append, remove, move, insert } = useFieldArray({
const { fields, append, remove, move, insert, update } = useFieldArray({
control,
name: "items",
});
const columns = useItemsColumns();
console.log(fields);
const baseColumns = useWithRowSelection(useItemsColumns(), true);
const columns = useMemo(
() => [...baseColumns, debugIdCol],
[baseColumns]
);
return (
<div className="space-y-0">
<DataTable columns={columns} data={fields}
pageSize={999}
getRowId={(r) => (r as any).id}
<DataTable columns={columns as any} data={fields}
getRowId={row => String(row?.index)}
meta={{
rowOps: {
//duplicate: (indexRow: number) => insert(indexRow + 1, { ...getValues(`items.${indexRow}`) /*, id: crypto.randomUUID()*/ }),
remove: (indexRow: number) => remove(indexRow),
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,
tableOps: {
onAdd: () => append({ ...createEmptyItem() }),
appendItem: (item: any) => append(item),
},
rowOps: {
remove: (i: number) => remove(i),
move: (from: number, to: number) => move(from, to),
insertItem: (index: number, item: any) => insert(index, item),
duplicateItems: (indexes: number[], table: Table<InvoiceFormData>) => {
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 >
);
}

View File

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

View File

@ -1,5 +1,5 @@
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 type { ColumnDef } from "@tanstack/react-table";
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
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',
header: ({ column }) => (
@ -72,26 +49,39 @@ export function useItemsColumns(): ColumnDef<InvoiceItemFormData>[] {
control={control}
name={`items.${row.index}.description`}
render={({ field }) => (
<Textarea
{...field}
id={`desc-${row.original.id}`} // ← estable
rows={1}
aria-label={t("form_fields.item.description.label")}
spellCheck
readOnly={readOnly}
// auto-grow simple
onInput={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}}
className={cn(
"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:resize-y"
)}
data-cell-focus
/>
<InputGroup>
<InputGroupTextarea {...field}
id={`desc-${row.original.id}`} // ← estable
rows={1}
aria-label={t("form_fields.item.description.label")}
spellCheck
readOnly={readOnly}
// auto-grow simple
onInput={(e) => {
const el = e.currentTarget;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}}
className={cn(
"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:resize-y"
)}
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 };
declare module "@tanstack/react-table" {
interface TableMeta<TData extends RowData> {
insertItem: (rowIndex: number, data?: unknown) => void;
appendItem: (data?: unknown) => void;
pickCatalogArticle?: () => void;
pickBlock?: () => void;
duplicateItems: (rowIndex?: number) => void;
deleteItems: (rowIndex?: number | number[]) => void;
updateItem: (
rowIndex: number,
rowData: TData & RowIdData,
fieldName: string,
value: unknown
) => void;
}
/*interface TableMeta<TData extends RowData> {
insertItem: (rowIndex: number, data?: unknown) => void;
appendItem: (data?: unknown) => void;
pickCatalogArticle?: () => void;
pickBlock?: () => void;
duplicateItems: (rowIndex?: number) => void;
deleteItems: (rowIndex?: number | number[]) => void;
updateItem: (
rowIndex: number,
rowData: TData & RowIdData,
fieldName: string,
value: unknown
) => void;
}
}*/
export interface CustomerInvoiceItemsSortableProps {
id: UniqueIdentifier;

View File

@ -50,7 +50,8 @@ export const InvoiceUpdateComp = ({
return invoiceData
? invoiceDtoToFormAdapter.fromDto(invoiceData, context)
: defaultCustomerInvoiceFormData
}, [invoiceData, context, defaultCustomerInvoiceFormData])
}, [invoiceData, context]);
const form = useHookForm<InvoiceFormData>({
resolverSchema: InvoiceFormSchema,
@ -58,7 +59,6 @@ export const InvoiceUpdateComp = ({
disabled: !invoiceData || isUpdating
});
const handleSubmit = (formData: InvoiceFormData) => {
mutate(
{ id: invoice_id, data: formData },
@ -81,8 +81,6 @@ export const InvoiceUpdateComp = ({
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
console.log("InvoiceUpdateComp")
return (
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
<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>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
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 { Check, PlusCircle } from "lucide-react"
import { Check, PlusCircleIcon } from "lucide-react"
import * as React from "react"
import {
@ -36,8 +36,8 @@ export function DataTableFacetedFilter<TData, TValue>({
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 border-dashed">
<PlusCircle />
<Button type="button" variant="outline" size="sm" className="h-8 border-dashed">
<PlusCircleIcon />
{title}
{selectedValues?.size > 0 && (
<>

View File

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

View File

@ -1,27 +1,169 @@
"use client"
import { Button } from '@repo/shadcn-ui/components'
import { Button, Separator, Tooltip, TooltipContent, TooltipTrigger } from '@repo/shadcn-ui/components'
import { cn } from '@repo/shadcn-ui/lib/utils'
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 { DataTableMeta } from './data-table.tsx'
interface DataTableToolbarProps<TData> {
table: Table<TData>
showViewOptions?: boolean
}
export function DataTableToolbar<TData>({
table,
showViewOptions = true,
}: 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 (
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center gap-2">
<div
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>
{/* DERECHA: opciones de vista / filtros */}
<div className="flex items-center gap-2">
<DataTableViewOptions table={table} />
<Button size="sm">Add Task</Button>
{showViewOptions && <DataTableViewOptions table={table} />}
</div>
</div>
)

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@ export function UserNav() {
return (
<DropdownMenu>
<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">
<AvatarImage src="/avatars/03.png" alt="@shadcn" />
<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",
"hide": "Hide",
"empty": "No results found",
"actions": "Actions"
"columns": {
"actions": "Actions"
},
"actions": {
"add": "Add new line"
}
},
"loading_indicator": {
"title": "Loading...",

View File

@ -1,6 +1,9 @@
{
"common": {
"actions": "Actions",
"cancel": "Cancelar",
"save": "Guardar",
"close": "Cerrar",
"actions": "Acciones",
"invalid_date": "Fecha incorrecta o no válida",
"required": "•",
"modified": "modificado",
@ -12,7 +15,16 @@
"desc": "Desc",
"hide": "Ocultar",
"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": {
"title": "Cargando...",

View File

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