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,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}