Facturas de cliente
This commit is contained in:
parent
d539e5b5f1
commit
0420286261
@ -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}
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 >
|
|
||||||
);
|
|
||||||
};
|
|
||||||
*/
|
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
@ -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";
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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]
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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...",
|
||||||
|
|||||||
@ -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...",
|
||||||
|
|||||||
@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user