import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Button } from "@tailor-platform/app-shell"; import { GripVertical, Plus, Trash2 } from "lucide-react"; import type { ArrayPath, FieldArray, FieldValues, UseFieldArrayReturn, UseFormReturn, } from "react-hook-form"; import { LinesFooter, useClientPagination } from "@/components/composed/line-pagination-footer"; import { Label } from "@/components/ui/label"; export interface LineColumn { id: string; header: React.ReactNode; width?: string; align?: "left" | "right" | "center"; cell: (ctx: { index: number; form: UseFormReturn; rowId: string }) => React.ReactNode; } export interface LineItemsTableProps> { form: UseFormReturn; // eslint-disable-next-line @typescript-eslint/no-explicit-any fieldArray: UseFieldArrayReturn; columns: LineColumn[]; label?: React.ReactNode; newRow?: () => FieldArray; addLabel?: string; canRemove?: (index: number) => boolean; emptyMessage?: React.ReactNode; arrayErrorMessage?: string; sortable?: boolean; /** * When set, rows are paginated client-side (all rows stay in the field array; * only one page renders at a time) and a list-style footer is shown. Drag * reordering works within the visible page. Omit to render every row at once * (the default — unchanged behaviour for non-paginated callers). */ pageSize?: number; /** Page-size options for the footer's selector. Defaults to [10, 25, 50, 100]. */ pageSizeOptions?: number[]; } const alignClass = (align?: "left" | "right" | "center") => align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left"; /** A field plus its index in the full (unpaginated) field array. */ interface IndexedRow> { // eslint-disable-next-line @typescript-eslint/no-explicit-any field: UseFieldArrayReturn["fields"][number]; index: number; } // Shared cell + remove-button rendering used by both row variants. interface RowCellsProps { rowId: string; index: number; columns: LineColumn[]; form: UseFormReturn; removable: boolean; onRemove: () => void; } function RowCells({ rowId, index, columns, form, removable, onRemove, }: RowCellsProps) { return ( <> {columns.map((col) => ( {col.cell({ index, form, rowId })} ))} {removable && ( )} ); } // Non-sortable row: no useSortable hook, no provider dependency. function PlainRow(props: RowCellsProps) { return ( ); } // Sortable row: opt-in path that calls useSortable. Only mounted when the // parent renders a DndContext + SortableContext. function SortableRow(props: RowCellsProps) { const { rowId, index } = props; const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: rowId, }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : undefined, }; return ( ); } // Inner table — sortable variant. Renders the grip-handle column and uses // SortableRow per line. `rows` is the visible slice; each row carries its full // field-array index so drag/move operate on the underlying array, not the slice. function SortableTable>({ rows, columns, form, canRemove, remove, move, }: { rows: IndexedRow[]; columns: LineColumn[]; form: UseFormReturn; canRemove?: (index: number) => boolean; remove: (index: number) => void; move: (from: number, to: number) => void; }) { const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; // Resolve to full field-array indices via the visible rows (both endpoints // are on the current page, so cross-page drag isn't possible by design). const from = rows.find((r) => r.field.id === active.id)?.index; const to = rows.find((r) => r.field.id === over.id)?.index; if (from == null || to == null) return; move(from, to); }; return ( r.field.id)} strategy={verticalListSortingStrategy}>
))} {rows.map(({ field, index }) => ( key={field.id} rowId={field.id} index={index} columns={columns} form={form} removable={canRemove ? canRemove(index) : true} onRemove={() => remove(index)} /> ))}
{columns.map((col) => ( {col.header}
); } // Inner table — plain variant. No dnd-kit imports reach the rendered tree. function PlainTable>({ rows, columns, form, canRemove, remove, }: { rows: IndexedRow[]; columns: LineColumn[]; form: UseFormReturn; canRemove?: (index: number) => boolean; remove: (index: number) => void; }) { return (
{columns.map((col) => ( ))} {rows.map(({ field, index }) => ( key={field.id} rowId={field.id} index={index} columns={columns} form={form} removable={canRemove ? canRemove(index) : true} onRemove={() => remove(index)} /> ))}
{col.header}
); } export function LineItemsTable>({ form, fieldArray, columns, label, newRow, addLabel = "Add Line", canRemove, emptyMessage, arrayErrorMessage, sortable = false, pageSize, pageSizeOptions = [10, 25, 50, 100], }: LineItemsTableProps) { const { fields, append, remove, move } = fieldArray; const paginated = pageSize != null; // Hook is called unconditionally (rules of hooks); its result is only used // when `pageSize` is set. All rows stay in the field array; only the slice // rendered changes, so the diff / drag still see every row. const pager = useClientPagination(fields.length, pageSize ?? 100); // The full field-array index is the slice offset plus the position within the // slice — computed directly (O(n)) rather than via fields.indexOf (O(n²), and // wrong if two rows ever share object identity). const offset = paginated ? pager.start : 0; const visibleRows: IndexedRow[] = ( paginated ? fields.slice(pager.start, pager.end) : fields ).map((field, i) => ({ field, index: offset + i })); return (
{label && } {fields.length > 0 ? ( <> {sortable ? ( rows={visibleRows} columns={columns} form={form} canRemove={canRemove} remove={remove} move={move} /> ) : ( rows={visibleRows} columns={columns} form={form} canRemove={canRemove} remove={remove} /> )} {paginated ? ( ) : null} ) : ( emptyMessage &&

{emptyMessage}

)} {newRow && ( )} {arrayErrorMessage &&

{arrayErrorMessage}

}
); }