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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "1.13.0",
"version": "1.14.0",
"type": "module",
"scripts": {
"dev": "vite",
+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>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useRef, useState } from 'react';
// Arrastrar-para-reordenar generico con drag & drop nativo (sin libreria nueva). `items` debe
// tener `.id`; `onReorder(idsEnNuevoOrden)` se llama al soltar. La reubicacion se ve al instante
// mientras se arrastra (estado local optimista); el llamador persiste el orden final.
// Reutilizado por ListView (actividades, y cualquier listado que pase onReorder) y por el listado
// de tareas de una actividad.
export function useDragReorder(items, onReorder, getId = (x) => x.id) {
const [order, setOrder] = useState(items);
const orderRef = useRef(items);
const draggingId = useRef(null);
// Se sincroniza por el CONTENIDO (ids), no por la referencia del arreglo: un llamador que pase
// `algo || []` (comun) crea un arreglo nuevo en cada render aunque el contenido no cambie, y
// sincronizar por referencia ahi entra en loop infinito de renders.
const itemsKey = items.map(getId).join(',');
useEffect(() => {
if (!draggingId.current) { setOrder(items); orderRef.current = items; }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemsKey]);
useEffect(() => { orderRef.current = order; }, [order]);
function getDragHandlers(item) {
const itemId = getId(item);
return {
draggable: true,
onDragStart: (e) => { draggingId.current = itemId; e.dataTransfer.effectAllowed = 'move'; },
onDragOver: (e) => {
e.preventDefault();
if (draggingId.current === null || draggingId.current === itemId) return;
setOrder((current) => {
const from = current.findIndex((x) => getId(x) === draggingId.current);
const to = current.findIndex((x) => getId(x) === itemId);
if (from === -1 || to === -1 || from === to) return current;
const next = current.slice();
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
},
onDrop: (e) => e.preventDefault(),
onDragEnd: () => {
draggingId.current = null;
onReorder(orderRef.current.map(getId));
},
};
}
return { order, getDragHandlers, isDragging: (itemId) => draggingId.current === itemId };
}
@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react';
import { Pencil, Save, X, Plus, Trash2, Link2, Repeat } from 'lucide-react';
import { Pencil, Save, X, Plus, Trash2, Link2, Repeat, GripVertical } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { useDragReorder } from '../../hooks/useDragReorder.js';
import { updateActivity, addDependency, removeDependency } from '../../services/activities.js';
import { listTasks, createTask, updateTask, setTaskDone, deleteTask } from '../../services/tasks.js';
import { listTasks, createTask, updateTask, setTaskDone, deleteTask, reorderTasks } from '../../services/tasks.js';
import { promoteTaskToRecurring } from '../../services/recurringTasks.js';
import { listCatalog } from '../../services/catalogs.js';
import { listUsers } from '../../services/users.js';
@@ -34,6 +35,10 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []);
const { data: users } = useAsync(listUsers, []);
const { data: tasks, reload: reloadTasks } = useAsync(() => listTasks(activity.id), [activity.id]);
const { order: orderedTasks, getDragHandlers, isDragging } = useDragReorder(tasks || [], async (ids) => {
await reorderTasks(activity.id, ids);
await reloadTasks();
});
const [tab, setTab] = useState('detail');
const [editing, setEditing] = useState(false);
@@ -228,8 +233,13 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
{tab === 'tasks' && (
<div>
{(tasks || []).map((task) => (
<div key={task.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}>
{orderedTasks.map((task) => (
<div
key={task.id}
className={isDragging(task.id) ? 'drag-row dragging' : 'drag-row'}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}
{...(editingTaskId === task.id ? {} : getDragHandlers(task))}
>
{editingTaskId === task.id ? (
<>
<input
@@ -242,6 +252,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
</>
) : (
<>
<GripVertical size={15} className="drag-handle" style={{ opacity: .6, cursor: 'grab', flexShrink: 0 }} />
<input type="checkbox" checked={!!task.is_done} onChange={() => toggleTask(task)} />
<span style={{ flex: 1, fontSize: '.8rem', textDecoration: task.is_done ? 'line-through' : 'none', opacity: task.is_done ? .6 : 1 }}>{task.name}</span>
<button className="icon-btn" title={t('common_edit')} onClick={() => startEditTask(task)}><Pencil size={15} /></button>
@@ -251,7 +262,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
)}
</div>
))}
{(!tasks || tasks.length === 0) && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
{orderedTasks.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<input className="input" value={newTask} placeholder={t('task_new')} onChange={(e) => setNewTask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTaskItem()} />
<Button variant="standard" icon={Plus} onClick={addTaskItem}>{t('common_new')}</Button>
@@ -3,7 +3,7 @@ import { Plus, Save, Trash2 } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { useAsync, useAction } from '../../hooks/useAsync.js';
import { listActivitiesByProject, createActivity, getActivity, deleteActivity } from '../../services/activities.js';
import { listActivitiesByProject, createActivity, getActivity, deleteActivity, reorderActivities } from '../../services/activities.js';
import { listCatalog } from '../../services/catalogs.js';
import { listUsers } from '../../services/users.js';
import { ListView } from '../../components/ListView.jsx';
@@ -57,6 +57,11 @@ export default function ProjectActivitiesTab({ projectId }) {
if (selected) setSelected(await getActivity(selected.id));
}
async function handleReorder(ids) {
await reorderActivities(projectId, ids);
await reload();
}
function confirmDelete(row) {
openDialog({
tone: 'warning',
@@ -107,6 +112,7 @@ export default function ProjectActivitiesTab({ projectId }) {
getSearchHaystack={(a) => `${a.code} ${a.name} ${a.responsible_name || ''}`}
getRowKey={(a) => a.id}
onRowClick={openActivity}
onReorder={currentUser?.roleCode !== 'viewer' ? handleReorder : undefined}
rowActions={currentUser?.roleCode !== 'viewer' ? (a) => [
{ label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(a) },
] : undefined}
+1
View File
@@ -5,5 +5,6 @@ export const createActivity = (projectId, data) => api.post(`/projects/${project
export const getActivity = (id) => api.get(`/activities/${id}`);
export const updateActivity = (id, data) => api.put(`/activities/${id}`, data);
export const deleteActivity = (id) => api.delete(`/activities/${id}`);
export const reorderActivities = (projectId, ids) => api.put(`/projects/${projectId}/activities/reorder`, { ids });
export const addDependency = (id, dependsOnActivityId) => api.post(`/activities/${id}/dependencies`, { dependsOnActivityId });
export const removeDependency = (id, dependsOnId) => api.delete(`/activities/${id}/dependencies/${dependsOnId}`);
+1
View File
@@ -5,3 +5,4 @@ export const createTask = (activityId, data) => api.post(`/activities/${activity
export const updateTask = (id, data) => api.put(`/tasks/${id}`, data);
export const setTaskDone = (id, isDone) => api.patch(`/tasks/${id}/done`, { isDone });
export const deleteTask = (id) => api.delete(`/tasks/${id}`);
export const reorderTasks = (activityId, ids) => api.put(`/activities/${activityId}/tasks/reorder`, { ids });
+5
View File
@@ -155,6 +155,11 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
.row-menu-btn { opacity: .6; }
.row-menu-btn:hover { opacity: 1; }
/* --- Arrastrar para reordenar (ListView con onReorder, listado de tareas de una actividad) --- */
.drag-handle-cell { width: 28px; padding: 10px 4px !important; color: rgb(var(--muted)); cursor: grab; }
.drag-handle-cell:active { cursor: grabbing; }
.list-table tbody tr.dragging, .drag-row.dragging { opacity: .4; }
/* --- Badges --- */
.badge {
display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px;