refactor: simplify quote items table with reusable components and hooks

- Refactored QuoteItemsSortableDataTable to reduce lines from 593 to 88, maintaining API and re-exporting types.
- Introduced QuoteItemsAddActions for centralized row/article/block addition buttons.
- Created QuoteItemsEmptyState to handle empty states with title, description, icon, and actions.
- Separated components for table header, body, and drag overlay for better modularity.
- Implemented useQuoteItemsSortableTable hook to manage row selection, drag-and-drop functionality, and item manipulation.
- Added tests for the new hook to ensure functionality for duplication, deletion, and drag-and-drop operations.
- Updated documentation to reflect changes and improvements in the quote items table structure.
This commit is contained in:
David Arranz 2026-10-19 18:05:03 +02:00
parent e332fcba52
commit f5217675b5
15 changed files with 626 additions and 588 deletions

View File

@ -11,12 +11,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
Label,
Select,
SelectContent,
@ -73,14 +67,11 @@ import {
import { useChapterCollapse } from "../hooks/useChapterCollapse";
import { ChapterEditorController } from "../hooks/useChapterEditor";
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
import { RowIdData } from "./QuoteItemsSortableDataTable";
import type { RowIdData } from "./QuoteItemsSortableDataTable.types";
import { QuoteItemsEmptyState } from "./QuoteItemsEmptyState";
import { QuoteItemsSortableDataTableToolbar } from "./QuoteItemsSortableDataTableToolbar";
import { QuoteItemsSortableTableRow } from "./QuoteItemsSortableTableRow";
import { AppendBlockRowButton } from "./AppendBlockRowButton";
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
type Selection = { chapters: string[]; indexes: number[] };
const isChapter = (id: string | number) => String(id).startsWith("chapter:");
@ -146,20 +137,14 @@ function EmptyChapterTarget({
>
<TableCell colSpan={columns} className='p-4'>
<ChapterContent>
<Empty className='border-border bg-muted/20'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<FolderOpenIcon aria-hidden='true' />
</EmptyMedia>
<EmptyTitle>{t("quotes.chapters.empty_title")}</EmptyTitle>
<EmptyDescription>{t("quotes.chapters.empty")}</EmptyDescription>
</EmptyHeader>
<EmptyContent className='flex-row flex-wrap justify-center gap-2 max-w-none'>
<AppendEmptyRowButton size='sm' onClick={onAddRow} />
<AppendCatalogArticleRowButton size='sm' onClick={onAddArticle} />
<AppendBlockRowButton size='sm' onClick={onAddBlock} />
</EmptyContent>
</Empty>
<QuoteItemsEmptyState
title={t("quotes.chapters.empty_title")}
description={t("quotes.chapters.empty")}
icon={<FolderOpenIcon aria-hidden='true' />}
onAddRow={onAddRow}
onAddArticle={onAddArticle}
onAddBlock={onAddBlock}
/>
</ChapterContent>
</TableCell>
</TableRow>

View File

@ -0,0 +1,23 @@
import type { ButtonProps } from "@/ui";
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
import { AppendBlockRowButton } from "./AppendBlockRowButton";
export interface QuoteItemsAddActionsProps {
onAddRow: () => void;
onAddArticle: () => void;
onAddBlock: () => void;
size?: ButtonProps["size"];
}
export function QuoteItemsAddActions({
onAddRow, onAddArticle, onAddBlock, size,
}: QuoteItemsAddActionsProps) {
return (
<>
<AppendEmptyRowButton size={size} onClick={onAddRow} />
<AppendCatalogArticleRowButton size={size} onClick={onAddArticle} />
<AppendBlockRowButton size={size} onClick={onAddBlock} />
</>
);
}

View File

@ -0,0 +1,49 @@
import { Badge } from "@/ui";
import { DragOverlay, defaultDropAnimation } from "@dnd-kit/core";
import type { DropAnimation, UniqueIdentifier } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { createPortal } from "react-dom";
const dropAnimationConfig: DropAnimation = {
keyframes({ transform }) {
return [
{ opacity: 1, transform: CSS.Transform.toString(transform.initial) },
{
opacity: 0,
transform: CSS.Transform.toString({
...transform.final,
x: transform.final.x + 5,
y: transform.final.y + 5,
}),
},
];
},
easing: "ease-out",
sideEffects({ active }) {
active.node.animate([{ opacity: 0 }, { opacity: 1 }], {
duration: defaultDropAnimation.duration,
easing: defaultDropAnimation.easing,
});
},
};
export function QuoteItemsDragOverlay({ activeId, selectedCount }: {
activeId: UniqueIdentifier | null | undefined;
selectedCount: number;
}) {
return createPortal(
<DragOverlay dropAnimation={dropAnimationConfig} className='z-40 opacity-100'>
{activeId && (
<div className='relative flex flex-wrap'>
{selectedCount > 0 && (
<Badge variant='destructive' className='absolute z-50 flex items-center justify-center w-2 h-2 p-3 rounded-full top left -left-2 -top-2'>
{selectedCount}
</Badge>
)}
</div>
)}
</DragOverlay>,
document.body
);
}

View File

@ -0,0 +1,32 @@
import { cn } from "@/lib/utils";
import {
Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent,
} from "@/ui";
import { ListPlusIcon } from "lucide-react";
import type { ReactNode } from "react";
import { QuoteItemsAddActions } from "./QuoteItemsAddActions";
import type { QuoteItemsAddActionsProps } from "./QuoteItemsAddActions";
interface QuoteItemsEmptyStateProps extends Omit<QuoteItemsAddActionsProps, "size"> {
title: ReactNode;
description: ReactNode;
icon?: ReactNode;
className?: string;
}
export function QuoteItemsEmptyState({
title, description, icon = <ListPlusIcon aria-hidden='true' />, className, ...actions
}: QuoteItemsEmptyStateProps) {
return (
<Empty className={cn("border-border bg-muted/20", className)}>
<EmptyHeader>
<EmptyMedia variant='icon'>{icon}</EmptyMedia>
<EmptyTitle>{title}</EmptyTitle>
<EmptyDescription>{description}</EmptyDescription>
</EmptyHeader>
<EmptyContent className='max-w-none flex-row flex-wrap justify-center gap-2'>
<QuoteItemsAddActions size='sm' {...actions} />
</EmptyContent>
</Empty>
);
}

View File

@ -1,589 +1,84 @@
import { ButtonGroup, DataTableColumnHeader } from "@/components";
import {
Badge,
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
Empty,
EmptyHeader,
EmptyMedia,
EmptyTitle,
EmptyDescription,
EmptyContent,
} from "@/ui";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table";
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
DropAnimation,
KeyboardSensor,
MeasuringStrategy,
MouseSensor,
PointerSensor,
TouchSensor,
UniqueIdentifier,
closestCenter,
defaultDropAnimation,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ColumnDef,
InitialTableState,
Row,
RowData,
RowSelectionState,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Fragment, ReactNode, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { ButtonGroup } from "@/components/ButtonGroup";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/ui";
import { Table } from "@/ui/table";
import { DndContext } from "@dnd-kit/core";
import type { RowData } from "@tanstack/react-table";
import { useTranslation } from "react-i18next";
import { ListPlusIcon } from "lucide-react";
import { FieldValues, UseFieldArrayReturn } from "react-hook-form";
import { AppendBlockRowButton } from "./AppendBlockRowButton";
import { AppendCatalogArticleRowButton } from "./AppendCatalogArticleRowButton";
import { AppendEmptyRowButton } from "./AppendEmptyRowButton";
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
import { useQuoteItemsSortableTable } from "../hooks/useQuoteItemsSortableTable";
import { QuoteItemsAddActions } from "./QuoteItemsAddActions";
import { QuoteItemsDragOverlay } from "./QuoteItemsDragOverlay";
import { QuoteItemsEmptyState } from "./QuoteItemsEmptyState";
import type {
QuoteItemsSortableDataTableProps,
RowIdData,
} from "./QuoteItemsSortableDataTable.types";
import { QuoteItemsSortableDataTableToolbar } from "./QuoteItemsSortableDataTableToolbar";
import { QuoteItemsSortableTableRow } from "./QuoteItemsSortableTableRow";
import { QuoteItemsSortableTableBody } from "./QuoteItemsSortableTableBody";
import { QuoteItemsTableHeader } from "./QuoteItemsTableHeader";
export type RowIdData = { [x: string]: any };
export type {
QuoteItemsSortableDataTableProps,
QuoteItemsSortableProps,
RowIdData,
} from "./QuoteItemsSortableDataTable.types";
declare module "@tanstack/react-table" {
interface TableMeta<TData extends RowData> {
insertItemBelow?: (rowIndex: number) => void;
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 QuoteItemsSortableProps {
id: UniqueIdentifier;
}
export type QuoteItemsSortableDataTableProps<
TData extends RowData & RowIdData,
TValue = unknown,
> = {
extraControls?: ReactNode;
columns: ColumnDef<TData, TValue>[];
data: TData[];
defaultValues: Readonly<{ [x: string]: any }> | undefined;
initialState?: InitialTableState;
actions: Omit<UseFieldArrayReturn<FieldValues, "items">, "fields"> & {
pickCatalogArticle?: () => void;
pickBlock?: () => void;
getItem?: (rowIndex: number) => TData | undefined;
};
showLineDiscountColumn: boolean;
hasLineDiscounts: boolean;
onShowLineDiscountColumnChange: (value: boolean) => void;
};
const measuringConfig = {
droppable: {
strategy: MeasuringStrategy.Always,
},
};
const dropAnimationConfig: DropAnimation = {
keyframes({ transform }) {
return [
{ opacity: 1, transform: CSS.Transform.toString(transform.initial) },
{
opacity: 0,
transform: CSS.Transform.toString({
...transform.final,
x: transform.final.x + 5,
y: transform.final.y + 5,
}),
},
];
},
easing: "ease-out",
sideEffects({ active }) {
active.node.animate([{ opacity: 0 }, { opacity: 1 }], {
duration: defaultDropAnimation.duration,
easing: defaultDropAnimation.easing,
});
},
};
export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, TValue = unknown>({
columns,
extraControls,
data,
defaultValues,
initialState,
actions,
showLineDiscountColumn,
hasLineDiscounts,
onShowLineDiscountColumnChange,
}: QuoteItemsSortableDataTableProps<TData, TValue>) {
export function QuoteItemsSortableDataTable<TData extends RowData & RowIdData, TValue = unknown>(
props: QuoteItemsSortableDataTableProps<TData, TValue>
) {
const { t } = useTranslation();
const isEmpty = data.length === 0;
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [activeId, setActiveId] = useState<UniqueIdentifier | null>();
const [activeRowId, setActiveRowId] = useState<string | null>(null);
const sorteableRowIds = useMemo(() => data.map((item) => item.id), [data]);
const table = useReactTable<TData>({
data,
columns,
enableColumnResizing: false,
columnResizeMode: "onChange",
//defaultColumn,
autoResetAll: false, // <-- añadido nuevo
initialState,
state: {
rowSelection,
columnVisibility: {
...initialState?.columnVisibility,
discount: showLineDiscountColumn,
},
},
enableRowSelection: true,
enableMultiRowSelection: true,
enableSorting: false,
enableHiding: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getRowId: (originalRow) => originalRow?.id,
debugTable: false,
debugHeaders: false,
debugColumns: false,
defaultColumn: {
minSize: 0, //starting column size
size: Number.MAX_SAFE_INTEGER, //enforced during column resizing
maxSize: Number.MAX_SAFE_INTEGER, //enforced during column resizing
},
meta: {
insertItem: (rowIndex: number, data?: unknown) => {
actions.insert(rowIndex, data || defaultValues?.items[0], { shouldFocus: true });
},
appendItem: (data?: unknown) => {
actions.append(data || defaultValues?.items[0], { shouldFocus: true });
},
pickCatalogArticle: () => {
if (actions.pickCatalogArticle) {
actions?.pickCatalogArticle();
}
},
pickBlock: () => {
if (actions.pickBlock) {
actions?.pickBlock();
}
},
duplicateItems: (rowIndex?: number) => {
if (rowIndex != undefined) {
const originalData =
actions.getItem?.(rowIndex) ?? table.getRowModel().rows[rowIndex].original;
actions.insert(rowIndex + 1, { ...originalData, id: undefined }, { shouldFocus: true });
} else if (table.getSelectedRowModel().rows.length) {
const lastIndex =
table.getSelectedRowModel().rows[table.getSelectedRowModel().rows.length - 1].index;
const data = table
.getSelectedRowModel()
.rows.map((row: Row<any>) => ({ ...row.original, id: undefined }));
if (table.getRowModel().rows.length < lastIndex + 1) {
actions.append(data);
} else {
actions.insert(lastIndex + 1, data, { shouldFocus: true });
}
table.resetRowSelection();
}
},
deleteItems: (rowIndex?: number | number[]) => {
if (rowIndex != undefined) {
actions.remove(rowIndex);
} else if (table.getSelectedRowModel().rows.length > 0) {
let start = table.getSelectedRowModel().rows.length - 1;
for (; start >= 0; start--) {
const oldIndex = sorteableRowIds.indexOf(
String(table.getSelectedRowModel().rows[start].id)
);
actions.remove(oldIndex);
sorteableRowIds.splice(oldIndex, 1);
}
/*table.getSelectedRowModel().rows.forEach((row) => {
});*/
table.resetRowSelection();
} else {
actions.remove();
}
},
updateItem: (rowIndex: number, rowData: any, fieldName: string, value: unknown) => {
// Skip page index reset until after next rerender
// skipAutoResetPageIndex();
actions.update(rowIndex, { ...rowData, [`${fieldName}`]: value });
},
},
});
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {}),
useSensor(PointerSensor, {})
);
function handleDragEnd(event: DragEndEvent) {
let activeId = event.active.id;
let overId = event.over?.id;
if (overId !== undefined && activeId !== overId) {
let newIndex = sorteableRowIds.indexOf(String(overId));
if (table.getSelectedRowModel().rows.length > 1) {
table.getSelectedRowModel().rows.forEach((row, index) => {
const oldIndex = sorteableRowIds.indexOf(String(row.id));
if (index > 0) {
activeId = row.id;
newIndex = sorteableRowIds.indexOf(String(overId));
if (newIndex < oldIndex) {
newIndex = newIndex + 1;
}
}
actions.move(oldIndex, newIndex);
sorteableRowIds.splice(newIndex, 0, sorteableRowIds.splice(oldIndex, 1)[0]);
overId = row.id;
});
} else {
const oldIndex = sorteableRowIds.indexOf(String(activeId));
actions.move(oldIndex, newIndex);
}
}
setActiveId(null);
}
function handleDragStart({ active }: DragStartEvent) {
if (!table.getSelectedRowModel().rowsById[active.id]) {
table.resetRowSelection();
}
setActiveId(active.id);
}
function handleDragCancel() {
setActiveId(null);
}
function filterItems(items: string[] | Row<unknown>[]) {
if (!activeId) {
return items;
}
return items.filter((idOrRow) => {
const id = typeof idOrRow === "string" ? idOrRow : idOrRow.id;
return id === activeId || !table.getSelectedRowModel().rowsById[id];
});
}
const { table, activeId, activeRowId, setActiveRowId, rows, sortableRowIds, dragContextProps } =
useQuoteItemsSortableTable(props);
const isEmpty = props.data.length === 0;
const addActions = {
onAddRow: () => table.options.meta?.appendItem(),
onAddArticle: () => table.options.meta?.pickCatalogArticle?.(),
onAddBlock: () => table.options.meta?.pickBlock?.(),
};
return (
<DndContext
measuring={measuringConfig}
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
onDragCancel={handleDragCancel}
collisionDetection={closestCenter}
>
<DndContext {...dragContextProps}>
<Card>
<CardHeader className='sticky z-10 top-16 bg-card/90'>
<CardTitle>
<QuoteItemsSortableDataTableToolbar
table={table}
extraControls={extraControls}
showLineDiscountColumn={showLineDiscountColumn}
hasLineDiscounts={hasLineDiscounts}
onShowLineDiscountColumnChange={onShowLineDiscountColumnChange}
extraControls={props.extraControls}
showLineDiscountColumn={props.showLineDiscountColumn}
hasLineDiscounts={props.hasLineDiscounts}
onShowLineDiscountColumnChange={props.onShowLineDiscountColumnChange}
/>
</CardTitle>
</CardHeader>
<CardContent>
{isEmpty ? (
<Empty className='border-border bg-muted/20 p-4 sm:p-8'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<ListPlusIcon aria-hidden='true' />
</EmptyMedia>
<EmptyTitle>{t("quotes.empty_details.title")}</EmptyTitle>
<EmptyDescription>{t("quotes.empty_details.description")}</EmptyDescription>
</EmptyHeader>
<EmptyContent className='max-w-none flex-row flex-wrap justify-center gap-2'>
<AppendEmptyRowButton size='sm' onClick={() => table.options.meta?.appendItem()} />
<AppendCatalogArticleRowButton
size='sm'
onClick={() => table.options.meta?.pickCatalogArticle?.()}
/>
<AppendBlockRowButton
size='sm'
onClick={() => table.options.meta?.pickBlock?.()}
/>
</EmptyContent>
</Empty>
<QuoteItemsEmptyState
title={t("quotes.empty_details.title")}
description={t("quotes.empty_details.description")}
className='p-4 sm:p-8'
{...addActions}
/>
) : (
<Table className='table-fixed'>
<TableHeader className='sticky top-0 z-10 bg-background'>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className='hover:bg-transparent'>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
className='px-2 py-1'
style={{
width:
header.getSize() === Number.MAX_SAFE_INTEGER
? "auto"
: header.getSize(),
}}
>
{header.isPlaceholder ? null : (
<DataTableColumnHeader table={table} header={header} />
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
<SortableContext
items={filterItems(sorteableRowIds)}
strategy={verticalListSortingStrategy}
>
{filterItems(table.getRowModel().rows).map((row) => {
const typedRow = row as Row<TData>;
const isActive = typedRow.id === activeRowId;
return (
<Fragment key={typedRow.id}>
<QuoteItemsSortableTableRow
id={typedRow.id}
isActive={isActive}
onActivate={() => setActiveRowId(typedRow.id)}
>
{typedRow.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className='px-2 py-2 align-top'
style={{
width:
cell.column.getSize() === Number.MAX_SAFE_INTEGER
? "auto"
: cell.column.getSize(),
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</QuoteItemsSortableTableRow>
{isActive && (
<TableRow className='border border-t-0 border-l-4 border-destructive/30 border-l-destructive bg-destructive/5 hover:bg-destructive/5'>
<TableCell
colSpan={typedRow.getVisibleCells().length}
className='px-2 py-2'
>
<QuoteItemsInlineRowActions
row={typedRow}
table={table}
onDelete={() => setActiveRowId(null)}
/>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</SortableContext>
</TableBody>
<QuoteItemsTableHeader table={table} />
<QuoteItemsSortableTableBody
table={table}
rows={rows}
sortableRowIds={sortableRowIds}
activeRowId={activeRowId}
onActivate={setActiveRowId}
onDeactivate={() => setActiveRowId(null)}
/>
</Table>
)}
{createPortal(
<DragOverlay dropAnimation={dropAnimationConfig} className={"z-40 opacity-100"}>
{activeId && (
<div className='relative flex flex-wrap'>
{table.getSelectedRowModel().rows.length ? (
<Badge
variant='destructive'
className='absolute z-50 flex items-center justify-center w-2 h-2 p-3 rounded-full top left -left-2 -top-2'
>
{table.getSelectedRowModel().rows.length}
</Badge>
) : null}
</div>
)}
</DragOverlay>,
document.body
)}
{false &&
createPortal(
<DragOverlay dropAnimation={dropAnimationConfig} className={"z-40 opacity-100"}>
{activeId && (
<div className='relative flex flex-wrap'>
{table.getSelectedRowModel().rows.length ? (
<Badge
variant='destructive'
className='absolute z-50 flex items-center justify-center w-2 h-2 p-3 rounded-full top left -left-2 -top-2'
>
{table.getSelectedRowModel().rows.length}
</Badge>
) : null}
<div className='absolute z-40 bg-white border rounded shadow opacity-100 top left hover:bg-white border-muted-foreground/50'>
<Table>
<TableBody>
{table.getRowModel().rows.map(
(row) =>
row.id === activeId && (
<TableRow key={row.id} id={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
className='p-1 align-top'
key={cell.id}
style={{ width: cell.column.getSize() }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
)}
</TableBody>
</Table>
</div>
{table.getSelectedRowModel().rows.length > 1 && (
<div className='absolute z-30 transform -translate-x-1 translate-y-1 bg-white border rounded shadow opacity-100 hover:bg-white border-muted-foreground/50 top left rotate-1'>
<Table>
<TableBody>
{table.getRowModel().rows.map(
(row) =>
row.id === activeId && (
<TableRow key={row.id} id={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
className='p-1 align-top'
key={cell.id}
style={{ width: cell.column.getSize() }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
)}
</TableBody>
</Table>
</div>
)}
{table.getSelectedRowModel().rows.length > 2 && (
<div className='absolute z-20 transform translate-x-1 -translate-y-1 bg-white border rounded shadow opacity-100 hover:bg-white border-muted-foreground/50 top left -rotate-1'>
<Table>
<TableBody>
{table.getRowModel().rows.map(
(row) =>
row.id === activeId && (
<TableRow key={row.id} id={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
className='p-1 align-top'
key={cell.id}
style={{ width: cell.column.getSize() }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
)}
</TableBody>
</Table>
</div>
)}
{table.getSelectedRowModel().rows.length > 3 && (
<div className='absolute z-10 transform translate-x-2 -translate-y-2 bg-white border rounded shadow opacity-100 hover:bg-white border-muted-foreground/50 top left rotate-2'>
<Table>
<TableBody>
{table.getRowModel().rows.map(
(row) =>
row.id === activeId && (
<TableRow key={row.id} id={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
className='p-1 align-top'
key={cell.id}
style={{ width: cell.column.getSize() }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
)}
</TableBody>
</Table>
</div>
)}
</div>
)}
</DragOverlay>,
document.body
)}
<QuoteItemsDragOverlay
activeId={activeId}
selectedCount={table.getSelectedRowModel().rows.length}
/>
</CardContent>
{!isEmpty && (
<CardFooter>
<ButtonGroup>
<AppendEmptyRowButton onClick={() => table.options.meta?.appendItem()} />
<AppendCatalogArticleRowButton
onClick={() => {
if (table.options.meta && table.options.meta.pickCatalogArticle) {
table.options.meta?.pickCatalogArticle();
}
}}
/>
<AppendBlockRowButton
onClick={() => {
if (table.options.meta && table.options.meta.pickBlock) {
table.options.meta?.pickBlock();
}
}}
/>
<QuoteItemsAddActions {...addActions} />
</ButtonGroup>
</CardFooter>
)}

View File

@ -0,0 +1,48 @@
import type { UniqueIdentifier } from "@dnd-kit/core";
import type { ColumnDef, InitialTableState, RowData } from "@tanstack/react-table";
import type { ReactNode } from "react";
import type { FieldValues, UseFieldArrayReturn } from "react-hook-form";
export type RowIdData = { [x: string]: any };
declare module "@tanstack/react-table" {
interface TableMeta<TData extends RowData> {
insertItemBelow?: (rowIndex: number) => void;
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 QuoteItemsSortableProps {
id: UniqueIdentifier;
}
export type QuoteItemsSortableDataTableProps<
TData extends RowData & RowIdData,
TValue = unknown,
> = {
extraControls?: ReactNode;
columns: ColumnDef<TData, TValue>[];
data: TData[];
defaultValues: Readonly<{ [x: string]: any }> | undefined;
initialState?: InitialTableState;
actions: Omit<UseFieldArrayReturn<FieldValues, "items">, "fields"> & {
pickCatalogArticle?: () => void;
pickBlock?: () => void;
getItem?: (rowIndex: number) => TData | undefined;
};
showLineDiscountColumn: boolean;
hasLineDiscounts: boolean;
onShowLineDiscountColumnChange: (value: boolean) => void;
};

View File

@ -137,7 +137,10 @@ describe("grouped toolbar selection actions", () => {
const unused = chapters ? lineActions : chapterActions;
for (const handler of Object.values(expected)) expect(handler).toHaveBeenCalledTimes(1);
for (const handler of Object.values(unused)) expect(handler).not.toHaveBeenCalled();
expect(container.querySelectorAll('[role="group"]')).toHaveLength(2);
// The current toolbar has one selected-actions group; chapter controls
// are separate. Verify the three grouped actions without fixing the
// number of groups in the overall layout.
expect(container.querySelector('[role="group"]')?.querySelectorAll("button")).toHaveLength(3);
expect(container.querySelector('[data-testid="pdf-settings"]')!.closest('[role="group"]')).toBeNull();
expect(container.querySelector('input[type="checkbox"]')).toBeTruthy();
} finally {

View File

@ -0,0 +1,59 @@
import { TableBody, TableCell, TableRow } from "@/ui/table";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { flexRender } from "@tanstack/react-table";
import type { Row, Table } from "@tanstack/react-table";
import { Fragment } from "react";
import { QuoteItemsInlineRowActions } from "./QuoteItemsInlineRowActions";
import { QuoteItemsSortableTableRow } from "./QuoteItemsSortableTableRow";
interface QuoteItemsSortableTableBodyProps<TData> {
table: Table<TData>;
rows: Row<TData>[];
sortableRowIds: string[];
activeRowId: string | null;
onActivate: (id: string) => void;
onDeactivate: () => void;
}
export function QuoteItemsSortableTableBody<TData>({
table, rows, sortableRowIds, activeRowId, onActivate, onDeactivate,
}: QuoteItemsSortableTableBodyProps<TData>) {
return (
<TableBody>
<SortableContext items={sortableRowIds} strategy={verticalListSortingStrategy}>
{rows.map((row) => {
const isActive = row.id === activeRowId;
return (
<Fragment key={row.id}>
<QuoteItemsSortableTableRow
id={row.id}
isActive={isActive}
onActivate={() => onActivate(row.id)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className='px-2 py-2 align-top'
style={{
width: cell.column.getSize() === Number.MAX_SAFE_INTEGER
? "auto" : cell.column.getSize(),
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</QuoteItemsSortableTableRow>
{isActive && (
<TableRow className='border border-t-0 border-l-4 border-destructive/30 border-l-destructive bg-destructive/5 hover:bg-destructive/5'>
<TableCell colSpan={row.getVisibleCells().length} className='px-2 py-2'>
<QuoteItemsInlineRowActions row={row} table={table} onDelete={onDeactivate} />
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</SortableContext>
</TableBody>
);
}

View File

@ -4,7 +4,7 @@ import { DraggableAttributes, DraggableSyntheticListeners } from "@dnd-kit/core"
import { defaultAnimateLayoutChanges, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { CSSProperties, PropsWithChildren, SyntheticEvent, createContext, useMemo } from "react";
import { QuoteItemsSortableProps } from "./QuoteItemsSortableDataTable";
import type { QuoteItemsSortableProps } from "./QuoteItemsSortableDataTable.types";
export interface QuoteItemsSortableTableRowContextValue {
attributes: DraggableAttributes;

View File

@ -0,0 +1,23 @@
import { DataTableColumnHeader } from "@/components/DataTable/DataTableColumnHeader";
import { TableHead, TableHeader, TableRow } from "@/ui/table";
import type { Table } from "@tanstack/react-table";
export function QuoteItemsTableHeader<TData>({ table }: { table: Table<TData> }) {
return (
<TableHeader className='sticky top-0 z-10 bg-background'>
{table.getHeaderGroups().map((group) => (
<TableRow key={group.id} className='hover:bg-transparent'>
{group.headers.map((header) => (
<TableHead
key={header.id}
className='px-2 py-1'
style={{ width: header.getSize() === Number.MAX_SAFE_INTEGER ? "auto" : header.getSize() }}
>
{!header.isPlaceholder && <DataTableColumnHeader table={table} header={header} />}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
);
}

View File

@ -1,3 +1,5 @@
export * from "./QuotePricesResume";
export * from "./QuoteResume";
export * from "./QuotesDataTable";
export * from "./QuoteItemsAddActions";
export * from "./QuoteItemsEmptyState";

View File

@ -1,3 +1,4 @@
export * from "./useDetailColumns";
export * from "./useQuotes";
export * from "./useChapterEditor";
export * from "./useQuoteItemsSortableTable";

View File

@ -0,0 +1,90 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { useFieldArray, useForm } from "react-hook-form";
import type { FieldValues, UseFormReturn } from "react-hook-form";
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
import { useQuoteItemsSortableTable } from "./useQuoteItemsSortableTable";
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
type Controller = ReturnType<typeof useQuoteItemsSortableTable<{ id: string }, unknown>>;
let controller: Controller;
let form: UseFormReturn<FieldValues>;
let root: ReturnType<typeof createRoot>;
let container: HTMLDivElement;
function Harness() {
form = useForm<FieldValues>({
defaultValues: { items: ["A", "B", "C", "D"].map((description) => ({ description })) },
});
const { fields, ...actions } = useFieldArray({ control: form.control, name: "items" });
controller = useQuoteItemsSortableTable({
columns: [{ accessorKey: "description" }],
data: fields,
defaultValues: { items: [{ description: "New" }] },
actions: { ...actions, getItem: (index) => form.getValues(`items.${index}`) },
showLineDiscountColumn: false,
});
return null;
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root.render(<Harness />));
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const descriptions = () => form.getValues("items").map((item: { description: string }) => item.description);
const start = (id: string) => act(() => controller.dragContextProps.onDragStart({ active: { id } } as DragStartEvent));
const drop = (from: string, to: string | null) => act(() => controller.dragContextProps.onDragEnd({
active: { id: from }, over: to ? { id: to } : null,
} as DragEndEvent));
it("duplicates current edited form values rather than stale field-array data", () => {
act(() => form.setValue("items.0.description", "Edited"));
act(() => controller.table.options.meta?.duplicateItems(0));
expect(descriptions()).toEqual(["Edited", "Edited", "B", "C", "D"]);
});
it("deletes only selected rows and clears selection after removal", () => {
act(() => {
controller.table.getRowModel().rows[1].toggleSelected(true);
controller.table.getRowModel().rows[3].toggleSelected(true);
});
act(() => controller.table.options.meta?.deleteItems());
expect(descriptions()).toEqual(["A", "C"]);
expect(controller.table.getSelectedRowModel().rows).toHaveLength(0);
});
it("moves selected rows together and restores all visible rows after the drop", () => {
const ids = controller.table.getRowModel().rows.map((row) => row.id);
act(() => {
controller.table.getRowModel().rows[0].toggleSelected(true);
controller.table.getRowModel().rows[1].toggleSelected(true);
});
start(ids[0]);
expect(controller.rows.map((row) => row.id)).toEqual([ids[0], ids[2], ids[3]]);
drop(ids[0], ids[3]);
expect(descriptions()).toEqual(["C", "D", "A", "B"]);
expect(controller.rows).toHaveLength(4);
expect(controller.activeId).toBeNull();
});
it("clears selection for an unselected drag and leaves data intact on cancellation or an outside drop", () => {
const ids = controller.table.getRowModel().rows.map((row) => row.id);
act(() => controller.table.getRowModel().rows[0].toggleSelected(true));
start(ids[2]);
expect(controller.table.getSelectedRowModel().rows).toHaveLength(0);
act(() => controller.dragContextProps.onDragCancel());
expect(controller.activeId).toBeNull();
start(ids[1]);
drop(ids[1], null);
expect(descriptions()).toEqual(["A", "B", "C", "D"]);
expect(controller.activeId).toBeNull();
});

View File

@ -0,0 +1,216 @@
import {
closestCenter,
KeyboardSensor,
MeasuringStrategy,
MouseSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { DragEndEvent, DragStartEvent, UniqueIdentifier } from "@dnd-kit/core";
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
import type { Row, RowData, RowSelectionState } from "@tanstack/react-table";
import { useMemo, useState } from "react";
import type {
QuoteItemsSortableDataTableProps,
RowIdData,
} from "../components/QuoteItemsSortableDataTable.types";
type Props<TData extends RowData & RowIdData, TValue> = Pick<
QuoteItemsSortableDataTableProps<TData, TValue>,
"columns" | "data" | "defaultValues" | "initialState" | "actions" | "showLineDiscountColumn"
>;
const measuringConfig = { droppable: { strategy: MeasuringStrategy.Always } };
export function useQuoteItemsSortableTable<TData extends RowData & RowIdData, TValue>({
columns,
data,
defaultValues,
initialState,
actions,
showLineDiscountColumn,
}: Props<TData, TValue>) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [activeId, setActiveId] = useState<UniqueIdentifier | null>();
const [activeRowId, setActiveRowId] = useState<string | null>(null);
const sortableRowIds = useMemo<string[]>(() => data.map((item) => item.id), [data]);
const table = useReactTable<TData>({
data,
columns,
enableColumnResizing: false,
columnResizeMode: "onChange",
autoResetAll: false,
initialState,
state: {
rowSelection,
columnVisibility: {
...initialState?.columnVisibility,
discount: showLineDiscountColumn,
},
},
enableRowSelection: true,
enableMultiRowSelection: true,
enableSorting: false,
enableHiding: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getRowId: (originalRow) => originalRow?.id,
debugTable: false,
debugHeaders: false,
debugColumns: false,
defaultColumn: {
minSize: 0,
size: Number.MAX_SAFE_INTEGER,
maxSize: Number.MAX_SAFE_INTEGER,
},
meta: {
insertItem: (rowIndex: number, data?: unknown) => {
actions.insert(rowIndex, data || defaultValues?.items[0], { shouldFocus: true });
},
appendItem: (data?: unknown) => {
actions.append(data || defaultValues?.items[0], { shouldFocus: true });
},
pickCatalogArticle: () => {
if (actions.pickCatalogArticle) {
actions?.pickCatalogArticle();
}
},
pickBlock: () => {
if (actions.pickBlock) {
actions?.pickBlock();
}
},
duplicateItems: (rowIndex?: number) => {
if (rowIndex != undefined) {
const originalData =
actions.getItem?.(rowIndex) ?? table.getRowModel().rows[rowIndex].original;
actions.insert(rowIndex + 1, { ...originalData, id: undefined }, { shouldFocus: true });
} else if (table.getSelectedRowModel().rows.length) {
const lastIndex =
table.getSelectedRowModel().rows[table.getSelectedRowModel().rows.length - 1].index;
const data = table
.getSelectedRowModel()
.rows.map((row) => ({ ...row.original, id: undefined }));
if (table.getRowModel().rows.length < lastIndex + 1) {
actions.append(data);
} else {
actions.insert(lastIndex + 1, data, { shouldFocus: true });
}
table.resetRowSelection();
}
},
deleteItems: (rowIndex?: number | number[]) => {
if (rowIndex != undefined) {
actions.remove(rowIndex);
} else if (table.getSelectedRowModel().rows.length > 0) {
let start = table.getSelectedRowModel().rows.length - 1;
for (; start >= 0; start--) {
const oldIndex = sortableRowIds.indexOf(
String(table.getSelectedRowModel().rows[start].id)
);
actions.remove(oldIndex);
sortableRowIds.splice(oldIndex, 1);
}
table.resetRowSelection();
} else {
actions.remove();
}
},
updateItem: (rowIndex, rowData, fieldName, value) => {
actions.update(rowIndex, { ...rowData, [`${fieldName}`]: value });
},
},
});
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {}),
useSensor(PointerSensor, {})
);
function handleDragEnd(event: DragEndEvent) {
let activeId = event.active.id;
let overId = event.over?.id;
if (overId !== undefined && activeId !== overId) {
let newIndex = sortableRowIds.indexOf(String(overId));
if (table.getSelectedRowModel().rows.length > 1) {
table.getSelectedRowModel().rows.forEach((row, index) => {
const oldIndex = sortableRowIds.indexOf(String(row.id));
if (index > 0) {
activeId = row.id;
newIndex = sortableRowIds.indexOf(String(overId));
if (newIndex < oldIndex) {
newIndex = newIndex + 1;
}
}
actions.move(oldIndex, newIndex);
sortableRowIds.splice(newIndex, 0, sortableRowIds.splice(oldIndex, 1)[0]);
overId = row.id;
});
} else {
const oldIndex = sortableRowIds.indexOf(String(activeId));
actions.move(oldIndex, newIndex);
}
}
setActiveId(null);
}
function handleDragStart({ active }: DragStartEvent) {
if (!table.getSelectedRowModel().rowsById[active.id]) {
table.resetRowSelection();
}
setActiveId(active.id);
}
function handleDragCancel() {
setActiveId(null);
}
function filterItems<TItem extends string | Row<TData>>(items: TItem[]): TItem[] {
if (!activeId) {
return items;
}
return items.filter((idOrRow) => {
const id = typeof idOrRow === "string" ? idOrRow : idOrRow.id;
return id === activeId || !table.getSelectedRowModel().rowsById[id];
});
}
return {
table,
activeId,
activeRowId,
setActiveRowId,
rows: filterItems(table.getRowModel().rows),
sortableRowIds: filterItems(sortableRowIds),
dragContextProps: {
sensors,
measuring: measuringConfig,
collisionDetection: closestCenter,
onDragEnd: handleDragEnd,
onDragStart: handleDragStart,
onDragCancel: handleDragCancel,
},
};
}

View File

@ -454,3 +454,15 @@ Inconsistencias observadas, sin cambios fuera del alcance:
- Al insertar el primer concepto aparece la tabla normal, sin crear capítulos. El estado también se aplica cuando una cotización vuelve a quedarse sin conceptos. Las cotizaciones con capítulos conservan sus estados vacíos propios. Nuevos textos `quotes.empty_details` en `es/en/ca/fr`.
- `QuoteChapterEditor.test.tsx` incorpora un formulario inicialmente vacío y tres pruebas de alta desde `Empty`, incluyendo los dos selectores y conservación del modo sin capítulos. Validación: tres pruebas nuevas y cinco de traducciones, tres casos existentes de tabla libre/orden de columnas, TypeScript sin emisión, ESLint acotado sin errores (cuatro avisos `any` previos), Tailwind hacia `/tmp` y `git diff --check`.
- Chromium sobre el HTML del harness revisa a 1440 y 375 px la ausencia de tabla vacía, los tres botones, ajuste al ancho y ausencia de desbordamiento. Muestras `/tmp/uecko-new-empty-*.png`; fixture temporal retirado. No se ejecutan PDF, base de datos, build de distribución ni E2E autenticado al limitarse al editor.
### Refactorización: tabla de conceptos con componentes reutilizables
- `QuoteItemsSortableDataTable.tsx` pasa de 593 a 88 líneas y queda como composición de la tarjeta, barra, tabla, estado vacío, pie y overlay. Mantiene su API y reexporta los tipos anteriores para conservar las importaciones existentes.
- `QuoteItemsAddActions.tsx` centraliza los botones de fila/artículo/bloque mediante callbacks, con tamaño configurable. `QuoteItemsEmptyState.tsx` compone `Empty` con título, descripción, icono y acciones. Ambos se reutilizan en la tabla sin capítulos y en los capítulos vacíos; estos conservan su destino droppable y animación propios.
- `QuoteItemsTableHeader.tsx`, `QuoteItemsSortableTableBody.tsx` y `QuoteItemsDragOverlay.tsx` separan cabecera genérica TanStack, filas/acciones activas y representación del arrastre. Se reutilizan las primitivas existentes de tabla, cabecera y filas; se elimina la vista previa de arrastre desactivada por `false &&`, que nunca se renderizaba.
- `useQuoteItemsSortableTable.ts` concentra selección, metadatos de edición, sensores y manejadores de arrastre, conservando sus operaciones y orden. `QuoteItemsSortableDataTable.types.ts` recoge los contratos compartidos y la ampliación de `TableMeta`. Los imports internos de tipos dejan de depender del componente principal; se mantienen los barrels del feature.
- Cuatro pruebas nuevas del hook con React Hook Form real verifican duplicación de valores editados, eliminación selectiva, movimiento conjunto de filas seleccionadas y cancelación/destino exterior sin cambios. Se corrige una expectativa obsoleta de la prueba de barra, repetida para dos modos: comprueba sus tres acciones agrupadas sin exigir un número fijo de grupos del diseño anterior. No se modifica la barra de producción.
- Validación: 40 pruebas de hook/editor/barra/asa correctas, TypeScript sin emisión, ESLint acotado sin errores (dos `any` heredados de los contratos, más dos avisos anteriores en la fila reutilizable), compilación Tailwind hacia `/tmp` y `git diff --check`. Persiste el aviso anterior de paginación React/TanStack fuera de `act` en la suite del editor.
- Chromium compara bundles locales de la versión anterior y la refactorizada: capturas idénticas byte a byte para estado vacío a 1440/375 px, filas normales, fila activa y selección a 1440 px. Comprueba también que añadir desde el estado vacío siga mostrando la tabla. Artefactos `/tmp/uecko-sortable-refactor-*`; no se añaden dependencias ni fixtures visuales al repositorio.
- No se ejecutan PDF, base de datos, build de distribución ni E2E autenticado: la refactorización afecta al cliente y conserva los flujos existentes. Las pruebas de navegador usan formulario y componentes reales en un harness local sin API. Persiste el aviso previo de Browserslist desactualizado.