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:
@@ -0,0 +1,8 @@
|
||||
-- Orden manual (arrastrar con el mouse) para actividades y tareas. 0 = nunca reordenado a mano
|
||||
-- (se inicializa con el id para preservar el orden actual); el endpoint de reordenar SIEMPRE usa
|
||||
-- posiciones 1..N, nunca 0, para que este UPDATE de aqui abajo sea idempotente entre deploys.
|
||||
ALTER TABLE activities ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE activities SET sort_order = id WHERE sort_order = 0;
|
||||
UPDATE tasks SET sort_order = id WHERE sort_order = 0;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "1.13.0",
|
||||
"version": "1.14.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
|
||||
@@ -9,6 +9,10 @@ export const postActivity = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.createActivity(Number(req.params.projectId), req.body, req.currentUser), 201);
|
||||
});
|
||||
|
||||
export const putReorderActivities = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.reorderActivities(Number(req.params.projectId), req.body.ids, req.currentUser));
|
||||
});
|
||||
|
||||
export const getActivity = asyncHandler(async (req, res) => {
|
||||
ok(res, await service.getActivity(Number(req.params.id)));
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ const LIST_SELECT = `
|
||||
a.priority_id, pr.name AS priority_name, pr.color AS priority_color,
|
||||
a.status_id, st.name AS status_name, st.color AS status_color, st.is_closed AS status_is_closed,
|
||||
a.start_date, a.due_date, a.actual_end_date, a.progress_pct, a.is_milestone,
|
||||
a.estimated_hours, a.created_by, a.created_at, a.updated_at
|
||||
a.estimated_hours, a.sort_order, a.created_by, a.created_at, a.updated_at
|
||||
FROM activities a
|
||||
JOIN priorities pr ON pr.id = a.priority_id
|
||||
JOIN activity_statuses st ON st.id = a.status_id
|
||||
@@ -13,7 +13,7 @@ const LIST_SELECT = `
|
||||
`;
|
||||
|
||||
export async function findByProject(projectId) {
|
||||
const [rows] = await pool.query(`${LIST_SELECT} WHERE a.project_id = ? ORDER BY a.start_date, a.id`, [projectId]);
|
||||
const [rows] = await pool.query(`${LIST_SELECT} WHERE a.project_id = ? ORDER BY a.sort_order, a.id`, [projectId]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,14 @@ export async function remove(id) {
|
||||
await pool.query('DELETE FROM activities WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
// Posiciones SIEMPRE 1..N (nunca 0 — 0 queda reservado para "nunca reordenado a mano" en la
|
||||
// migracion). Solo reordena las filas de projectId que vengan en ids, ignora el resto.
|
||||
export async function reorder(projectId, ids) {
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await pool.query('UPDATE activities SET sort_order = ? WHERE id = ? AND project_id = ?', [i + 1, ids[i], projectId]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function countTasks(id) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS c FROM tasks WHERE activity_id = ?', [id]);
|
||||
return rows[0].c;
|
||||
|
||||
@@ -118,6 +118,17 @@ export async function removeActivity(id, currentUser) {
|
||||
await recalcProjectProgress(activity.project_id);
|
||||
}
|
||||
|
||||
// Nuevo orden manual (arrastrar con el mouse): mismo permiso que crear actividades en el proyecto.
|
||||
export async function reorderActivities(projectId, ids, currentUser) {
|
||||
const project = await getProjectOr404(projectId);
|
||||
if (!(await canWriteProject(currentUser, project))) {
|
||||
throw new HttpError(403, 'No tienes permisos sobre este proyecto');
|
||||
}
|
||||
if (!Array.isArray(ids) || ids.length === 0) throw new HttpError(400, 'Falta el orden de actividades');
|
||||
await repo.reorder(projectId, ids);
|
||||
return repo.findByProject(projectId);
|
||||
}
|
||||
|
||||
export async function addDependency(activityId, dependsOnActivityId, currentUser) {
|
||||
if (activityId === dependsOnActivityId) throw new HttpError(400, 'Una actividad no puede depender de si misma');
|
||||
const activity = await repo.findById(activityId);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
deleteProjectPermanent, postMember, putMemberRole, deleteMember,
|
||||
} from './controller.js';
|
||||
import { getGantt } from '../dashboard/controller.js';
|
||||
import { getActivitiesByProject, postActivity } from '../activities/controller.js';
|
||||
import { getActivitiesByProject, postActivity, putReorderActivities } from '../activities/controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -23,5 +23,6 @@ router.put('/:id/members/:userId/role', putMemberRole);
|
||||
router.delete('/:id/members/:userId', deleteMember);
|
||||
router.get('/:projectId/activities', getActivitiesByProject);
|
||||
router.post('/:projectId/activities', postActivity);
|
||||
router.put('/:projectId/activities/reorder', putReorderActivities);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user