feat: arrastrar con el mouse para reordenar actividades y tareas

Hook reutilizable useDragReorder (drag & drop nativo HTML5, sin
libreria nueva) usado en dos lugares: ListView (opt-in via onReorder,
deshabilitado mientras haya un ordenamiento por columna activo) para
el listado de actividades de un proyecto, y el listado de tareas de
una actividad. Orden persistido en sort_order (activities y tasks,
migracion 012, posiciones siempre 1..N).

Fix real encontrado durante las pruebas: sincronizar el estado interno
del hook por la REFERENCIA del arreglo de entrada entraba en loop
infinito de renders cuando el llamador pasa "algo || []" (un arreglo
nuevo en cada render mientras los datos cargan). Corregido para
sincronizar por el contenido (ids), no por referencia.

Probado en dev con curl (persistencia del orden) y Playwright
(arrastrar filas en el navegador, orden visible y guardado
correctamente, sin errores de consola tras el fix).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-15 12:41:19 -05:00
parent 9bb8fecf1c
commit 80981e9063
22 changed files with 183 additions and 20 deletions
+24 -5
View File
@@ -1,11 +1,15 @@
import { useMemo, useState } from 'react';
import { MoreVertical } from 'lucide-react';
import { MoreVertical, GripVertical } from 'lucide-react';
import { useUi } from '../context/ui-context.jsx';
import { useDragReorder } from '../hooks/useDragReorder.js';
import { Flyout, FlyoutItem } from './Flyout.jsx';
// ListView/DataGrid: columnas ordenables, buscador ligero sobre el haystack completo, clic en
// fila abre detalle, boton "..." abre el menu de acciones (equivalente al clic derecho).
export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowClick, rowActions, getRowKey, initialSort }) {
// `onReorder(idsEnNuevoOrden)` es opcional: si se pasa, habilita arrastrar filas con el mouse para
// definir un orden manual (columna con icono de agarre a la izquierda) — solo mientras no haya un
// ordenamiento por columna activo, para no mezclar los dos mecanismos de orden a la vez.
export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowClick, rowActions, getRowKey, initialSort, onReorder }) {
const { t } = useUi();
const [sort, setSort] = useState(initialSort || { key: null, dir: 1 });
@@ -26,6 +30,10 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
});
}, [filtered, sort, columns]);
const dragEnabled = !!onReorder && !sort.key && !searchValue;
const { order, getDragHandlers, isDragging } = useDragReorder(sorted, (ids) => onReorder?.(ids), getRowKey);
const displayRows = dragEnabled ? order : sorted;
function toggleSort(key) {
setSort((prev) => (prev.key === key ? { key, dir: prev.dir * -1 } : { key, dir: 1 }));
}
@@ -35,6 +43,7 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
<table className="list-table">
<thead>
<tr>
{dragEnabled && <th style={{ width: 28 }} />}
{columns.map((col) => (
<th key={col.key} onClick={() => col.sortable !== false && toggleSort(col.key)}>
{col.label}{sort.key === col.key ? (sort.dir === 1 ? ' ▲' : ' ▼') : ''}
@@ -44,8 +53,18 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
</tr>
</thead>
<tbody>
{sorted.map((row) => (
<tr key={getRowKey(row)} onClick={() => onRowClick?.(row)}>
{displayRows.map((row) => (
<tr
key={getRowKey(row)}
onClick={() => onRowClick?.(row)}
className={dragEnabled && isDragging(getRowKey(row)) ? 'dragging' : ''}
{...(dragEnabled ? getDragHandlers(row) : {})}
>
{dragEnabled && (
<td className="drag-handle-cell" onClick={(e) => e.stopPropagation()}>
<GripVertical size={15} />
</td>
)}
{columns.map((col) => (
<td key={col.key}>{col.render ? col.render(row) : row[col.key]}</td>
))}
@@ -66,7 +85,7 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
))}
</tbody>
</table>
{sorted.length === 0 && <div className="list-empty">{t('common_empty')}</div>}
{displayRows.length === 0 && <div className="list-empty">{t('common_empty')}</div>}
</div>
);
}