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
+4
View File
@@ -13,6 +13,10 @@ export const putTask = asyncHandler(async (req, res) => {
ok(res, await service.updateTask(Number(req.params.id), req.body, req.currentUser));
});
export const putReorderTasks = asyncHandler(async (req, res) => {
ok(res, await service.reorderTasks(Number(req.params.activityId), req.body.ids, req.currentUser));
});
export const patchTaskDone = asyncHandler(async (req, res) => {
ok(res, await service.setTaskDone(Number(req.params.id), !!req.body.isDone, req.currentUser));
});
+10 -2
View File
@@ -3,14 +3,22 @@ import { pool } from '../../config/db.js';
export async function findByActivity(activityId) {
const [rows] = await pool.query(
`SELECT t.id, t.activity_id, t.name, t.responsible_user_id, u.name AS responsible_name,
t.is_done, t.due_date, t.created_at
t.is_done, t.due_date, t.sort_order, t.created_at
FROM tasks t LEFT JOIN users u ON u.id = t.responsible_user_id
WHERE t.activity_id = ? ORDER BY t.created_at`,
WHERE t.activity_id = ? ORDER BY t.sort_order, t.created_at`,
[activityId]
);
return rows;
}
// Posiciones SIEMPRE 1..N (nunca 0 — ver migracion 012). Solo reordena las tareas de activityId
// que vengan en ids, ignora el resto.
export async function reorder(activityId, ids) {
for (let i = 0; i < ids.length; i++) {
await pool.query('UPDATE tasks SET sort_order = ? WHERE id = ? AND activity_id = ?', [i + 1, ids[i], activityId]);
}
}
export async function findById(id) {
const [rows] = await pool.query('SELECT * FROM tasks WHERE id = ?', [id]);
return rows[0] || null;
+2 -1
View File
@@ -1,12 +1,13 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { getTasks, postTask, putTask, patchTaskDone, deleteTask } from './controller.js';
import { getTasks, postTask, putTask, putReorderTasks, patchTaskDone, deleteTask } from './controller.js';
const router = Router();
router.use(requireAuth);
router.get('/activities/:activityId/tasks', getTasks);
router.post('/activities/:activityId/tasks', postTask);
router.put('/activities/:activityId/tasks/reorder', putReorderTasks);
router.put('/tasks/:id', putTask);
router.patch('/tasks/:id/done', patchTaskDone);
router.delete('/tasks/:id', deleteTask);
+8
View File
@@ -48,6 +48,14 @@ export async function setTaskDone(id, isDone, currentUser) {
return updated;
}
// Nuevo orden manual (arrastrar con el mouse): mismo permiso que crear/editar tareas de la actividad.
export async function reorderTasks(activityId, ids, currentUser) {
await requireActivityAccess(activityId, currentUser);
if (!Array.isArray(ids) || ids.length === 0) throw new HttpError(400, 'Falta el orden de tareas');
await repo.reorder(activityId, ids);
return repo.findByActivity(activityId);
}
export async function removeTask(id, currentUser) {
const task = await repo.findById(id);
if (!task) throw new HttpError(404, 'Tarea no encontrada');